feat(anime): add anime browser powered by Aniyomi extensions

- Add `subminer anime` / `--anime` and a tray entry to open a browser that searches installed Aniyomi extension sources, shows cover art and episodes, and plays into mpv with overlay/mining attached
- Add an Extensions tab to add repos and install/update/remove sources, and per-source settings for sources needing config
- Support searching all sources at once with streaming, per-source results and status
- Prefer Japanese audio/subtitle tracks from the source and keep the primary subtitle slot reserved for Japanese
- Fix window/tray/Dock handling so the browser and mpv can be switched between without quitting the app or losing the Dock icon
- Add anime.repos, anime.extensionsDir, anime.preferredQuality config keys (no bundled repos or discovery)
This commit is contained in:
2026-07-31 17:16:49 -07:00
parent b204d4dd6e
commit e64ff1a0ee
117 changed files with 7565 additions and 529 deletions
+1
View File
@@ -17,6 +17,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
yomitan: false,
settings: false,
syncWindow: false,
animeBrowser: false,
setup: false,
show: false,
hide: false,
+2 -1
View File
@@ -186,7 +186,8 @@ export function startAppLifecycle(initialArgs: CliArgs, deps: AppLifecycleServic
(!deps.isDarwinPlatform() ||
initialArgs.settings ||
initialArgs.setup ||
initialArgs.syncWindow)
initialArgs.syncWindow ||
initialArgs.animeBrowser)
) {
deps.quitApp();
}
+5
View File
@@ -22,6 +22,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
yomitan: false,
settings: false,
syncWindow: false,
animeBrowser: false,
setup: false,
show: false,
hide: false,
@@ -142,6 +143,9 @@ function createDeps(overrides: Partial<CliCommandServiceDeps> = {}) {
openSyncUiWindow: () => {
calls.push('openSyncUiWindow');
},
openAnimeBrowserWindow: () => {
calls.push('openAnimeBrowserWindow');
},
openFirstRunSetup: (force?: boolean) => {
calls.push(`openFirstRunSetup:${force === true ? 'force' : 'default'}`);
},
@@ -665,6 +669,7 @@ test('createCliCommandDepsRuntime reconnects MPV client when reconnect hook exis
openYomitanSettings: () => {},
openConfigSettingsWindow: () => {},
openSyncUiWindow: () => {},
openAnimeBrowserWindow: () => {},
cycleSecondarySubMode: () => {},
openRuntimeOptionsPalette: () => {},
printHelp: () => {},
+5
View File
@@ -45,6 +45,7 @@ export interface CliCommandServiceDeps {
openYomitanSettingsDelayed: (delayMs: number) => void;
openConfigSettingsWindow: () => void;
openSyncUiWindow: () => void;
openAnimeBrowserWindow: () => void;
setVisibleOverlayVisible: (visible: boolean) => void;
copyCurrentSubtitle: () => void;
startPendingMultiCopy: (timeoutMs: number) => void;
@@ -172,6 +173,7 @@ interface UiCliRuntime {
openYomitanSettings: () => void;
openConfigSettingsWindow: () => void;
openSyncUiWindow: () => void;
openAnimeBrowserWindow: () => void;
cycleSecondarySubMode: () => void;
openRuntimeOptionsPalette: () => void;
printHelp: () => void;
@@ -277,6 +279,7 @@ export function createCliCommandDepsRuntime(
},
openConfigSettingsWindow: options.ui.openConfigSettingsWindow,
openSyncUiWindow: options.ui.openSyncUiWindow,
openAnimeBrowserWindow: options.ui.openAnimeBrowserWindow,
setVisibleOverlayVisible: options.overlay.setVisible,
copyCurrentSubtitle: options.mining.copyCurrentSubtitle,
startPendingMultiCopy: options.mining.startPendingMultiCopy,
@@ -422,6 +425,8 @@ export function handleCliCommand(
deps.openConfigSettingsWindow();
} else if (args.syncWindow) {
deps.openSyncUiWindow();
} else if (args.animeBrowser) {
deps.openAnimeBrowserWindow();
} else if (args.show || args.showVisibleOverlay) {
deps.setVisibleOverlayVisible(true);
} else if (args.hide || args.hideVisibleOverlay) {
@@ -0,0 +1,72 @@
import assert from 'node:assert/strict';
import test, { beforeEach } from 'node:test';
import {
isDockIconRetained,
releaseDockIcon,
resetDockIconRetentionForTests,
retainDockIcon,
} from './dock-icon-visibility';
function createDockRecorder() {
const calls: string[] = [];
return {
calls,
dock: {
show: () => {
calls.push('show');
},
hide: () => {
calls.push('hide');
},
},
};
}
beforeEach(() => {
resetDockIconRetentionForTests();
});
test('retainDockIcon shows the dock once and marks retention on darwin', () => {
const recorder = createDockRecorder();
retainDockIcon({ dock: recorder.dock, platform: 'darwin' });
retainDockIcon({ dock: recorder.dock, platform: 'darwin' });
assert.deepEqual(recorder.calls, ['show']);
assert.equal(isDockIconRetained(), true);
});
test('releaseDockIcon rehides only when the last retainer releases and overlay still needs it', () => {
const recorder = createDockRecorder();
retainDockIcon({ dock: recorder.dock, platform: 'darwin' });
retainDockIcon({ dock: recorder.dock, platform: 'darwin' });
releaseDockIcon({ dock: recorder.dock, platform: 'darwin', shouldRehide: () => true });
assert.deepEqual(recorder.calls, ['show']);
assert.equal(isDockIconRetained(), true);
releaseDockIcon({ dock: recorder.dock, platform: 'darwin', shouldRehide: () => true });
assert.deepEqual(recorder.calls, ['show', 'hide']);
assert.equal(isDockIconRetained(), false);
});
test('releaseDockIcon leaves the dock visible when no overlay needs the accessory transform', () => {
const recorder = createDockRecorder();
retainDockIcon({ dock: recorder.dock, platform: 'darwin' });
releaseDockIcon({ dock: recorder.dock, platform: 'darwin', shouldRehide: () => false });
assert.deepEqual(recorder.calls, ['show']);
assert.equal(isDockIconRetained(), false);
});
test('release without retain and non-darwin platforms are no-ops', () => {
const recorder = createDockRecorder();
releaseDockIcon({ dock: recorder.dock, platform: 'darwin', shouldRehide: () => true });
retainDockIcon({ dock: recorder.dock, platform: 'linux' });
releaseDockIcon({ dock: recorder.dock, platform: 'win32', shouldRehide: () => true });
assert.deepEqual(recorder.calls, []);
assert.equal(isDockIconRetained(), false);
});
+49
View File
@@ -0,0 +1,49 @@
// macOS-only: Electron's setVisibleOnAllWorkspaces(..., { visibleOnFullScreen: true })
// transforms the whole app into a UIElement (accessory) process so overlay windows can
// float above fullscreen Spaces. That side effect removes the Dock icon and the app's
// Cmd+Tab entry, making regular windows (anime browser, settings) unreachable.
//
// While at least one regular window "retains" the Dock icon, overlay/stats level
// assertions must pass skipTransformProcessType so they don't yank the app back to
// accessory mode behind the user's back.
type DockLike = {
show: () => Promise<void> | void;
hide: () => void;
};
let dockIconRetainCount = 0;
export function isDockIconRetained(): boolean {
return dockIconRetainCount > 0;
}
export function retainDockIcon(options: {
dock: DockLike | null | undefined;
platform?: NodeJS.Platform;
}): void {
if ((options.platform ?? process.platform) !== 'darwin') return;
dockIconRetainCount += 1;
if (dockIconRetainCount === 1) {
void options.dock?.show();
}
}
export function releaseDockIcon(options: {
dock: DockLike | null | undefined;
platform?: NodeJS.Platform;
// True when an overlay window still needs the accessory transform to float
// above fullscreen Spaces; false leaves the Dock icon visible.
shouldRehide: () => boolean;
}): void {
if ((options.platform ?? process.platform) !== 'darwin') return;
if (dockIconRetainCount === 0) return;
dockIconRetainCount -= 1;
if (dockIconRetainCount === 0 && options.shouldRehide()) {
options.dock?.hide();
}
}
export function resetDockIconRetentionForTests(): void {
dockIconRetainCount = 0;
}
+7 -1
View File
@@ -15,6 +15,7 @@ import {
type HyprlandPlacementStatus,
} from './hyprland-window-placement';
import { buildOverlayWindowOptions, OVERLAY_WINDOW_TITLES } from './overlay-window-options';
import { isDockIconRetained } from './dock-icon-visibility';
import { normalizeOverlayWindowBoundsForPlatform } from './overlay-window-bounds';
import { OVERLAY_WINDOW_CONTENT_READY_FLAG } from './overlay-window-flags';
export { OVERLAY_WINDOW_CONTENT_READY_FLAG } from './overlay-window-flags';
@@ -79,7 +80,12 @@ export function updateOverlayWindowBounds(
export function ensureOverlayWindowLevel(window: BrowserWindow): void {
if (process.platform === 'darwin') {
window.setAlwaysOnTop(true, 'screen-saver', 1);
window.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true });
// While a regular window (anime browser, ...) holds the Dock icon, skip the
// accessory process transform so the app stays in the Dock and Cmd+Tab.
window.setVisibleOnAllWorkspaces(true, {
visibleOnFullScreen: true,
skipTransformProcessType: isDockIconRetained(),
});
window.setFullScreenable(false);
window.moveTop();
return;
@@ -17,6 +17,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
yomitan: false,
settings: false,
syncWindow: false,
animeBrowser: false,
setup: false,
show: false,
hide: false,
+5 -1
View File
@@ -4,6 +4,7 @@ import type {
MessageBoxSyncOptions,
} from 'electron';
import type { WindowGeometry } from '../../types';
import { isDockIconRetained } from './dock-icon-visibility';
const DEFAULT_STATS_WINDOW_WIDTH = 900;
const DEFAULT_STATS_WINDOW_HEIGHT = 700;
@@ -114,7 +115,10 @@ export function promoteStatsWindowLevel(
): void {
if (platform === 'darwin') {
window.setAlwaysOnTop(true, 'screen-saver', 2);
window.setVisibleOnAllWorkspaces?.(true, { visibleOnFullScreen: true });
window.setVisibleOnAllWorkspaces?.(true, {
visibleOnFullScreen: true,
skipTransformProcessType: isDockIconRetained(),
});
window.setFullScreenable?.(false);
window.moveTop();
return;