4 Commits

Author SHA1 Message Date
385959a1bd docs: regenerate config example 2026-03-16 01:09:06 -07:00
fbfd688109 Route OSD notifications through overlay with mpv fallback
- Add main/renderer overlay notification pipeline with loading-state support
- Preserve notificationType semantics across osd/system/both/none routing
- Update plugin fallback behavior, docs/config examples, and regression tests
2026-03-16 00:52:13 -07:00
e35aac6ee0 chore: prepare v0.6.5 release 2026-03-15 21:05:58 -07:00
fe2da22d29 chore: prepare v0.6.4 release 2026-03-15 20:43:50 -07:00
37 changed files with 1067 additions and 97 deletions

View File

@@ -386,11 +386,6 @@ jobs:
exit 1
fi
- name: Install makepkg
run: |
sudo apt-get update
sudo apt-get install -y makepkg
- name: Configure SSH for AUR
env:
AUR_SSH_PRIVATE_KEY: ${{ secrets.AUR_SSH_PRIVATE_KEY }}
@@ -426,6 +421,7 @@ jobs:
version_no_v="${{ steps.version.outputs.VERSION }}"
version_no_v="${version_no_v#v}"
cp packaging/aur/subminer-bin/PKGBUILD aur-subminer-bin/PKGBUILD
cp packaging/aur/subminer-bin/.SRCINFO aur-subminer-bin/.SRCINFO
bash scripts/update-aur-package.sh \
--pkg-dir aur-subminer-bin \
--version "${{ steps.version.outputs.VERSION }}" \

View File

@@ -1,5 +1,15 @@
# Changelog
## v0.6.5 (2026-03-15)
### Internal
- Release: Seed the AUR checkout with the repo `.SRCINFO` template before rewriting metadata so tagged releases do not depend on prior AUR state.
## v0.6.4 (2026-03-15)
### Internal
- Release: Reworked AUR metadata generation to update `.SRCINFO` directly instead of depending on runner `makepkg`, fixing tagged release publishing for `subminer-bin`.
## v0.6.3 (2026-03-15)
### Changed

View File

@@ -0,0 +1,51 @@
---
id: TASK-166
title: Replace OSD notifications with overlay notifications and runtime fallback
status: In Progress
assignee: []
created_date: '2026-03-16 00:00'
updated_date: '2026-03-16 00:00'
labels:
- overlay
- notifications
- ux
dependencies: []
references:
- /home/sudacode/.t3/worktrees/SubMiner/t3code-c5d9c2f7/src/main.ts
- /home/sudacode/.t3/worktrees/SubMiner/t3code-c5d9c2f7/src/renderer/renderer.ts
- /home/sudacode/.t3/worktrees/SubMiner/t3code-c5d9c2f7/plugin/subminer/process.lua
priority: medium
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Replace SubMiner's mpv OSD-first notification flow with overlay-native notifications. The visible overlay becomes the primary in-app notification surface, with fallback to mpv OSD only when the overlay is unavailable, hidden, disabled, or failed to load. Electron/system notifications remain unchanged. Existing notification copy and loading states should be refreshed to fit the richer overlay surface, especially spinner-driven loading states.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [ ] #1 Main-process logical OSD notifications render on the overlay when the visible overlay is available and visible.
- [ ] #2 Logical OSD notifications fall back to actual mpv OSD when the overlay is hidden, disabled, unavailable, or fails to load.
- [ ] #3 Notification-type config semantics remain intact: `osd` uses the overlay-or-OSD channel, `system` remains Electron notifications, `both` uses both channels, and `none` suppresses notifications.
- [ ] #4 Overlay notifications support richer presentation for loading/progress states, including a spinner/persistent loading state.
- [ ] #5 Regression coverage verifies routing, renderer behavior, and fallback handling.
<!-- AC:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Plan:
1. Add failing tests for a pure main-process overlay notification router and a renderer overlay notification controller.
2. Add overlay notification IPC/types plus renderer DOM/controller/CSS for styled toast notifications and spinner handling.
3. Route existing logical OSD callsites through the new overlay-or-OSD fallback seam in main.
4. Trim plugin-side direct OSD usage down to true fallback/error cases.
5. Verify with targeted runtime-compat/core lanes, then escalate if runtime behavior claims remain unproven.
<!-- SECTION:NOTES:END -->

View File

@@ -0,0 +1,5 @@
type: changed
area: overlay
- Routed in-app OSD notifications through the visible overlay when available, with fallback to mpv OSD when the overlay is hidden or unavailable.
- Added overlay-native notification styling for loading, success, warning, and error states.

View File

@@ -206,7 +206,7 @@ Animated AVIF requires an AV1 encoder (`libaom-av1`, `libsvtav1`, or `librav1e`)
"overwriteImage": true, // replace existing image, or append
"mediaInsertMode": "append", // "append" or "prepend" to field content
"autoUpdateNewCards": true, // auto-update when new card detected
"notificationType": "osd" // "osd", "system", "both", or "none"
"notificationType": "osd" // "osd" prefers overlay notifications and falls back to mpv OSD; "system", "both", or "none"
}
}
```

View File

@@ -1,5 +1,11 @@
# Changelog
## v0.6.5 (2026-03-15)
- Seeded the AUR checkout with the repo `.SRCINFO` template before rewriting metadata so tagged releases do not depend on prior AUR state.
## v0.6.4 (2026-03-15)
- Reworked AUR metadata generation to update `.SRCINFO` directly instead of depending on runner `makepkg`, fixing tagged release publishing for `subminer-bin`.
## v0.6.3 (2026-03-15)
- Expanded `Alt+C` into an inline controller config/remap flow with preferred-controller saving and per-action learn mode for buttons, triggers, and stick directions.
- Automated `subminer-bin` AUR package updates from the tagged release workflow.

View File

@@ -829,7 +829,7 @@ This example is intentionally compact. The option table below documents availabl
| `ankiConnect.nPlusOne.minSentenceWords` | number | Minimum number of words required in a sentence before single unknown-word N+1 highlighting can trigger (default: `3`). |
| `ankiConnect.nPlusOne.refreshMinutes` | number | Minutes between known-word cache refreshes (default: `1440`) |
| `ankiConnect.nPlusOne.decks` | array of strings | Decks used by known-word cache refresh. Leave empty for compatibility with legacy `deck` scope. |
| `behavior.notificationType` | `"osd"`, `"system"`, `"both"`, `"none"` | Notification type on card update (default: `"osd"`) |
| `behavior.notificationType` | `"osd"`, `"system"`, `"both"`, `"none"` | Notification channel for card updates. `"osd"` prefers the visible overlay and falls back to mpv OSD when the overlay is hidden/unavailable (default: `"osd"`). |
| `behavior.autoUpdateNewCards` | `true`, `false` | Automatically update cards on creation (default: `true`) |
| `metadata.pattern` | string | Format pattern for metadata: `%f`=filename, `%F`=filename+ext, `%t`=time |
| `isLapis` | object | Lapis/shared sentence-card config: `{ enabled, sentenceCardModel }`. Sentence/audio field names are fixed to `Sentence` and `SentenceAudio`. |

View File

@@ -1,6 +1,6 @@
{
"name": "subminer",
"version": "0.6.3",
"version": "0.6.5",
"description": "All-in-one sentence mining overlay with AnkiConnect and dictionary integration",
"packageManager": "bun@1.3.5",
"main": "dist/main-entry.js",
@@ -54,7 +54,7 @@
"test:launcher": "bun run test:launcher:src",
"test:core": "bun run test:core:src",
"test:subtitle": "bun run test:subtitle:src",
"test:fast": "bun run test:config:src && bun run test:core:src && bun test src/main-entry-runtime.test.ts src/anki-integration/anki-connect-proxy.test.ts src/release-workflow.test.ts src/ci-workflow.test.ts scripts/build-changelog.test.ts scripts/mkv-to-readme-video.test.ts && bun run tsc && bun test dist/main/runtime/registry.test.js",
"test:fast": "bun run test:config:src && bun run test:core:src && bun test src/main-entry-runtime.test.ts src/anki-integration/anki-connect-proxy.test.ts src/release-workflow.test.ts src/ci-workflow.test.ts scripts/build-changelog.test.ts scripts/mkv-to-readme-video.test.ts scripts/update-aur-package.test.ts && bun run tsc && bun test dist/main/runtime/registry.test.js",
"generate:config-example": "bun run src/generate-config-example.ts",
"verify:config-example": "bun run src/verify-config-example.ts",
"start": "bun run build && electron . --start",

View File

@@ -34,6 +34,17 @@ function M.create(ctx)
return options_helper.coerce_bool(raw_pause_until_ready, false)
end
local function should_prefer_overlay_notifications()
return resolve_visible_overlay_startup()
end
local function show_overlay_fallback_osd(message)
if should_prefer_overlay_notifications() then
return
end
show_osd(message)
end
local function normalize_socket_path(path)
if type(path) ~= "string" then
return nil
@@ -98,7 +109,7 @@ function M.create(ctx)
end
disarm_auto_play_ready_gate({ resume_playback = false })
mp.set_property_native("pause", false)
show_osd(AUTO_PLAY_READY_READY_OSD)
show_overlay_fallback_osd(AUTO_PLAY_READY_READY_OSD)
subminer_log("info", "process", "Resuming playback after startup gate: " .. tostring(reason or "ready"))
end
@@ -109,11 +120,11 @@ function M.create(ctx)
end
state.auto_play_ready_gate_armed = true
mp.set_property_native("pause", true)
show_osd(AUTO_PLAY_READY_LOADING_OSD)
show_overlay_fallback_osd(AUTO_PLAY_READY_LOADING_OSD)
if type(mp.add_periodic_timer) == "function" then
state.auto_play_ready_osd_timer = mp.add_periodic_timer(2.5, function()
if state.auto_play_ready_gate_armed then
show_osd(AUTO_PLAY_READY_LOADING_OSD)
show_overlay_fallback_osd(AUTO_PLAY_READY_LOADING_OSD)
end
end)
end
@@ -298,7 +309,7 @@ function M.create(ctx)
return
end
subminer_log("info", "process", "Overlay already running")
show_osd("Already running")
show_overlay_fallback_osd("Already running")
return
end
@@ -332,7 +343,7 @@ function M.create(ctx)
end
if attempt == 1 and not state.auto_play_ready_gate_armed then
show_osd("Starting...")
show_overlay_fallback_osd("Starting...")
end
state.overlay_running = true
@@ -458,7 +469,6 @@ function M.create(ctx)
run_control_command_async("settings", nil, function(ok)
if ok 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")
@@ -474,7 +484,7 @@ function M.create(ctx)
end
subminer_log("info", "process", "Restarting overlay...")
show_osd("Restarting...")
show_overlay_fallback_osd("Restarting...")
run_control_command_async("stop", nil, function()
state.overlay_running = false
@@ -502,7 +512,7 @@ function M.create(ctx)
)
show_osd("Restart failed")
else
show_osd("Restarted successfully")
show_overlay_fallback_osd("Restarted successfully")
end
end)
end)
@@ -516,7 +526,11 @@ function M.create(ctx)
end
local status = state.overlay_running and "running" or "stopped"
show_osd("Status: overlay is " .. status)
if state.overlay_running then
show_overlay_fallback_osd("Status: overlay is " .. status)
else
show_osd("Status: overlay is " .. status)
end
subminer_log("info", "process", "Status check: overlay is " .. status)
end

View File

@@ -1,10 +1,6 @@
## Highlights
### Changed
- Overlay: Expanded the `Alt+C` controller modal into an inline config/remap flow with preferred-controller saving and per-action learn mode for buttons, triggers, and stick directions.
### Internal
- Workflow: Hardened the `subminer-scrum-master` skill to explicitly answer whether docs updates and changelog fragments are required before handoff.
- Release: Automate `subminer-bin` AUR package updates from the tagged release workflow.
- Release: Seed the AUR checkout with the repo `.SRCINFO` template before rewriting metadata so tagged releases do not depend on prior AUR state.
## Installation

View File

@@ -724,12 +724,12 @@ do
"duplicate pause-until-ready auto-start should re-assert visible overlay on both start and ready events"
)
assert_true(
count_osd_message(recorded.osd, "SubMiner: Loading subtitle tokenization...") == 2,
"duplicate pause-until-ready auto-start should arm tokenization loading gate for each file"
count_osd_message(recorded.osd, "SubMiner: Loading subtitle tokenization...") == 0,
"duplicate pause-until-ready auto-start should suppress loading OSD when visible overlay is enabled"
)
assert_true(
count_osd_message(recorded.osd, "SubMiner: Subtitle tokenization ready") == 2,
"duplicate pause-until-ready auto-start should release tokenization gate for each file"
count_osd_message(recorded.osd, "SubMiner: Subtitle tokenization ready") == 0,
"duplicate pause-until-ready auto-start should suppress ready OSD when visible overlay is enabled"
)
assert_true(
count_property_set(recorded.property_sets, "pause", true) == 2,
@@ -770,16 +770,16 @@ do
"autoplay-ready script message should resume mpv playback"
)
assert_true(
has_osd_message(recorded.osd, "SubMiner: Loading subtitle tokenization..."),
"pause-until-ready auto-start should show loading OSD message"
not has_osd_message(recorded.osd, "SubMiner: Loading subtitle tokenization..."),
"pause-until-ready auto-start should suppress loading OSD when visible overlay is enabled"
)
assert_true(
not has_osd_message(recorded.osd, "SubMiner: Starting..."),
"pause-until-ready auto-start should avoid replacing loading OSD with generic starting OSD"
)
assert_true(
has_osd_message(recorded.osd, "SubMiner: Subtitle tokenization ready"),
"autoplay-ready should show loaded OSD message"
not has_osd_message(recorded.osd, "SubMiner: Subtitle tokenization ready"),
"autoplay-ready should suppress ready OSD when visible overlay is enabled"
)
assert_true(
count_control_calls(recorded.async_calls, "--show-visible-overlay") == 2,

View File

@@ -54,6 +54,7 @@ fi
version="${version#v}"
pkgbuild="${pkg_dir}/PKGBUILD"
srcinfo="${pkg_dir}/.SRCINFO"
if [[ ! -f "$pkgbuild" ]]; then
echo "Missing PKGBUILD at $pkgbuild" >&2
@@ -118,7 +119,96 @@ awk \
' "$pkgbuild" > "$tmpfile"
mv "$tmpfile" "$pkgbuild"
(
cd "$pkg_dir"
makepkg --printsrcinfo > .SRCINFO
)
if [[ ! -f "$srcinfo" ]]; then
echo "Missing .SRCINFO at $srcinfo" >&2
exit 1
fi
tmpfile="$(mktemp)"
awk \
-v version="$version" \
-v sum_appimage="${sha256sums[0]}" \
-v sum_wrapper="${sha256sums[1]}" \
-v sum_assets="${sha256sums[2]}" \
'
BEGIN {
sha_index = 0
found_pkgver = 0
found_provides = 0
found_noextract = 0
found_source_appimage = 0
found_source_wrapper = 0
found_source_assets = 0
}
/^\tpkgver = / {
print "\tpkgver = " version
found_pkgver = 1
next
}
/^\tprovides = subminer=/ {
print "\tprovides = subminer=" version
found_provides = 1
next
}
/^\tnoextract = SubMiner-.*\.AppImage$/ {
print "\tnoextract = SubMiner-" version ".AppImage"
found_noextract = 1
next
}
/^\tsource = SubMiner-.*\.AppImage::https:\/\/github\.com\/ksyasuda\/SubMiner\/releases\/download\/v.*\/SubMiner-.*\.AppImage$/ {
print "\tsource = SubMiner-" version ".AppImage::https://github.com/ksyasuda/SubMiner/releases/download/v" version "/SubMiner-" version ".AppImage"
found_source_appimage = 1
next
}
/^\tsource = subminer::https:\/\/github\.com\/ksyasuda\/SubMiner\/releases\/download\/v.*\/subminer$/ {
print "\tsource = subminer::https://github.com/ksyasuda/SubMiner/releases/download/v" version "/subminer"
found_source_wrapper = 1
next
}
/^\tsource = subminer-assets\.tar\.gz::https:\/\/github\.com\/ksyasuda\/SubMiner\/releases\/download\/v.*\/subminer-assets\.tar\.gz$/ {
print "\tsource = subminer-assets.tar.gz::https://github.com/ksyasuda/SubMiner/releases/download/v" version "/subminer-assets.tar.gz"
found_source_assets = 1
next
}
/^\tsha256sums = / {
sha_index += 1
if (sha_index == 1) {
print "\tsha256sums = " sum_appimage
next
}
if (sha_index == 2) {
print "\tsha256sums = " sum_wrapper
next
}
if (sha_index == 3) {
print "\tsha256sums = " sum_assets
next
}
}
{
print
}
END {
if (!found_pkgver) {
print "Missing pkgver entry in .SRCINFO" > "/dev/stderr"
exit 1
}
if (!found_provides) {
print "Missing provides entry in .SRCINFO" > "/dev/stderr"
exit 1
}
if (!found_noextract) {
print "Missing noextract entry in .SRCINFO" > "/dev/stderr"
exit 1
}
if (!found_source_appimage || !found_source_wrapper || !found_source_assets) {
print "Missing source entry in .SRCINFO" > "/dev/stderr"
exit 1
}
if (sha_index < 3) {
print "Missing sha256sums entries in .SRCINFO" > "/dev/stderr"
exit 1
}
}
' "$srcinfo" > "$tmpfile"
mv "$tmpfile" "$srcinfo"

View File

@@ -0,0 +1,67 @@
import assert from 'node:assert/strict';
import { execFileSync } from 'node:child_process';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import test from 'node:test';
function createWorkspace(name: string): string {
return fs.mkdtempSync(path.join(os.tmpdir(), `${name}-`));
}
test('update-aur-package updates PKGBUILD and .SRCINFO without makepkg', () => {
const workspace = createWorkspace('subminer-aur-package');
const pkgDir = path.join(workspace, 'aur-subminer-bin');
const appImagePath = path.join(workspace, 'SubMiner-0.6.3.AppImage');
const wrapperPath = path.join(workspace, 'subminer');
const assetsPath = path.join(workspace, 'subminer-assets.tar.gz');
fs.mkdirSync(pkgDir, { recursive: true });
fs.copyFileSync('packaging/aur/subminer-bin/PKGBUILD', path.join(pkgDir, 'PKGBUILD'));
fs.copyFileSync('packaging/aur/subminer-bin/.SRCINFO', path.join(pkgDir, '.SRCINFO'));
fs.writeFileSync(appImagePath, 'appimage');
fs.writeFileSync(wrapperPath, 'wrapper');
fs.writeFileSync(assetsPath, 'assets');
try {
execFileSync(
'bash',
[
'scripts/update-aur-package.sh',
'--pkg-dir',
pkgDir,
'--version',
'v0.6.3',
'--appimage',
appImagePath,
'--wrapper',
wrapperPath,
'--assets',
assetsPath,
],
{
cwd: process.cwd(),
encoding: 'utf8',
},
);
const pkgbuild = fs.readFileSync(path.join(pkgDir, 'PKGBUILD'), 'utf8');
const srcinfo = fs.readFileSync(path.join(pkgDir, '.SRCINFO'), 'utf8');
const expectedSums = [appImagePath, wrapperPath, assetsPath].map((filePath) =>
execFileSync('sha256sum', [filePath], { encoding: 'utf8' }).split(/\s+/)[0],
);
assert.match(pkgbuild, /^pkgver=0\.6\.3$/m);
assert.match(srcinfo, /^\tpkgver = 0\.6\.3$/m);
assert.match(srcinfo, /^\tprovides = subminer=0\.6\.3$/m);
assert.match(
srcinfo,
/^\tsource = SubMiner-0\.6\.3\.AppImage::https:\/\/github\.com\/ksyasuda\/SubMiner\/releases\/download\/v0\.6\.3\/SubMiner-0\.6\.3\.AppImage$/m,
);
assert.match(srcinfo, new RegExp(`^\\tsha256sums = ${expectedSums[0]}$`, 'm'));
assert.match(srcinfo, new RegExp(`^\\tsha256sums = ${expectedSums[1]}$`, 'm'));
assert.match(srcinfo, new RegExp(`^\\tsha256sums = ${expectedSums[2]}$`, 'm'));
} finally {
fs.rmSync(workspace, { recursive: true, force: true });
}
});

View File

@@ -361,7 +361,7 @@ import { registerIpcRuntimeServices } from './main/ipc-runtime';
import { createAnkiJimakuIpcRuntimeServiceDeps } from './main/dependencies';
import { handleCliCommandRuntimeServiceWithContext } from './main/cli-runtime';
import { createOverlayModalRuntimeService } from './main/overlay-runtime';
import type { OverlayHostedModal } from './shared/ipc/contracts';
import { IPC_CHANNELS, type OverlayHostedModal } from './shared/ipc/contracts';
import { createOverlayShortcutsRuntimeService } from './main/overlay-shortcuts-runtime';
import {
createFrequencyDictionaryRuntimeService,
@@ -377,6 +377,11 @@ import { createCharacterDictionaryRuntimeService } from './main/character-dictio
import { createCharacterDictionaryAutoSyncRuntimeService } from './main/runtime/character-dictionary-auto-sync';
import { notifyCharacterDictionaryAutoSyncStatus } from './main/runtime/character-dictionary-auto-sync-notifications';
import { createCurrentMediaTokenizationGate } from './main/runtime/current-media-tokenization-gate';
import {
createConfiguredNotificationHandler,
createShowLogicalOsdHandler,
createShowOverlayNotificationHandler,
} from './main/runtime/overlay-notifications';
import { createStartupOsdSequencer } from './main/runtime/startup-osd-sequencer';
import { createYomitanProfilePolicy } from './main/runtime/yomitan-profile-policy';
import { formatSkippedYomitanWriteAction } from './main/runtime/yomitan-read-only-log';
@@ -1120,8 +1125,7 @@ syncOverlayShortcutsForModal = (isActive: boolean): void => {
const buildConfigHotReloadMessageMainDepsHandler = createBuildConfigHotReloadMessageMainDepsHandler(
{
showMpvOsd: (message) => showMpvOsd(message),
showDesktopNotification: (title, options) => showDesktopNotification(title, options),
showConfiguredNotification: (title, payload) => showConfiguredNotification(title, payload),
},
);
const configHotReloadMessageMainDeps = buildConfigHotReloadMessageMainDepsHandler();
@@ -1921,9 +1925,7 @@ const {
registerSubminerProtocolClient,
} = composeAnilistSetupHandlers({
notifyDeps: {
hasMpvClient: () => Boolean(appState.mpvClient),
showMpvOsd: (message) => showMpvOsd(message),
showDesktopNotification: (title, options) => showDesktopNotification(title, options),
showConfiguredNotification: (title, payload) => showConfiguredNotification(title, payload),
logInfo: (message) => logger.info(message),
},
consumeTokenDeps: {
@@ -3123,8 +3125,10 @@ function openYomitanSettings(): boolean {
logger.warn(
'Yomitan settings window disabled while yomitan.externalProfilePath is configured because external profile mode is read-only.',
);
showDesktopNotification('SubMiner', { body: message });
showMpvOsd(message);
showConfiguredNotification('SubMiner', {
kind: 'warning',
message,
});
return false;
}
openYomitanSettingsHandler();
@@ -3177,7 +3181,7 @@ const {
},
});
const { flushMpvLog, showMpvOsd } = createMpvOsdRuntimeHandlers({
const { flushMpvLog, showMpvOsd: showActualMpvOsd } = createMpvOsdRuntimeHandlers({
appendToMpvLogMainDeps: {
logPath: DEFAULT_MPV_LOG_PATH,
dirname: (targetPath) => path.dirname(targetPath),
@@ -3200,6 +3204,21 @@ const { flushMpvLog, showMpvOsd } = createMpvOsdRuntimeHandlers({
flushPendingMpvLogWrites = () => {
void flushMpvLog();
};
const showOverlayNotification = createShowOverlayNotificationHandler({
isOverlayRuntimeInitialized: () => appState.overlayRuntimeInitialized,
getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(),
getMainWindow: () => overlayManager.getMainWindow(),
notificationChannel: IPC_CHANNELS.event.overlayNotification,
});
const showMpvOsd = createShowLogicalOsdHandler({
showOverlayNotification: (payload) => showOverlayNotification(payload),
showMpvOsd: (message) => showActualMpvOsd(message),
});
const showConfiguredNotification = createConfiguredNotificationHandler({
getNotificationType: () => getResolvedConfig().ankiConnect.behavior.notificationType,
showLogicalOsd: (payload) => showMpvOsd(payload),
showDesktopNotification: (title, options) => showDesktopNotification(title, options),
});
const cycleSecondarySubMode = createCycleSecondarySubModeRuntimeHandler({
cycleSecondarySubModeMainDeps: {

View File

@@ -10,17 +10,14 @@ import {
test('notify anilist setup main deps builder maps callbacks', () => {
const calls: string[] = [];
const deps = createBuildNotifyAnilistSetupMainDepsHandler({
hasMpvClient: () => true,
showMpvOsd: (message) => calls.push(`osd:${message}`),
showDesktopNotification: (title) => calls.push(`notify:${title}`),
showConfiguredNotification: (title, payload) =>
calls.push(`configured:${title}:${payload.kind}:${payload.message}`),
logInfo: (message) => calls.push(`log:${message}`),
})();
assert.equal(deps.hasMpvClient(), true);
deps.showMpvOsd('ok');
deps.showDesktopNotification('SubMiner', { body: 'x' });
deps.showConfiguredNotification('SubMiner', { kind: 'success', message: 'ok' });
deps.logInfo('done');
assert.deepEqual(calls, ['osd:ok', 'notify:SubMiner', 'log:done']);
assert.deepEqual(calls, ['configured:SubMiner:success:ok', 'log:done']);
});
test('consume anilist setup token main deps builder maps callbacks', () => {

View File

@@ -18,10 +18,7 @@ type RegisterSubminerProtocolClientMainDeps = Parameters<
export function createBuildNotifyAnilistSetupMainDepsHandler(deps: NotifyAnilistSetupMainDeps) {
return (): NotifyAnilistSetupMainDeps => ({
hasMpvClient: () => deps.hasMpvClient(),
showMpvOsd: (message: string) => deps.showMpvOsd(message),
showDesktopNotification: (title: string, options: { body: string }) =>
deps.showDesktopNotification(title, options),
showConfiguredNotification: (title, payload) => deps.showConfiguredNotification(title, payload),
logInfo: (message: string) => deps.logInfo(message),
});
}

View File

@@ -7,16 +7,18 @@ import {
createRegisterSubminerProtocolClientHandler,
} from './anilist-setup-protocol';
test('createNotifyAnilistSetupHandler sends OSD when mpv client exists', () => {
test('createNotifyAnilistSetupHandler routes AniList setup messages through configured notifications', () => {
const calls: string[] = [];
const notify = createNotifyAnilistSetupHandler({
hasMpvClient: () => true,
showMpvOsd: (message) => calls.push(`osd:${message}`),
showDesktopNotification: () => calls.push('desktop'),
showConfiguredNotification: (title, payload) =>
calls.push(`configured:${title}:${payload.kind}:${payload.message}`),
logInfo: () => calls.push('log'),
});
notify('AniList login success');
assert.deepEqual(calls, ['osd:AniList login success']);
assert.deepEqual(calls, [
'configured:SubMiner AniList:success:AniList login success',
'log',
]);
});
test('createConsumeAnilistSetupTokenFromUrlHandler delegates with deps', () => {

View File

@@ -29,18 +29,28 @@ export function createConsumeAnilistSetupTokenFromUrlHandler(deps: ConsumeAnilis
});
}
import type { OverlayNotificationPayload } from '../../types';
function resolveAnilistNotificationKind(message: string): OverlayNotificationPayload['kind'] {
const normalized = message.toLowerCase();
if (normalized.includes('failed') || normalized.includes('error')) {
return 'error';
}
if (normalized.includes('success')) {
return 'success';
}
return 'info';
}
export function createNotifyAnilistSetupHandler(deps: {
hasMpvClient: () => boolean;
showMpvOsd: (message: string) => void;
showDesktopNotification: (title: string, options: { body: string }) => void;
showConfiguredNotification: (title: string, payload: OverlayNotificationPayload) => void;
logInfo: (message: string) => void;
}) {
return (message: string): void => {
if (deps.hasMpvClient()) {
deps.showMpvOsd(message);
return;
}
deps.showDesktopNotification('SubMiner AniList', { body: message });
deps.showConfiguredNotification('SubMiner AniList', {
kind: resolveAnilistNotificationKind(message),
message,
});
deps.logInfo(`[AniList setup] ${message}`);
};
}

View File

@@ -60,7 +60,7 @@ test('auto sync notifications send osd updates for progress phases', () => {
]);
});
test('auto sync notifications never send desktop notifications', () => {
test('auto sync notifications send desktop notifications when the type includes system', () => {
const calls: string[] = [];
notifyCharacterDictionaryAutoSyncStatus(makeEvent('syncing', 'syncing'), {
@@ -88,5 +88,27 @@ test('auto sync notifications never send desktop notifications', () => {
calls.push(`desktop:${title}:${options.body ?? ''}`),
});
assert.deepEqual(calls, ['osd:syncing', 'osd:importing', 'osd:ready', 'osd:failed']);
assert.deepEqual(calls, [
'osd:syncing',
'desktop:SubMiner:syncing',
'osd:importing',
'desktop:SubMiner:importing',
'osd:ready',
'desktop:SubMiner:ready',
'osd:failed',
'desktop:SubMiner:failed',
]);
});
test('auto sync notifications respect system-only mode', () => {
const calls: string[] = [];
notifyCharacterDictionaryAutoSyncStatus(makeEvent('syncing', 'syncing'), {
getNotificationType: () => 'system',
showOsd: (message) => calls.push(`osd:${message}`),
showDesktopNotification: (title, options) =>
calls.push(`desktop:${title}:${options.body ?? ''}`),
});
assert.deepEqual(calls, ['desktop:SubMiner:syncing']);
});

View File

@@ -1,5 +1,6 @@
import type { CharacterDictionaryAutoSyncStatusEvent } from './character-dictionary-auto-sync';
import type { StartupOsdSequencerCharacterDictionaryEvent } from './startup-osd-sequencer';
import { shouldShowDesktopNotification, shouldShowLogicalOsd } from './overlay-notifications';
export type CharacterDictionaryAutoSyncNotificationEvent = CharacterDictionaryAutoSyncStatusEvent;
@@ -12,23 +13,23 @@ export interface CharacterDictionaryAutoSyncNotificationDeps {
};
}
function shouldShowOsd(type: 'osd' | 'system' | 'both' | 'none' | undefined): boolean {
return type !== 'none';
}
export function notifyCharacterDictionaryAutoSyncStatus(
event: CharacterDictionaryAutoSyncNotificationEvent,
deps: CharacterDictionaryAutoSyncNotificationDeps,
): void {
const type = deps.getNotificationType();
if (shouldShowOsd(type)) {
if (shouldShowLogicalOsd(type)) {
if (deps.startupOsdSequencer) {
deps.startupOsdSequencer.notifyCharacterDictionaryStatus({
phase: event.phase,
message: event.message,
});
return;
} else {
deps.showOsd(event.message);
}
deps.showOsd(event.message);
}
if (shouldShowDesktopNotification(type)) {
deps.showDesktopNotification('SubMiner', { body: event.message });
}
}

View File

@@ -5,9 +5,7 @@ import { composeAnilistSetupHandlers } from './anilist-setup-composer';
test('composeAnilistSetupHandlers returns callable setup handlers', () => {
const composed = composeAnilistSetupHandlers({
notifyDeps: {
hasMpvClient: () => false,
showMpvOsd: () => {},
showDesktopNotification: () => {},
showConfiguredNotification: () => {},
logInfo: () => {},
},
consumeTokenDeps: {

View File

@@ -70,12 +70,12 @@ test('createConfigHotReloadAppliedHandler skips optional effects when no hot fie
test('createConfigHotReloadMessageHandler mirrors message to OSD and desktop notification', () => {
const calls: string[] = [];
const handleMessage = createConfigHotReloadMessageHandler({
showMpvOsd: (message) => calls.push(`osd:${message}`),
showDesktopNotification: (title, options) => calls.push(`notify:${title}:${options.body}`),
showConfiguredNotification: (title, payload) =>
calls.push(`configured:${title}:${payload.kind}:${payload.message}`),
});
handleMessage('Config reload failed');
assert.deepEqual(calls, ['osd:Config reload failed', 'notify:SubMiner:Config reload failed']);
assert.deepEqual(calls, ['configured:SubMiner:warning:Config reload failed']);
});
test('buildRestartRequiredConfigMessage formats changed fields', () => {

View File

@@ -1,7 +1,12 @@
import type { ConfigHotReloadDiff } from '../../core/services/config-hot-reload';
import { resolveKeybindings } from '../../core/utils/keybindings';
import { DEFAULT_KEYBINDINGS } from '../../config';
import type { ConfigHotReloadPayload, ResolvedConfig, SecondarySubMode } from '../../types';
import type {
ConfigHotReloadPayload,
OverlayNotificationPayload,
ResolvedConfig,
SecondarySubMode,
} from '../../types';
type ConfigHotReloadAppliedDeps = {
setKeybindings: (keybindings: ConfigHotReloadPayload['keybindings']) => void;
@@ -14,8 +19,7 @@ type ConfigHotReloadAppliedDeps = {
};
type ConfigHotReloadMessageDeps = {
showMpvOsd: (message: string) => void;
showDesktopNotification: (title: string, options: { body: string }) => void;
showConfiguredNotification: (title: string, payload: OverlayNotificationPayload) => void;
};
export function resolveSubtitleStyleForRenderer(config: ResolvedConfig) {
@@ -66,8 +70,10 @@ export function createConfigHotReloadAppliedHandler(deps: ConfigHotReloadApplied
export function createConfigHotReloadMessageHandler(deps: ConfigHotReloadMessageDeps) {
return (message: string): void => {
deps.showMpvOsd(message);
deps.showDesktopNotification('SubMiner', { body: message });
deps.showConfiguredNotification('SubMiner', {
kind: 'warning',
message,
});
};
}

View File

@@ -75,13 +75,12 @@ test('watch config path main deps builder maps filesystem callbacks', () => {
test('config hot reload message main deps builder maps notifications', () => {
const calls: string[] = [];
const deps = createBuildConfigHotReloadMessageMainDepsHandler({
showMpvOsd: (message) => calls.push(`osd:${message}`),
showDesktopNotification: (title) => calls.push(`notify:${title}`),
showConfiguredNotification: (title, payload) =>
calls.push(`configured:${title}:${payload.kind}:${payload.message}`),
})();
deps.showMpvOsd('updated');
deps.showDesktopNotification('SubMiner', { body: 'updated' });
assert.deepEqual(calls, ['osd:updated', 'notify:SubMiner']);
deps.showConfiguredNotification('SubMiner', { kind: 'warning', message: 'updated' });
assert.deepEqual(calls, ['configured:SubMiner:warning:updated']);
});
test('config hot reload applied main deps builder maps callbacks', () => {

View File

@@ -54,9 +54,7 @@ export function createBuildConfigHotReloadMessageMainDepsHandler(
deps: ConfigHotReloadMessageMainDeps,
) {
return (): ConfigHotReloadMessageMainDeps => ({
showMpvOsd: (message: string) => deps.showMpvOsd(message),
showDesktopNotification: (title: string, options: { body: string }) =>
deps.showDesktopNotification(title, options),
showConfiguredNotification: (title, payload) => deps.showConfiguredNotification(title, payload),
});
}

View File

@@ -0,0 +1,241 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { OverlayNotificationPayload } from '../../types';
import {
createConfiguredNotificationHandler,
createShowOverlayNotificationHandler,
createShowLogicalOsdHandler,
} from './overlay-notifications.js';
test('logical OSD prefers overlay notifications when available', () => {
const calls: string[] = [];
const showLogicalOsd = createShowLogicalOsdHandler({
showOverlayNotification: (payload) => {
calls.push(`overlay:${payload.kind}:${payload.message}`);
return true;
},
showMpvOsd: (message) => {
calls.push(`osd:${message}`);
},
});
const result = showLogicalOsd({
kind: 'success',
message: 'Subtitle annotations loaded',
});
assert.equal(result, 'overlay');
assert.deepEqual(calls, ['overlay:success:Subtitle annotations loaded']);
});
test('logical OSD normalizes spinner-frame loading messages for overlay notifications', () => {
const calls: string[] = [];
const showLogicalOsd = createShowLogicalOsdHandler({
showOverlayNotification: (payload) => {
calls.push(`overlay:${payload.kind}:${payload.message}`);
return true;
},
showMpvOsd: (message) => {
calls.push(`osd:${message}`);
},
});
showLogicalOsd('Loading subtitle annotations |');
assert.deepEqual(calls, ['overlay:loading:Loading subtitle annotations']);
});
test('logical OSD falls back to mpv OSD when overlay notifications are unavailable', () => {
const calls: string[] = [];
const showLogicalOsd = createShowLogicalOsdHandler({
showOverlayNotification: () => false,
showMpvOsd: (message) => {
calls.push(`osd:${message}`);
},
});
const result = showLogicalOsd({
kind: 'loading',
message: 'Loading subtitle annotations',
});
assert.equal(result, 'osd');
assert.deepEqual(calls, ['osd:Loading subtitle annotations']);
});
test('overlay notifications send to the visible overlay when it is enabled and visible', () => {
const calls: string[] = [];
const showOverlayNotification = createShowOverlayNotificationHandler({
isOverlayRuntimeInitialized: () => true,
getVisibleOverlayVisible: () => true,
getMainWindow: () =>
({
isDestroyed: () => false,
isVisible: () => true,
webContents: {
isLoading: () => false,
getURL: () => 'file:///overlay.html',
once: () => undefined,
send: (channel: string, payload: OverlayNotificationPayload) => {
calls.push(`${channel}:${payload.kind}:${payload.message}`);
},
},
}) as never,
notificationChannel: 'overlay:notification',
});
const shown = showOverlayNotification({
kind: 'success',
message: 'Overlay ready',
});
assert.equal(shown, true);
assert.deepEqual(calls, ['overlay:notification:success:Overlay ready']);
});
test('overlay notifications return false when the visible overlay is hidden', () => {
const showOverlayNotification = createShowOverlayNotificationHandler({
isOverlayRuntimeInitialized: () => true,
getVisibleOverlayVisible: () => false,
getMainWindow: () => null,
notificationChannel: 'overlay:notification',
});
assert.equal(
showOverlayNotification({
kind: 'info',
message: 'Hidden overlay fallback',
}),
false,
);
});
test('overlay notifications queue until renderer load finishes when window is visible but still loading', () => {
const calls: string[] = [];
let didFinishLoad: (() => void) | null = null;
const showOverlayNotification = createShowOverlayNotificationHandler({
isOverlayRuntimeInitialized: () => true,
getVisibleOverlayVisible: () => true,
getMainWindow: () =>
({
isDestroyed: () => false,
isVisible: () => true,
webContents: {
isLoading: () => true,
getURL: () => 'about:blank',
once: (_event: string, listener: () => void) => {
didFinishLoad = listener;
},
send: (channel: string, payload: OverlayNotificationPayload) => {
calls.push(`${channel}:${payload.kind}:${payload.message}`);
},
},
}) as never,
notificationChannel: 'overlay:notification',
});
const shown = showOverlayNotification({
kind: 'loading',
message: 'Loading subtitle annotations',
});
assert.equal(shown, true);
assert.deepEqual(calls, []);
if (didFinishLoad === null) {
throw new Error('expected did-finish-load listener');
}
const runDidFinishLoad: () => void = didFinishLoad;
runDidFinishLoad();
assert.deepEqual(calls, ['overlay:notification:loading:Loading subtitle annotations']);
});
test('configured notifications treat osd as the overlay-or-fallback channel', () => {
const calls: string[] = [];
const showConfiguredNotification = createConfiguredNotificationHandler({
getNotificationType: () => 'osd',
showLogicalOsd: (payload) => {
calls.push(`logical:${payload.kind}:${payload.message}`);
return 'overlay';
},
showDesktopNotification: () => {
calls.push('desktop');
},
});
showConfiguredNotification('SubMiner', {
kind: 'info',
message: 'Config reload failed',
});
assert.deepEqual(calls, ['logical:info:Config reload failed']);
});
test('configured notifications send both logical OSD and desktop notifications for both', () => {
const calls: string[] = [];
const showConfiguredNotification = createConfiguredNotificationHandler({
getNotificationType: () => 'both',
showLogicalOsd: (payload) => {
calls.push(`logical:${payload.kind}:${payload.message}`);
return 'overlay';
},
showDesktopNotification: (title, options) => {
calls.push(`desktop:${title}:${options.body}`);
},
});
showConfiguredNotification('SubMiner', {
kind: 'warning',
message: 'Restart required',
});
assert.deepEqual(calls, [
'logical:warning:Restart required',
'desktop:SubMiner:Restart required',
]);
});
test('configured notifications suppress all channels for none', () => {
const calls: string[] = [];
const showConfiguredNotification = createConfiguredNotificationHandler({
getNotificationType: () => 'none',
showLogicalOsd: () => {
calls.push('logical');
return 'overlay';
},
showDesktopNotification: () => {
calls.push('desktop');
},
});
showConfiguredNotification('SubMiner', {
kind: 'error',
message: 'Overlay failed to start',
});
assert.deepEqual(calls, []);
});
test('configured notifications default missing types to logical OSD only', () => {
const calls: string[] = [];
const showConfiguredNotification = createConfiguredNotificationHandler({
getNotificationType: () => undefined,
showLogicalOsd: (payload) => {
calls.push(`logical:${payload.kind}:${payload.message}`);
return 'overlay';
},
showDesktopNotification: () => {
calls.push('desktop');
},
});
const payload: OverlayNotificationPayload = {
kind: 'success',
message: 'Card updated',
};
showConfiguredNotification('SubMiner', payload);
assert.deepEqual(calls, ['logical:success:Card updated']);
});

View File

@@ -0,0 +1,115 @@
import type { OverlayNotificationPayload } from '../../types';
export type NotificationType = 'osd' | 'system' | 'both' | 'none' | undefined;
function normalizeNotificationPayload(
payload: string | OverlayNotificationPayload,
): OverlayNotificationPayload {
if (typeof payload === 'string') {
const trimmed = payload.trim();
const spinnerNormalized = trimmed.replace(/\s+[|/\\-]$/, '');
if (
trimmed.startsWith('Loading subtitle annotations') ||
trimmed === 'Overlay loading...' ||
trimmed === 'Starting...' ||
trimmed === 'Restarting...'
) {
return {
kind: 'loading',
message: spinnerNormalized,
};
}
return {
kind: 'info',
message: payload,
};
}
return payload;
}
export function createShowLogicalOsdHandler(deps: {
showOverlayNotification: (payload: OverlayNotificationPayload) => boolean;
showMpvOsd: (message: string) => void;
}) {
return (payload: string | OverlayNotificationPayload): 'overlay' | 'osd' => {
const normalized = normalizeNotificationPayload(payload);
if (deps.showOverlayNotification(normalized)) {
return 'overlay';
}
deps.showMpvOsd(normalized.message);
return 'osd';
};
}
export function createShowOverlayNotificationHandler(deps: {
isOverlayRuntimeInitialized: () => boolean;
getVisibleOverlayVisible: () => boolean;
getMainWindow: () => {
isDestroyed: () => boolean;
isVisible: () => boolean;
webContents: {
isLoading: () => boolean;
getURL: () => string;
once: (event: 'did-finish-load', listener: () => void) => void;
send: (channel: string, payload: OverlayNotificationPayload) => void;
};
} | null;
notificationChannel: string;
}) {
return (payload: OverlayNotificationPayload): boolean => {
if (!deps.isOverlayRuntimeInitialized()) {
return false;
}
if (!deps.getVisibleOverlayVisible()) {
return false;
}
const mainWindow = deps.getMainWindow();
if (!mainWindow || mainWindow.isDestroyed() || !mainWindow.isVisible()) {
return false;
}
const send = (): void => {
mainWindow.webContents.send(deps.notificationChannel, payload);
};
if (!mainWindow.webContents.isLoading() && mainWindow.webContents.getURL() !== 'about:blank') {
send();
return true;
}
mainWindow.webContents.once('did-finish-load', () => {
if (!mainWindow.isDestroyed() && mainWindow.isVisible()) {
send();
}
});
return true;
};
}
export function shouldShowLogicalOsd(type: NotificationType): boolean {
return type === undefined || type === 'osd' || type === 'both';
}
export function shouldShowDesktopNotification(type: NotificationType): boolean {
return type === 'system' || type === 'both';
}
export function createConfiguredNotificationHandler(deps: {
getNotificationType: () => NotificationType;
showLogicalOsd: (payload: OverlayNotificationPayload) => 'overlay' | 'osd';
showDesktopNotification: (title: string, options: { body?: string; icon?: string }) => void;
}) {
return (title: string, payload: string | OverlayNotificationPayload): void => {
const normalized = normalizeNotificationPayload(payload);
const notificationType = deps.getNotificationType();
if (shouldShowLogicalOsd(notificationType)) {
deps.showLogicalOsd(normalized);
}
if (shouldShowDesktopNotification(notificationType)) {
deps.showDesktopNotification(title, { body: normalized.message });
}
};
}

View File

@@ -51,6 +51,7 @@ import type {
ControllerConfigUpdate,
ControllerPreferenceUpdate,
ResolvedControllerConfig,
OverlayNotificationPayload,
} from './types';
import { IPC_CHANNELS } from './shared/ipc/contracts';
@@ -136,6 +137,10 @@ const onKikuFieldGroupingRequestEvent =
IPC_CHANNELS.event.kikuFieldGroupingRequest,
(payload) => payload as KikuFieldGroupingRequestData,
);
const onOverlayNotificationEvent = createQueuedIpcListenerWithPayload<OverlayNotificationPayload>(
IPC_CHANNELS.event.overlayNotification,
(payload) => payload as OverlayNotificationPayload,
);
const electronAPI: ElectronAPI = {
getOverlayLayer: () => overlayLayer,
@@ -318,6 +323,7 @@ const electronAPI: ElectronAPI = {
},
);
},
onOverlayNotification: onOverlayNotificationEvent,
};
contextBridge.exposeInMainWorld('electronAPI', electronAPI);

View File

@@ -72,14 +72,18 @@ test('release workflow publishes subminer-bin to AUR from tagged release artifac
assert.match(releaseWorkflow, /needs:\s*\[release\]/);
assert.match(releaseWorkflow, /AUR_SSH_PRIVATE_KEY/);
assert.match(releaseWorkflow, /ssh:\/\/aur@aur\.archlinux\.org\/subminer-bin\.git/);
assert.match(releaseWorkflow, /Install makepkg/);
assert.match(releaseWorkflow, /scripts\/update-aur-package\.sh/);
assert.match(
releaseWorkflow,
/cp packaging\/aur\/subminer-bin\/\.SRCINFO aur-subminer-bin\/\.SRCINFO/,
);
assert.match(releaseWorkflow, /version_no_v="\$\{\{ steps\.version\.outputs\.VERSION \}\}"/);
assert.match(releaseWorkflow, /SubMiner-\$\{version_no_v\}\.AppImage/);
assert.doesNotMatch(
releaseWorkflow,
/SubMiner-\$\{\{ steps\.version\.outputs\.VERSION \}\}\.AppImage/,
);
assert.doesNotMatch(releaseWorkflow, /Install makepkg/);
});
test('release workflow skips empty AUR sync commits', () => {

View File

@@ -42,6 +42,18 @@
role="status"
aria-live="polite"
></div>
<div
id="overlayNotificationToast"
class="overlay-notification-toast hidden"
role="status"
aria-live="polite"
>
<div id="overlayNotificationSpinner" class="overlay-notification-spinner hidden"></div>
<div class="overlay-notification-copy">
<div id="overlayNotificationTitle" class="overlay-notification-title"></div>
<div id="overlayNotificationMessage" class="overlay-notification-message"></div>
</div>
</div>
<div id="secondarySubContainer" class="secondary-sub-hidden">
<div id="secondarySubRoot"></div>
</div>

View File

@@ -0,0 +1,112 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createOverlayNotificationsController } from './overlay-notifications.js';
function createClassList(initialTokens: string[] = []) {
const tokens = new Set(initialTokens);
return {
add: (...entries: string[]) => {
for (const entry of entries) tokens.add(entry);
},
remove: (...entries: string[]) => {
for (const entry of entries) tokens.delete(entry);
},
contains: (entry: string) => tokens.has(entry),
};
}
test('overlay notifications show loading state with spinner and no auto-hide timer', () => {
const toast = {
classList: createClassList(['hidden']),
dataset: {} as Record<string, string>,
};
const title = { textContent: '' };
const message = { textContent: '' };
const spinner = { classList: createClassList(['hidden']) };
let scheduled = false;
const controller = createOverlayNotificationsController(
{
overlayNotificationToast: toast,
overlayNotificationTitle: title,
overlayNotificationMessage: message,
overlayNotificationSpinner: spinner,
} as never,
{
setTimeout: () => {
scheduled = true;
return 1 as never;
},
clearTimeout: () => {},
},
);
controller.show({
kind: 'loading',
title: 'SubMiner',
message: 'Loading subtitle annotations',
});
assert.equal(toast.classList.contains('hidden'), false);
assert.equal(spinner.classList.contains('hidden'), false);
assert.equal(title.textContent, 'SubMiner');
assert.equal(message.textContent, 'Loading subtitle annotations');
assert.equal(toast.dataset.kind, 'loading');
assert.equal(scheduled, false);
});
test('overlay notifications auto-hide non-loading messages and clear loading styling', () => {
let nextTimerId = 1;
const scheduled = new Map<number, () => void>();
const toast = {
classList: createClassList(['hidden']),
dataset: {} as Record<string, string>,
};
const title = { textContent: '' };
const message = { textContent: '' };
const spinner = { classList: createClassList(['hidden']) };
const controller = createOverlayNotificationsController(
{
overlayNotificationToast: toast,
overlayNotificationTitle: title,
overlayNotificationMessage: message,
overlayNotificationSpinner: spinner,
} as never,
{
durationMs: 1200,
setTimeout: (callback: () => void) => {
const id = nextTimerId++;
scheduled.set(id, callback);
return id as never;
},
clearTimeout: (id) => {
scheduled.delete(id as never as number);
},
},
);
controller.show({
kind: 'loading',
title: 'SubMiner',
message: 'Loading subtitle annotations',
});
controller.show({
kind: 'success',
title: 'SubMiner',
message: 'Subtitle annotations loaded',
});
assert.equal(spinner.classList.contains('hidden'), true);
assert.equal(toast.dataset.kind, 'success');
assert.equal(message.textContent, 'Subtitle annotations loaded');
assert.equal(scheduled.size, 1);
const [hide] = scheduled.values();
hide?.();
assert.equal(toast.classList.contains('hidden'), true);
assert.equal(title.textContent, '');
assert.equal(message.textContent, '');
});

View File

@@ -0,0 +1,82 @@
import type { OverlayNotificationPayload } from '../types';
type OverlayNotificationsDom = {
overlayNotificationToast: {
classList: {
add: (...tokens: string[]) => void;
remove: (...tokens: string[]) => void;
contains?: (token: string) => boolean;
};
dataset: {
kind?: string;
};
};
overlayNotificationTitle: {
textContent: string;
};
overlayNotificationMessage: {
textContent: string;
};
overlayNotificationSpinner: {
classList: {
add: (...tokens: string[]) => void;
remove: (...tokens: string[]) => void;
};
};
};
type OverlayNotificationsTimerDeps = {
durationMs?: number;
setTimeout?: (callback: () => void, delayMs: number) => number | ReturnType<typeof setTimeout>;
clearTimeout?: (timeout: number | ReturnType<typeof setTimeout>) => void;
};
export function createOverlayNotificationsController(
dom: OverlayNotificationsDom,
deps: OverlayNotificationsTimerDeps = {},
) {
let hideTimer: number | ReturnType<typeof setTimeout> | null = null;
const durationMs = deps.durationMs ?? 2200;
const setTimeoutHandler = deps.setTimeout ?? setTimeout;
const clearTimeoutHandler = deps.clearTimeout ?? clearTimeout;
const clearHideTimer = (): void => {
if (hideTimer === null) {
return;
}
clearTimeoutHandler(hideTimer);
hideTimer = null;
};
const hide = (): void => {
clearHideTimer();
dom.overlayNotificationToast.classList.add('hidden');
dom.overlayNotificationSpinner.classList.add('hidden');
dom.overlayNotificationToast.dataset.kind = '';
dom.overlayNotificationTitle.textContent = '';
dom.overlayNotificationMessage.textContent = '';
};
const show = (payload: OverlayNotificationPayload): void => {
clearHideTimer();
dom.overlayNotificationToast.classList.remove('hidden');
dom.overlayNotificationToast.dataset.kind = payload.kind;
dom.overlayNotificationTitle.textContent = payload.title ?? '';
dom.overlayNotificationMessage.textContent = payload.message;
if (payload.kind === 'loading') {
dom.overlayNotificationSpinner.classList.remove('hidden');
return;
}
dom.overlayNotificationSpinner.classList.add('hidden');
hideTimer = setTimeoutHandler(() => {
hide();
}, payload.durationMs ?? durationMs);
};
return {
show,
hide,
};
}

View File

@@ -29,6 +29,7 @@ import { createKeyboardHandlers } from './handlers/keyboard.js';
import { createGamepadController } from './handlers/gamepad-controller.js';
import { createMouseHandlers } from './handlers/mouse.js';
import { createControllerStatusIndicator } from './controller-status-indicator.js';
import { createOverlayNotificationsController } from './overlay-notifications.js';
import { createControllerDebugModal } from './modals/controller-debug.js';
import { createControllerSelectModal } from './modals/controller-select.js';
import { createJimakuModal } from './modals/jimaku.js';
@@ -110,6 +111,7 @@ const controllerDebugModal = createControllerDebugModal(ctx, {
syncSettingsModalSubtitleSuppression,
});
const controllerStatusIndicator = createControllerStatusIndicator(ctx.dom);
const overlayNotifications = createOverlayNotificationsController(ctx.dom);
const sessionHelpModal = createSessionHelpModal(ctx, {
modalStateReader: { isAnyModalOpen },
syncSettingsModalSubtitleSuppression,
@@ -431,6 +433,12 @@ function registerKeyboardCommandHandlers(): void {
keyboardHandlers.handleLookupWindowToggleRequested();
});
});
window.electronAPI.onOverlayNotification((payload) => {
runGuarded('overlay-notification', () => {
overlayNotifications.show(payload);
});
});
}
function runGuarded(action: string, fn: () => void): void {

View File

@@ -108,6 +108,93 @@ body {
transform: translateY(0);
}
.overlay-notification-toast {
position: absolute;
top: 18px;
right: 18px;
display: flex;
align-items: center;
gap: 12px;
max-width: min(460px, calc(100vw - 36px));
padding: 12px 14px;
border-radius: 16px;
border: 1px solid rgba(255, 255, 255, 0.14);
background:
radial-gradient(circle at top left, rgba(255, 255, 255, 0.08), transparent 46%),
linear-gradient(140deg, rgba(12, 18, 26, 0.96), rgba(20, 31, 46, 0.95));
box-shadow: 0 16px 44px rgba(0, 0, 0, 0.32);
color: rgba(245, 249, 255, 0.98);
pointer-events: none;
opacity: 0;
transform: translateY(-8px) scale(0.985);
transition:
opacity 180ms ease,
transform 180ms ease;
z-index: 1320;
}
.overlay-notification-toast[data-kind='success'] {
border-color: rgba(139, 213, 202, 0.35);
}
.overlay-notification-toast[data-kind='warning'] {
border-color: rgba(245, 169, 127, 0.45);
}
.overlay-notification-toast[data-kind='error'] {
border-color: rgba(237, 135, 150, 0.5);
}
.overlay-notification-toast[data-kind='loading'] {
border-color: rgba(138, 173, 244, 0.4);
}
.overlay-notification-toast:not(.hidden) {
opacity: 1;
transform: translateY(0) scale(1);
}
.overlay-notification-spinner {
width: 18px;
height: 18px;
border-radius: 999px;
border: 2px solid rgba(255, 255, 255, 0.18);
border-top-color: rgba(198, 224, 255, 0.95);
flex: 0 0 auto;
animation: overlay-notification-spin 720ms linear infinite;
}
.overlay-notification-copy {
display: flex;
flex-direction: column;
gap: 3px;
min-width: 0;
}
.overlay-notification-title {
font-size: 11px;
font-weight: 800;
letter-spacing: 0.12em;
text-transform: uppercase;
color: rgba(184, 211, 255, 0.74);
}
.overlay-notification-message {
font-size: 14px;
line-height: 1.35;
font-weight: 700;
color: rgba(245, 249, 255, 0.98);
}
@keyframes overlay-notification-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.modal {
position: absolute;
inset: 0;

View File

@@ -4,6 +4,10 @@ export type RendererDom = {
overlay: HTMLElement;
controllerStatusToast: HTMLDivElement;
overlayErrorToast: HTMLDivElement;
overlayNotificationToast: HTMLDivElement;
overlayNotificationTitle: HTMLDivElement;
overlayNotificationMessage: HTMLDivElement;
overlayNotificationSpinner: HTMLDivElement;
secondarySubContainer: HTMLElement;
secondarySubRoot: HTMLElement;
@@ -99,6 +103,10 @@ export function resolveRendererDom(): RendererDom {
overlay: getRequiredElement<HTMLElement>('overlay'),
controllerStatusToast: getRequiredElement<HTMLDivElement>('controllerStatusToast'),
overlayErrorToast: getRequiredElement<HTMLDivElement>('overlayErrorToast'),
overlayNotificationToast: getRequiredElement<HTMLDivElement>('overlayNotificationToast'),
overlayNotificationTitle: getRequiredElement<HTMLDivElement>('overlayNotificationTitle'),
overlayNotificationMessage: getRequiredElement<HTMLDivElement>('overlayNotificationMessage'),
overlayNotificationSpinner: getRequiredElement<HTMLDivElement>('overlayNotificationSpinner'),
secondarySubContainer: getRequiredElement<HTMLElement>('secondarySubContainer'),
secondarySubRoot: getRequiredElement<HTMLElement>('secondarySubRoot'),

View File

@@ -77,6 +77,7 @@ export const IPC_CHANNELS = {
keyboardModeToggleRequested: 'keyboard-mode-toggle:requested',
lookupWindowToggleRequested: 'lookup-window-toggle:requested',
configHotReload: 'config:hot-reload',
overlayNotification: 'overlay:notification',
},
} as const;

View File

@@ -1015,6 +1015,15 @@ export interface ConfigHotReloadPayload {
secondarySubMode: SecondarySubMode;
}
export type OverlayNotificationKind = 'info' | 'success' | 'warning' | 'error' | 'loading';
export interface OverlayNotificationPayload {
kind: OverlayNotificationKind;
title?: string;
message: string;
durationMs?: number;
}
export type ResolvedControllerConfig = ResolvedConfig['controller'];
export interface SubtitleHoverTokenPayload {
@@ -1097,6 +1106,7 @@ export interface ElectronAPI {
) => void;
reportOverlayContentBounds: (measurement: OverlayContentMeasurement) => void;
onConfigHotReload: (callback: (payload: ConfigHotReloadPayload) => void) => void;
onOverlayNotification: (callback: (payload: OverlayNotificationPayload) => void) => void;
}
declare global {