feat(anime): open Anime Browser in player modal

- Add the Ctrl+Alt+A shortcut and dedicated overlay modal
- Share queue, sources, and watch history while isolating browser state
This commit is contained in:
2026-08-15 21:44:36 -07:00
parent cc43d3007a
commit 7a1450ddb7
51 changed files with 921 additions and 180 deletions
+1
View File
@@ -29,3 +29,4 @@ area: anime
- Right-clicking an episode opens a menu for marking it watched or unwatched by hand, plus "Mark this and N below watched/unwatched" for the episode and every episode listed under it. Sources list newest first, so a span covers the back catalogue, which is how a series watched elsewhere gets caught up. A filter never narrows what a span covers, and the status bar reports how many episodes were touched.
- Marking an episode that was never played creates its stats row, carrying the same series, season and episode fields playback would have recorded. Both stats library views join the lifetime tables, so a manual mark does not show up there as watch time nobody spent, and clearing a mark creates nothing.
- Episodes can be queued instead of replacing what is playing. Every episode row has **Play** and **Queue** buttons (clicking the row still plays now), the right-click menu offers the same two, and a queued episode shows its place in line ("next up", "#2 in queue") with a queue count and **Clear queue** in the episode header. The queue spans anime, resolves and appends each episode to mpv's real playlist as soon as it is queued while subtitle tracks cache in the background, so next/previous navigation works immediately and the next episode starts without a resolution pause when the current one ends. Queueing with nothing playing just plays.
- Added an in-player Anime Browser modal on `Ctrl+Alt+A`. It stays within the player bounds and shares playback queue, source configuration, and watch history with the standalone browser, while each surface keeps independent search and navigation state.
+6
View File
@@ -341,6 +341,12 @@
"__playlist-browser-open"
] // Command setting.
},
{
"key": "Ctrl+Alt+KeyA", // Key setting.
"command": [
"__anime-browser-open"
] // Command setting.
},
{
"key": "Ctrl+Shift+KeyH", // Key setting.
"command": [
+11
View File
@@ -7,6 +7,17 @@ Open it with `subminer anime`, with `SubMiner.AppImage --anime`, or from
**Browse Anime** in the tray menu. The window stays open while you watch, so you
can queue the next episode without reopening it.
During playback, `Ctrl+Alt+A` opens the same browser as a modal inside the mpv
player bounds. It uses a dedicated modal surface, so it stays above fullscreen
playback and closes like the other in-player tools. The standalone window and
the modal keep their own search, selected source, tab, and scroll state, so using
one does not replace or cancel what you were doing in the other.
Both surfaces use the same playback queue, source configuration, and stats
history. Queue changes appear in both immediately, and watched marks come from
the same history that playback and the stats window update. Closing and reopening
the modal therefore picks up progress made from either browser surface.
While the window is open, SubMiner shows a tray icon and — on macOS — appears
in the Cmd+Tab switcher and the Dock (macOS ties the two together), so you can
flip between the browser and mpv. SubMiner normally hides itself from the Dock
+2 -2
View File
@@ -74,8 +74,8 @@ src/
shared/ipc/ # Cross-process IPC channel constants + payload validators
renderer/ # Overlay renderer (modularized UI/runtime)
handlers/ # Keyboard/mouse/gamepad interaction modules
modals/ # Modal flows (Jimaku, Kiku, subsync, runtime options, session help,
# changelog, character dictionary, playlist browser, subtitle
modals/ # Modal flows (Anime Browser, Jimaku, Kiku, subsync, runtime options,
# session help, changelog, character dictionary, playlist browser, subtitle
# sidebar, YouTube track picker, controller config/debug/select)
positioning/ # Subtitle position controller (drag-to-reposition)
settings/ # Settings window UI (model, controls, markup)
+2 -1
View File
@@ -593,6 +593,7 @@ See `config.example.jsonc` for detailed configuration options and more examples.
| `KeyJ` | `["cycle", "sid"]` | Cycle primary subtitle track |
| `Shift+KeyJ` | `["cycle", "secondary-sid"]` | Cycle secondary subtitle track |
| `Ctrl+Alt+KeyP` | `["__playlist-browser-open"]` | Open playlist browser |
| `Ctrl+Alt+KeyA` | `["__anime-browser-open"]` | Open Anime Browser in the player |
| `Ctrl+Alt+KeyC` | `["__youtube-picker-open"]` | Open the manual YouTube subtitle picker |
| `ArrowRight` | `["seek", 5]` | Seek forward 5 seconds |
| `ArrowLeft` | `["seek", -5]` | Seek backward 5 seconds |
@@ -634,7 +635,7 @@ See `config.example.jsonc` for detailed configuration options and more examples.
{ "key": "Space", "command": null }
```
**Special commands:** Commands prefixed with `__` are handled internally by the overlay rather than sent to mpv. `__playlist-browser-open` opens the split-pane playlist browser for the current file's parent directory and the live mpv queue. `__replay-subtitle` replays the current subtitle and pauses at its end. `__play-next-subtitle` seeks to the next subtitle, plays it, and pauses at its end. `__runtime-options-open` opens the runtime options palette. `__runtime-option-cycle:<id>[:next|prev]` cycles a runtime option value.
**Special commands:** Commands prefixed with `__` are handled internally by the overlay rather than sent to mpv. `__playlist-browser-open` opens the split-pane playlist browser for the current file's parent directory and the live mpv queue. `__anime-browser-open` opens the Anime Browser inside the player bounds. `__replay-subtitle` replays the current subtitle and pauses at its end. `__play-next-subtitle` seeks to the next subtitle, plays it, and pauses at its end. `__runtime-options-open` opens the runtime options palette. `__runtime-option-cycle:<id>[:next|prev]` cycles a runtime option value.
**Supported commands:** Any valid mpv JSON IPC command array (`["cycle", "pause"]`, `["seek", 5]`, `["script-binding", "..."]`, etc.)
+6
View File
@@ -341,6 +341,12 @@
"__playlist-browser-open"
] // Command setting.
},
{
"key": "Ctrl+Alt+KeyA", // Key setting.
"command": [
"__anime-browser-open"
] // Command setting.
},
{
"key": "Ctrl+Shift+KeyH", // Key setting.
"command": [
+2 -1
View File
@@ -49,6 +49,7 @@ These control playback and subtitle display. They require overlay window focus.
| `J` | Cycle primary subtitle track |
| `Shift+J` | Cycle secondary subtitle track |
| `Ctrl+Alt+P` | Open playlist browser for current directory + queue |
| `Ctrl+Alt+A` | Open Anime Browser inside the player |
| `ArrowRight` | Seek forward 5 seconds |
| `ArrowLeft` | Seek backward 5 seconds |
| `ArrowUp` | Seek forward 60 seconds |
@@ -67,7 +68,7 @@ These control playback and subtitle display. They require overlay window focus.
| `Right-click` | Toggle pause (outside subtitle area) |
| `Right-click + drag` | Reposition subtitles (on subtitle area) |
The mpv-command rows above (`Space`, `F`, `J`, `Shift+J`, the seek/sub-seek/sub-step/sub-delay keys, replay/play-next, and quit) are merged from the `keybindings` config array and can be remapped or disabled there. `V` and the mouse actions are built-in overlay behaviors and are not part of the `keybindings` array. The playlist browser opens a split overlay modal with sibling video files on the left and the live mpv playlist on the right.
The mpv-command rows above (`Space`, `F`, `J`, `Shift+J`, the seek/sub-seek/sub-step/sub-delay keys, replay/play-next, and quit) are merged from the `keybindings` config array and can be remapped or disabled there. `V` and the mouse actions are built-in overlay behaviors and are not part of the `keybindings` array. The playlist browser opens a split overlay modal with sibling video files on the left and the live mpv playlist on the right. The Anime Browser shortcut opens a dedicated overlay modal bounded to the player; the standalone Anime Browser window remains independent.
On macOS managed playback, SubMiner disables mpv's menu-bar shortcuts so configured SubMiner shortcuts like `Cmd+Shift+O` reach the mpv plugin instead of opening native mpv menu actions.
+2
View File
@@ -268,6 +268,8 @@ function M.create(ctx)
return { "--open-controller-debug" }
elseif action_id == "openPlaylistBrowser" then
return { "--open-playlist-browser" }
elseif action_id == "openAnimeBrowser" then
return { "--session-action", '{"actionId":"openAnimeBrowser"}' }
elseif action_id == "replayCurrentSubtitle" then
return { "--replay-current-subtitle" }
elseif action_id == "playNextSubtitle" then
+7
View File
@@ -21,6 +21,10 @@ test('build:syncui bundles the sandboxed preload and keeps Electron external', (
test('build:animeui bundles the sandboxed preload and keeps Electron external', () => {
const command = buildScript('build:animeui');
const sharedPreloadSource = fs.readFileSync(
path.join(import.meta.dir, '..', 'src', 'preload-anime-browser-api.ts'),
'utf8',
);
// The preload imports IPC_CHANNELS, so it must be bundled rather than
// emitted by plain tsc with a relative runtime require.
@@ -28,6 +32,9 @@ test('build:animeui bundles the sandboxed preload and keeps Electron external',
assert.match(command, /--bundle/);
assert.match(command, /--external:electron/);
assert.match(command, /--outfile=dist\/preload-animeui\.js/);
// The standalone Anime window uses Electron's sandboxed preload runtime,
// which exposes `electron` but cannot require arbitrary Node built-ins.
assert.doesNotMatch(sharedPreloadSource, /from ['"]node:/);
});
test('build:animeui runs as part of the top-level build', () => {
+24 -4
View File
@@ -22,13 +22,25 @@ import type {
AnimeBrowserSource,
} from '../types/anime-browser';
declare global {
interface Window {
animeBrowserAPI: AnimeBrowserAPI;
const embeddedInOverlay =
new URLSearchParams(window.location.search).get('embedded') === 'overlay-modal';
if (embeddedInOverlay) document.documentElement.dataset.embedded = 'overlay-modal';
function resolveAnimeBrowserAPI(): AnimeBrowserAPI {
const local = (window as Window & { animeBrowserAPI?: AnimeBrowserAPI }).animeBrowserAPI;
if (local) return local;
try {
const parent = (window.parent as Window & { animeBrowserAPI?: AnimeBrowserAPI })
.animeBrowserAPI;
if (parent) return parent;
} catch {
// The embedded document is expected to be same-origin. Fall through to a
// direct error if Chromium blocks that relationship unexpectedly.
}
throw new Error('Anime Browser API is unavailable.');
}
const api = window.animeBrowserAPI;
const api = resolveAnimeBrowserAPI();
const searchForm = el<HTMLFormElement>('search-form');
const searchInput = el<HTMLInputElement>('search-input');
@@ -82,6 +94,14 @@ function setStatus(message: string, tone: 'info' | 'ok' | 'error' = 'info'): voi
const detailPanel = createDetailPanel({ api, setStatus });
if (embeddedInOverlay) {
document.addEventListener('keydown', (event) => {
if (event.key !== 'Escape' || event.defaultPrevented || detailPanel.isOpen()) return;
event.preventDefault();
window.parent.postMessage('subminer:anime-browser-close', '*');
});
}
const BRIDGE_LABELS: Record<AnimeBrowserBridgeState['stage'], string> = {
idle: 'Starting the extension bridge',
locating: 'Looking up the extension bridge release',
+5 -1
View File
@@ -89,7 +89,11 @@ export function createDetailPanel({ api, setStatus }: DetailPanelOptions) {
detailBack.addEventListener('click', close);
document.addEventListener('keydown', (event) => {
if (event.key === 'Escape' && !detail.classList.contains('hidden')) close();
if (event.key === 'Escape' && !detail.classList.contains('hidden')) {
event.preventDefault();
event.stopImmediatePropagation();
close();
}
});
return {
+17
View File
@@ -52,6 +52,23 @@ body {
overflow: hidden;
}
html[data-embedded='overlay-modal'] .topbar {
gap: 12px;
padding: 10px 14px;
}
html[data-embedded='overlay-modal'] .brand-block {
display: none;
}
html[data-embedded='overlay-modal'] .bridge-banner {
padding-inline: 14px;
}
html[data-embedded='overlay-modal'] .statusbar {
padding-inline: 14px;
}
body {
display: flex;
flex-direction: column;
+2
View File
@@ -61,6 +61,7 @@ export const SPECIAL_COMMANDS = {
PLAY_NEXT_SUBTITLE: '__play-next-subtitle',
YOUTUBE_PICKER_OPEN: '__youtube-picker-open',
PLAYLIST_BROWSER_OPEN: '__playlist-browser-open',
ANIME_BROWSER_OPEN: '__anime-browser-open',
} as const;
export const DEFAULT_KEYBINDINGS: NonNullable<ResolvedConfig['keybindings']> = [
@@ -81,6 +82,7 @@ export const DEFAULT_KEYBINDINGS: NonNullable<ResolvedConfig['keybindings']> = [
{ key: 'KeyX', command: ['add', 'sub-delay', 0.1] },
{ key: 'Ctrl+Alt+KeyC', command: [SPECIAL_COMMANDS.YOUTUBE_PICKER_OPEN] },
{ key: 'Ctrl+Alt+KeyP', command: [SPECIAL_COMMANDS.PLAYLIST_BROWSER_OPEN] },
{ key: 'Ctrl+Alt+KeyA', command: [SPECIAL_COMMANDS.ANIME_BROWSER_OPEN] },
{ key: 'Ctrl+Shift+KeyH', command: [SPECIAL_COMMANDS.REPLAY_SUBTITLE] },
{ key: 'Ctrl+Shift+KeyL', command: [SPECIAL_COMMANDS.PLAY_NEXT_SUBTITLE] },
{ key: 'KeyQ', command: ['quit'] },
+4
View File
@@ -19,6 +19,7 @@ function createOptions(overrides: Partial<Parameters<typeof handleMpvCommandFrom
PLAY_NEXT_SUBTITLE: '__play-next-subtitle',
YOUTUBE_PICKER_OPEN: '__youtube-picker-open',
PLAYLIST_BROWSER_OPEN: '__playlist-browser-open',
ANIME_BROWSER_OPEN: '__anime-browser-open',
},
triggerSubsyncFromConfig: () => {
calls.push('subsync');
@@ -38,6 +39,9 @@ function createOptions(overrides: Partial<Parameters<typeof handleMpvCommandFrom
openPlaylistBrowser: () => {
calls.push('playlist-browser');
},
openAnimeBrowser: () => {
calls.push('anime-browser');
},
runtimeOptionsCycle: () => ({ ok: true }),
showMpvOsd: (text) => {
osd.push(text);
+12
View File
@@ -20,6 +20,7 @@ export interface HandleMpvCommandFromIpcOptions {
PLAY_NEXT_SUBTITLE: string;
YOUTUBE_PICKER_OPEN: string;
PLAYLIST_BROWSER_OPEN: string;
ANIME_BROWSER_OPEN: string;
};
triggerSubsyncFromConfig: () => void;
openRuntimeOptionsPalette: () => void;
@@ -27,6 +28,7 @@ export interface HandleMpvCommandFromIpcOptions {
openTsukihime: () => void;
openYoutubeTrackPicker: () => void | Promise<void>;
openPlaylistBrowser: () => void | Promise<void>;
openAnimeBrowser: () => void | Promise<void>;
runtimeOptionsCycle: (id: RuntimeOptionId, direction: 1 | -1) => RuntimeOptionApplyResult;
showMpvOsd: (text: string) => void;
showRawMpvOsd?: (text: string) => void;
@@ -141,6 +143,16 @@ export function handleMpvCommandFromIpc(
return;
}
if (first === options.specialCommands.ANIME_BROWSER_OPEN) {
Promise.resolve()
.then(() => options.openAnimeBrowser())
.catch((error) => {
const message = error instanceof Error ? error.message : String(error);
options.showMpvOsd(`Anime Browser failed: ${message}`);
});
return;
}
if (first.startsWith(options.specialCommands.RUNTIME_OPTION_CYCLE_PREFIX)) {
if (!options.hasRuntimeOptionsManager()) return;
const [, idToken, directionToken] = first.split(':');
@@ -47,6 +47,9 @@ function createDeps(overrides: Partial<SessionActionExecutorDeps> = {}) {
openPlaylistBrowser: () => {
calls.push('playlist');
},
openAnimeBrowser: () => {
calls.push('anime-browser');
},
replayCurrentSubtitle: () => calls.push('replay'),
playNextSubtitle: () => calls.push('play-next'),
cycleRuntimeOption: () => ({ ok: true }),
+4
View File
@@ -27,6 +27,7 @@ export interface SessionActionExecutorDeps {
openTsukihime: () => void;
openYoutubeTrackPicker: () => void | Promise<void>;
openPlaylistBrowser: () => boolean | void | Promise<boolean | void>;
openAnimeBrowser: () => boolean | void | Promise<boolean | void>;
replayCurrentSubtitle: () => void;
playNextSubtitle: () => void;
cycleRuntimeOption: (id: RuntimeOptionId, direction: 1 | -1) => RuntimeOptionApplyResult;
@@ -125,6 +126,9 @@ export async function dispatchSessionAction(
case 'openPlaylistBrowser':
await deps.openPlaylistBrowser();
return;
case 'openAnimeBrowser':
await deps.openAnimeBrowser();
return;
case 'replayCurrentSubtitle':
deps.replayCurrentSubtitle();
return;
@@ -291,6 +291,7 @@ test('compileSessionBindings wires every default keybinding to an overlay or mpv
const expectedSpecialActions: Record<string, string> = {
[SPECIAL_COMMANDS.YOUTUBE_PICKER_OPEN]: 'openYoutubePicker',
[SPECIAL_COMMANDS.PLAYLIST_BROWSER_OPEN]: 'openPlaylistBrowser',
[SPECIAL_COMMANDS.ANIME_BROWSER_OPEN]: 'openAnimeBrowser',
[SPECIAL_COMMANDS.REPLAY_SUBTITLE]: 'replayCurrentSubtitle',
[SPECIAL_COMMANDS.PLAY_NEXT_SUBTITLE]: 'playNextSubtitle',
};
+4
View File
@@ -317,6 +317,10 @@ function resolveCommandBinding(
if (command.length !== 1) return null;
return { actionType: 'session-action', actionId: 'openPlaylistBrowser' };
}
if (first === SPECIAL_COMMANDS.ANIME_BROWSER_OPEN) {
if (command.length !== 1) return null;
return { actionType: 'session-action', actionId: 'openAnimeBrowser' };
}
if (first === SPECIAL_COMMANDS.REPLAY_SUBTITLE) {
if (command.length !== 1) return null;
return { actionType: 'session-action', actionId: 'replayCurrentSubtitle' };
+55 -14
View File
@@ -544,7 +544,11 @@ import {
createCreateAnimeBrowserWindowHandler,
} from './main/runtime/setup-window-factory';
import { createAnimeBrowserRuntime } from './main/runtime/anime-browser-runtime';
import { registerAnimeBrowserIpcHandlers } from './main/runtime/anime-browser-ipc-handlers';
import { openAnimeBrowserModal as openAnimeBrowserModalRuntime } from './main/runtime/anime-browser-open';
import {
registerAnimeBrowserIpcHandlers,
type AnimeBrowserIpcSender,
} from './main/runtime/anime-browser-ipc-handlers';
import { ensureBridgeBinaries } from './main/runtime/anime-bridge-installer';
import { createConfigSettingsRuntime } from './main/runtime/config-settings-runtime';
import { createOpenConfigSettingsWindowHandler } from './main/runtime/config-settings-window';
@@ -2965,6 +2969,24 @@ function openPlaylistBrowser(): void {
);
}
function openAnimeBrowserModal(): void {
if (!appState.mpvClient?.connected) {
overlayNotificationsRuntime.showConfiguredStatusNotification(
'The in-player Anime Browser requires active playback.',
{
title: 'Anime Browser',
variant: 'warning',
},
);
return;
}
openOverlayHostedModalWithOsd(
openAnimeBrowserModalRuntime,
'Anime Browser overlay unavailable.',
'Failed to open Anime Browser overlay.',
);
}
function getRuntimeBooleanOption(
id:
| 'subtitle.annotation.knownWords.highlightEnabled'
@@ -3303,6 +3325,17 @@ const {
});
const DEFAULT_ANIME_EXTENSIONS_DIR = path.join(USER_DATA_PATH, 'anime-extensions');
const animeBrowserSessions = new Map<string, AnimeBrowserIpcSender>();
function broadcastAnimeBrowserEvent(channel: string, payload: unknown): void {
const targets = new Set<AnimeBrowserIpcSender>();
const standalone = appState.animeBrowserWindow;
if (standalone && !standalone.isDestroyed()) targets.add(standalone.webContents);
for (const sender of animeBrowserSessions.values()) {
if (!sender.isDestroyed()) targets.add(sender);
}
for (const sender of targets) sender.send(channel, payload);
}
function resolveAnimeExtensionsDir(): string {
const configured = configService.getConfig().anime?.extensionsDir?.trim();
@@ -3392,27 +3425,33 @@ const animeBrowserRuntime = createAnimeBrowserRuntime({
});
},
onBridgeState: (state) => {
const window = appState.animeBrowserWindow;
if (window && !window.isDestroyed()) {
window.webContents.send(IPC_CHANNELS.event.animeBrowserBridgeState, state);
}
broadcastAnimeBrowserEvent(IPC_CHANNELS.event.animeBrowserBridgeState, state);
},
onSearchUpdate: (update) => {
const window = appState.animeBrowserWindow;
if (window && !window.isDestroyed()) {
window.webContents.send(IPC_CHANNELS.event.animeBrowserSearchUpdate, update);
onSearchUpdate: (update, sessionId) => {
const sender = animeBrowserSessions.get(sessionId);
if (sender && !sender.isDestroyed()) {
sender.send(IPC_CHANNELS.event.animeBrowserSearchUpdate, update);
}
},
onQueueState: (state) => {
const window = appState.animeBrowserWindow;
if (window && !window.isDestroyed()) {
window.webContents.send(IPC_CHANNELS.event.animeBrowserQueueState, state);
}
broadcastAnimeBrowserEvent(IPC_CHANNELS.event.animeBrowserQueueState, state);
},
log: (message) => logger.info(message),
});
registerAnimeBrowserIpcHandlers({ ipcMain, runtime: animeBrowserRuntime });
registerAnimeBrowserIpcHandlers({
ipcMain,
runtime: animeBrowserRuntime,
registerSession: (sessionId, sender) => {
if (animeBrowserSessions.get(sessionId) === sender) return;
animeBrowserSessions.set(sessionId, sender);
sender.once('destroyed', () => {
if (animeBrowserSessions.get(sessionId) !== sender) return;
animeBrowserSessions.delete(sessionId);
animeBrowserRuntime.releaseSession(sessionId);
});
},
});
let animeBrowserDockIconRetained = false;
const releaseAnimeBrowserDockIcon = (): void => {
@@ -5590,6 +5629,7 @@ async function dispatchSessionAction(request: SessionActionDispatchRequest): Pro
openControllerDebug: () => openControllerDebugOverlay(),
openYoutubeTrackPicker: () => openYoutubeTrackPickerFromPlayback(),
openPlaylistBrowser: () => openPlaylistBrowser(),
openAnimeBrowser: () => openAnimeBrowserModal(),
replayCurrentSubtitle: () => replayCurrentSubtitleRuntime(appState.mpvClient),
playNextSubtitle: () => playNextSubtitleRuntime(appState.mpvClient),
cycleRuntimeOption: (id, direction) => {
@@ -5620,6 +5660,7 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
openTsukihime: () => openTsukihimeOverlay(),
openYoutubeTrackPicker: () => openYoutubeTrackPickerFromPlayback(),
openPlaylistBrowser: () => openPlaylistBrowser(),
openAnimeBrowser: () => openAnimeBrowserModal(),
cycleRuntimeOption: (id, direction) => {
if (!appState.runtimeOptionsManager) {
return { ok: false, error: 'Runtime options manager unavailable' };
+2
View File
@@ -234,6 +234,7 @@ export interface MpvCommandRuntimeServiceDepsParams {
openTsukihime: HandleMpvCommandFromIpcOptions['openTsukihime'];
openYoutubeTrackPicker: HandleMpvCommandFromIpcOptions['openYoutubeTrackPicker'];
openPlaylistBrowser: HandleMpvCommandFromIpcOptions['openPlaylistBrowser'];
openAnimeBrowser: HandleMpvCommandFromIpcOptions['openAnimeBrowser'];
showMpvOsd: HandleMpvCommandFromIpcOptions['showMpvOsd'];
showRawMpvOsd?: HandleMpvCommandFromIpcOptions['showRawMpvOsd'];
showPlaybackFeedback?: HandleMpvCommandFromIpcOptions['showPlaybackFeedback'];
@@ -444,6 +445,7 @@ export function createMpvCommandRuntimeServiceDeps(
openTsukihime: params.openTsukihime,
openYoutubeTrackPicker: params.openYoutubeTrackPicker,
openPlaylistBrowser: params.openPlaylistBrowser,
openAnimeBrowser: params.openAnimeBrowser,
runtimeOptionsCycle: params.runtimeOptionsCycle,
showMpvOsd: params.showMpvOsd,
showRawMpvOsd: params.showRawMpvOsd,
+2
View File
@@ -16,6 +16,7 @@ export interface MpvCommandFromIpcRuntimeDeps {
openTsukihime: () => void;
openYoutubeTrackPicker: () => void | Promise<void>;
openPlaylistBrowser: () => void | Promise<void>;
openAnimeBrowser: () => void | Promise<void>;
cycleRuntimeOption: (id: RuntimeOptionId, direction: 1 | -1) => RuntimeOptionApplyResult;
showMpvOsd: (text: string) => void;
showRawMpvOsd?: (text: string) => void;
@@ -42,6 +43,7 @@ export function handleMpvCommandFromIpcRuntime(
openTsukihime: deps.openTsukihime,
openYoutubeTrackPicker: deps.openYoutubeTrackPicker,
openPlaylistBrowser: deps.openPlaylistBrowser,
openAnimeBrowser: deps.openAnimeBrowser,
runtimeOptionsCycle: deps.cycleRuntimeOption,
showMpvOsd: deps.showMpvOsd,
showRawMpvOsd: deps.showRawMpvOsd,
+45 -11
View File
@@ -12,6 +12,17 @@ export interface AnimeBrowserIpcDeps {
handle(channel: string, listener: (event: unknown, ...args: unknown[]) => unknown): unknown;
};
runtime: AnimeBrowserRuntime;
registerSession?: (sessionId: string, sender: AnimeBrowserIpcSender) => void;
}
export interface AnimeBrowserIpcSender {
send(channel: string, ...args: unknown[]): void;
isDestroyed(): boolean;
once(event: 'destroyed', listener: () => void): unknown;
}
interface AnimeBrowserIpcEvent {
sender: AnimeBrowserIpcSender;
}
/**
@@ -28,20 +39,36 @@ export function registerAnimeBrowserIpcHandlers(deps: AnimeBrowserIpcDeps): void
deps.ipcMain.handle(channel, listener);
};
handle(channels.animeBrowserGetSnapshot, () => runtime.getSnapshot());
handle(channels.animeBrowserEnsureBridge, () => runtime.ensureBridge());
handle(channels.animeBrowserSelectSource, (_event, sourceId) =>
runtime.selectSource(String(sourceId)),
handle(channels.animeBrowserGetSnapshot, (event, sessionId) => {
const session = registerSession(deps, event, sessionId);
return runtime.getSnapshot(session);
});
handle(channels.animeBrowserEnsureBridge, (event, sessionId) => {
registerSession(deps, event, sessionId);
return runtime.ensureBridge();
});
handle(channels.animeBrowserSelectSource, (event, sessionId, sourceId) =>
runtime.selectSource(String(sourceId), registerSession(deps, event, sessionId)),
);
handle(channels.animeBrowserSearch, (_event, query, page) =>
runtime.search(String(query ?? ''), toPage(page)),
handle(channels.animeBrowserSearch, (event, sessionId, query, page) =>
runtime.search(String(query ?? ''), toPage(page), registerSession(deps, event, sessionId)),
);
handle(channels.animeBrowserGetPopular, (_event, page) => runtime.getPopular(toPage(page)));
handle(channels.animeBrowserGetDetails, (_event, animeUrl, sourceId) =>
runtime.getDetails(String(animeUrl), toOptionalId(sourceId)),
handle(channels.animeBrowserGetPopular, (event, sessionId, page) =>
runtime.getPopular(toPage(page), registerSession(deps, event, sessionId)),
);
handle(channels.animeBrowserGetEpisodes, (_event, animeUrl, sourceId) =>
runtime.getEpisodes(String(animeUrl), toOptionalId(sourceId)),
handle(channels.animeBrowserGetDetails, (event, sessionId, animeUrl, sourceId) =>
runtime.getDetails(
String(animeUrl),
toOptionalId(sourceId),
registerSession(deps, event, sessionId),
),
);
handle(channels.animeBrowserGetEpisodes, (event, sessionId, animeUrl, sourceId) =>
runtime.getEpisodes(
String(animeUrl),
toOptionalId(sourceId),
registerSession(deps, event, sessionId),
),
);
handle(channels.animeBrowserGetWatchState, (_event, request) =>
runtime.getWatchState(toWatchStateRequest(request)),
@@ -79,6 +106,13 @@ export function registerAnimeBrowserIpcHandlers(deps: AnimeBrowserIpcDeps): void
);
}
function registerSession(deps: AnimeBrowserIpcDeps, event: unknown, sessionId: unknown): string {
const normalized = typeof sessionId === 'string' && sessionId.length > 0 ? sessionId : 'default';
const sender = (event as AnimeBrowserIpcEvent | undefined)?.sender;
if (sender) deps.registerSession?.(normalized, sender);
return normalized;
}
/** Coerce a play (or queue) request at the renderer trust boundary. */
function toPlayRequest(value: unknown): AnimeBrowserPlayRequest {
const request = (value ?? {}) as Partial<AnimeBrowserPlayRequest>;
@@ -0,0 +1,44 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { IPC_CHANNELS } from '../../shared/ipc/contracts';
import { openAnimeBrowserModal } from './anime-browser-open';
test('anime browser open uses a dedicated player-bounded modal window', async () => {
const calls: string[] = [];
const opened = await openAnimeBrowserModal({
ensureOverlayStartupPrereqs: () => calls.push('prereqs'),
ensureOverlayWindowsReadyForVisibilityActions: () => calls.push('windows'),
sendToActiveOverlayWindow: (channel, payload, runtimeOptions) => {
calls.push(`send:${channel}`);
assert.equal(payload, undefined);
assert.deepEqual(runtimeOptions, {
restoreOnModalClose: 'anime-browser',
preferModalWindow: true,
});
return true;
},
waitForModalOpen: async () => true,
logWarn: () => {},
});
assert.equal(opened, true);
assert.deepEqual(calls, ['prereqs', 'windows', `send:${IPC_CHANNELS.event.animeBrowserOpen}`]);
});
test('anime browser open retries on a fresh modal window after a missed acknowledgement', async () => {
let attempts = 0;
const opened = await openAnimeBrowserModal({
ensureOverlayStartupPrereqs: () => {},
ensureOverlayWindowsReadyForVisibilityActions: () => {},
sendToActiveOverlayWindow: () => true,
waitForModalOpen: async () => {
attempts += 1;
return attempts === 2;
},
logWarn: () => {},
});
assert.equal(opened, true);
assert.equal(attempts, 2);
});
+48
View File
@@ -0,0 +1,48 @@
import type { OverlayHostedModal } from '../../shared/ipc/contracts';
import { IPC_CHANNELS } from '../../shared/ipc/contracts';
import { openOverlayHostedModal, retryOverlayModalOpen } from './overlay-hosted-modal-open';
const ANIME_BROWSER_MODAL: OverlayHostedModal = 'anime-browser';
const ANIME_BROWSER_OPEN_TIMEOUT_MS = 1500;
export async function openAnimeBrowserModal(deps: {
ensureOverlayStartupPrereqs: () => void;
ensureOverlayWindowsReadyForVisibilityActions: () => void;
sendToActiveOverlayWindow: (
channel: string,
payload?: unknown,
runtimeOptions?: {
restoreOnModalClose?: OverlayHostedModal;
preferModalWindow?: boolean;
},
) => boolean;
waitForModalOpen: (modal: OverlayHostedModal, timeoutMs: number) => Promise<boolean>;
logWarn: (message: string) => void;
}): Promise<boolean> {
return await retryOverlayModalOpen(
{
waitForModalOpen: deps.waitForModalOpen,
logWarn: deps.logWarn,
},
{
modal: ANIME_BROWSER_MODAL,
timeoutMs: ANIME_BROWSER_OPEN_TIMEOUT_MS,
retryWarning:
'Anime Browser modal did not acknowledge modal open on first attempt; retrying dedicated modal window.',
sendOpen: () =>
openOverlayHostedModal(
{
ensureOverlayStartupPrereqs: deps.ensureOverlayStartupPrereqs,
ensureOverlayWindowsReadyForVisibilityActions:
deps.ensureOverlayWindowsReadyForVisibilityActions,
sendToActiveOverlayWindow: deps.sendToActiveOverlayWindow,
},
{
channel: IPC_CHANNELS.event.animeBrowserOpen,
modal: ANIME_BROWSER_MODAL,
preferModalWindow: true,
},
),
},
);
}
@@ -64,7 +64,7 @@ export interface AnimeBrowserRuntimeDeps {
/** Pushes the play queue to the browser window, advances included. */
onQueueState?: (state: AnimeBrowserQueueState) => void;
/** Streams per-source progress while a search invoke is pending. */
onSearchUpdate?: (update: AnimeBrowserSearchUpdate) => void;
onSearchUpdate?: (update: AnimeBrowserSearchUpdate, sessionId: string) => void;
preferredQuality?: () => string | undefined;
log: (message: string) => void;
/** Overrides process startup in focused runtime tests. */
@@ -4,6 +4,8 @@ import { mkdtemp, readFile, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import type { BridgePreference } from '../../anime-bridge/types';
import type { BridgeAnimePage } from '../../anime-bridge/types';
import type { AnimeBrowserSearchUpdate } from '../../types/anime-browser';
import { createAnimeBrowserRuntime } from './anime-browser-runtime';
import type { AnimeBridgeClient } from '../../anime-bridge/bridge-client';
@@ -15,6 +17,7 @@ async function setupRuntime(
client: Record<string, unknown>,
packages: Record<string, string>,
storedPreferences?: Record<string, BridgePreference[]>,
onSearchUpdate?: (update: AnimeBrowserSearchUpdate, sessionId: string) => void,
) {
const dir = await mkdtemp(path.join(tmpdir(), 'subminer-anime-runtime-'));
for (const [pkg, contents] of Object.entries(packages)) {
@@ -31,6 +34,7 @@ async function setupRuntime(
sendMpvCommand: () => undefined,
ensureMpvConnected: async () => true,
onBridgeState: () => undefined,
onSearchUpdate,
log: () => undefined,
startSidecar: async () => ({
client: client as unknown as AnimeBridgeClient,
@@ -136,3 +140,72 @@ test('colliding bridge ids keep package preferences isolated and uninstall clear
assert.deepEqual(persisted['pkg.two:shared'], [two]);
await runtime.dispose();
});
test('standalone and modal browser sessions keep source selection and searches isolated', async () => {
const client = {
listAnimeSources: async () => [{ id: 'shared', name: 'Source', lang: 'en' }],
searchAnime: async (source: { fingerprint: string }, query: string) => ({
animes: [{ url: `${source.fingerprint}/${query}`, title: source.fingerprint }],
hasNextPage: false,
}),
};
const { runtime } = await setupRuntime(client, { 'pkg.one': 'one', 'pkg.two': 'two' });
const sourceIds = runtime.getSnapshot('standalone').sources.map((source) => source.id);
runtime.selectSource(sourceIds[0]!, 'standalone');
runtime.selectSource(sourceIds[1]!, 'modal');
const [standalone, modal] = await Promise.all([
runtime.search('one', 1, 'standalone'),
runtime.search('two', 1, 'modal'),
]);
assert.equal(runtime.getSnapshot('standalone').selectedSourceId, sourceIds[0]);
assert.equal(runtime.getSnapshot('modal').selectedSourceId, sourceIds[1]);
assert.equal(standalone.entries[0]?.sourceId, sourceIds[0]);
assert.equal(modal.entries[0]?.sourceId, sourceIds[1]);
await runtime.dispose();
});
test('releasing a browser session suppresses its pending search updates', async () => {
let resolveOldSearch: (page: BridgeAnimePage) => void = () => undefined;
let notifyOldSearchStarted: () => void = () => undefined;
const oldSearchStarted = new Promise<void>((resolve) => {
notifyOldSearchStarted = resolve;
});
const updates: Array<{ sessionId: string; update: AnimeBrowserSearchUpdate }> = [];
const client = {
listAnimeSources: async () => [{ id: 'shared', name: 'Source', lang: 'en' }],
searchAnime: async (_source: unknown, query: string): Promise<BridgeAnimePage> => {
if (query !== 'old') {
return { animes: [{ url: `/${query}`, title: query }], hasNextPage: false };
}
notifyOldSearchStarted();
return await new Promise<BridgeAnimePage>((resolve) => {
resolveOldSearch = resolve;
});
},
};
const { runtime } = await setupRuntime(
client,
{ 'pkg.one': 'one' },
undefined,
(update, sessionId) => {
updates.push({ sessionId, update });
},
);
const oldSearch = runtime.search('old', 1, 'reused');
await oldSearchStarted;
runtime.releaseSession('reused');
await runtime.search('new', 1, 'reused');
resolveOldSearch({ animes: [{ url: '/old', title: 'old' }], hasNextPage: false });
await oldSearch;
const resultUrls = updates.flatMap(({ sessionId, update }) =>
sessionId === 'reused' && update.kind === 'result'
? update.entries.map((entry) => entry.url)
: [],
);
assert.deepEqual(resultUrls, ['/new']);
await runtime.dispose();
});
+61 -24
View File
@@ -63,12 +63,21 @@ export function createAnimeBrowserRuntime(deps: AnimeBrowserRuntimeDeps) {
let starting: Promise<SidecarHandle> | null = null;
let extensions: InstalledExtension[] = [];
let sources: ExtensionSource[] = [];
let selectedSourceId: string | null = null;
let loadFailures: ExtensionLoadFailure[] = [];
// Monotonic; identifies the newest browse so stale ones stop emitting.
let searchToken = 0;
const browserSessions = new Map<
string,
{ selectedSourceId: string | null; searchToken: number }
>();
const preferenceStore = new PreferenceStore(deps.preferencesFile);
function getBrowserSession(sessionId = 'default') {
const existing = browserSessions.get(sessionId);
if (existing) return existing;
const created = { selectedSourceId: sources[0]?.id ?? null, searchToken: 0 };
browserSessions.set(sessionId, created);
return created;
}
function setState(state: AnimeBrowserBridgeState): void {
bridgeState = state;
deps.onBridgeState(state);
@@ -175,11 +184,13 @@ export function createAnimeBrowserRuntime(deps: AnimeBrowserRuntimeDeps) {
deps.log(`[anime-bridge] extension ${extension.fallbackName} failed to load: ${message}`);
});
// Keep the current selection if it survived the rescan. "All sources"
// survives as long as anything is installed.
const keptAll = selectedSourceId === ALL_SOURCES_ID && sources.length > 0;
if (!keptAll && !sources.some((source) => source.id === selectedSourceId)) {
selectedSourceId = sources[0]?.id ?? null;
// Each open browser keeps its own source selection. Reconcile all of them
// after a rescan without making one renderer change another renderer's UI.
for (const session of browserSessions.values()) {
const keptAll = session.selectedSourceId === ALL_SOURCES_ID && sources.length > 0;
if (!keptAll && !sources.some((source) => source.id === session.selectedSourceId)) {
session.selectedSourceId = sources[0]?.id ?? null;
}
}
setState({
@@ -252,23 +263,27 @@ export function createAnimeBrowserRuntime(deps: AnimeBrowserRuntimeDeps) {
*/
async function browse(
page: number,
sessionId: string,
fetchPage: (
source: Awaited<ReturnType<typeof sourceFor>>,
page: number,
) => Promise<BridgeAnimePage>,
): Promise<AnimeBrowserSearchResult> {
const { baseUrl } = await bridge();
const session = getBrowserSession(sessionId);
const targets =
selectedSourceId === ALL_SOURCES_ID ? sources : [requireSource(selectedSourceId)];
session.selectedSourceId === ALL_SOURCES_ID
? sources
: [requireSource(session.selectedSourceId)];
if (targets.length === 0) throw new Error('No sources are installed.');
// Each source's answer is pushed the moment it lands, so a fast source is
// on screen while a slow one is still resolving. Guarded by the token: a
// superseded search stops emitting, and its remaining sources run out
// quietly.
const token = ++searchToken;
const token = ++session.searchToken;
const emit = (update: AnimeBrowserSearchUpdate): void => {
if (token === searchToken) deps.onSearchUpdate?.(update);
if (token === session.searchToken) deps.onSearchUpdate?.(update, sessionId);
};
emit({ kind: 'start', token, sourceCount: targets.length });
@@ -370,7 +385,8 @@ export function createAnimeBrowserRuntime(deps: AnimeBrowserRuntimeDeps) {
});
return {
getSnapshot(): AnimeBrowserSnapshot {
getSnapshot(sessionId = 'default'): AnimeBrowserSnapshot {
const session = getBrowserSession(sessionId);
return {
bridge: bridgeState,
sources: sources.map((source) => ({
@@ -379,7 +395,7 @@ export function createAnimeBrowserRuntime(deps: AnimeBrowserRuntimeDeps) {
lang: source.lang,
pkg: source.pkg,
})),
selectedSourceId,
selectedSourceId: session.selectedSourceId,
loadFailures,
installed: toInstalledExtensionViews(extensions, sources, loadFailures),
extensionsDir: deps.extensionsDir(),
@@ -458,12 +474,13 @@ export function createAnimeBrowserRuntime(deps: AnimeBrowserRuntimeDeps) {
if (sidecar) await scanExtensions(sidecar);
},
selectSource(sourceId: string): void {
selectSource(sourceId: string, sessionId = 'default'): void {
const session = getBrowserSession(sessionId);
if (sourceId === ALL_SOURCES_ID && sources.length > 0) {
selectedSourceId = ALL_SOURCES_ID;
session.selectedSourceId = ALL_SOURCES_ID;
return;
}
if (sources.some((source) => source.id === sourceId)) selectedSourceId = sourceId;
if (sources.some((source) => source.id === sourceId)) session.selectedSourceId = sourceId;
},
/**
@@ -507,21 +524,31 @@ export function createAnimeBrowserRuntime(deps: AnimeBrowserRuntimeDeps) {
return parsePreferences(refreshed.length > 0 ? refreshed : updated);
},
async search(query: string, page = 1): Promise<AnimeBrowserSearchResult> {
async search(
query: string,
page = 1,
sessionId = 'default',
): Promise<AnimeBrowserSearchResult> {
const { client } = await bridge();
return browse(page, (source, requestedPage) =>
return browse(page, sessionId, (source, requestedPage) =>
client.searchAnime(source, query, requestedPage),
);
},
async getPopular(page = 1): Promise<AnimeBrowserSearchResult> {
async getPopular(page = 1, sessionId = 'default'): Promise<AnimeBrowserSearchResult> {
const { client } = await bridge();
return browse(page, (source, requestedPage) => client.getPopularAnime(source, requestedPage));
return browse(page, sessionId, (source, requestedPage) =>
client.getPopularAnime(source, requestedPage),
);
},
async getDetails(animeUrl: string, sourceId?: string): Promise<AnimeBrowserDetails> {
async getDetails(
animeUrl: string,
sourceId?: string,
sessionId = 'default',
): Promise<AnimeBrowserDetails> {
const { client, baseUrl } = await bridge();
const source = requireSource(sourceId ?? selectedSourceId);
const source = requireSource(sourceId ?? getBrowserSession(sessionId).selectedSourceId);
const details = await client.getAnimeDetails(await sourceFor(source.id), animeUrl);
return {
...toEntry(baseUrl, { ...details, url: details.url ?? animeUrl }, source),
@@ -532,9 +559,13 @@ export function createAnimeBrowserRuntime(deps: AnimeBrowserRuntimeDeps) {
};
},
async getEpisodes(animeUrl: string, sourceId?: string): Promise<AnimeBrowserEpisode[]> {
async getEpisodes(
animeUrl: string,
sourceId?: string,
sessionId = 'default',
): Promise<AnimeBrowserEpisode[]> {
const { client } = await bridge();
const source = requireSource(sourceId ?? selectedSourceId);
const source = requireSource(sourceId ?? getBrowserSession(sessionId).selectedSourceId);
const episodes = await client.getEpisodeList(await sourceFor(source.id), animeUrl);
return episodes.map((episode) => ({
url: episode.url ?? '',
@@ -632,6 +663,12 @@ export function createAnimeBrowserRuntime(deps: AnimeBrowserRuntimeDeps) {
}
},
releaseSession(sessionId: string): void {
const session = browserSessions.get(sessionId);
if (session) session.searchToken += 1;
browserSessions.delete(sessionId);
},
async dispose(): Promise<void> {
const handle = sidecar;
const proxy = stripProxy;
@@ -14,6 +14,7 @@ test('composeIpcRuntimeHandlers returns callable IPC handlers and registration b
openTsukihime: () => {},
openYoutubeTrackPicker: () => {},
openPlaylistBrowser: () => {},
openAnimeBrowser: () => {},
cycleRuntimeOption: () => ({ ok: true }),
showMpvOsd: () => {},
replayCurrentSubtitle: () => {},
@@ -17,6 +17,7 @@ test('ipc bridge action main deps builders map callbacks', async () => {
openTsukihime: () => {},
openYoutubeTrackPicker: () => {},
openPlaylistBrowser: () => {},
openAnimeBrowser: () => {},
cycleRuntimeOption: () => ({ ok: false as const, error: 'x' }),
showMpvOsd: () => {},
replayCurrentSubtitle: () => {},
@@ -14,6 +14,7 @@ test('handle mpv command handler forwards command and built deps', () => {
openTsukihime: () => {},
openYoutubeTrackPicker: () => {},
openPlaylistBrowser: () => {},
openAnimeBrowser: () => {},
cycleRuntimeOption: () => ({ ok: false as const, error: 'x' }),
showMpvOsd: () => {},
replayCurrentSubtitle: () => {},
@@ -15,6 +15,9 @@ test('ipc mpv command main deps builder maps callbacks', () => {
openPlaylistBrowser: () => {
calls.push('playlist-browser');
},
openAnimeBrowser: () => {
calls.push('anime-browser');
},
cycleRuntimeOption: () => ({ ok: false as const, error: 'x' }),
showMpvOsd: (text) => calls.push(`osd:${text}`),
showRawMpvOsd: (text) => calls.push(`raw-osd:${text}`),
@@ -33,6 +36,7 @@ test('ipc mpv command main deps builder maps callbacks', () => {
deps.openTsukihime();
void deps.openYoutubeTrackPicker();
void deps.openPlaylistBrowser();
void deps.openAnimeBrowser();
assert.deepEqual(deps.cycleRuntimeOption('anki.nPlusOneMatchMode', 1), { ok: false, error: 'x' });
deps.showMpvOsd('hello');
deps.showRawMpvOsd?.('delay');
@@ -50,6 +54,7 @@ test('ipc mpv command main deps builder maps callbacks', () => {
'tsukihime',
'youtube-picker',
'playlist-browser',
'anime-browser',
'osd:hello',
'raw-osd:delay',
'feedback:primary',
@@ -13,6 +13,7 @@ export function createBuildMpvCommandFromIpcRuntimeMainDepsHandler(
openTsukihime: () => deps.openTsukihime(),
openYoutubeTrackPicker: () => deps.openYoutubeTrackPicker(),
openPlaylistBrowser: () => deps.openPlaylistBrowser(),
openAnimeBrowser: () => deps.openAnimeBrowser(),
cycleRuntimeOption: (id, direction) => deps.cycleRuntimeOption(id, direction),
showMpvOsd: (text: string) => deps.showMpvOsd(text),
...(showRawMpvOsd ? { showRawMpvOsd: (text: string) => showRawMpvOsd(text) } : {}),
+66
View File
@@ -0,0 +1,66 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { IPC_CHANNELS } from './shared/ipc/contracts';
import { createAnimeBrowserAPI } from './preload-anime-browser-api';
function createIpcRecorder() {
const calls: Array<{ channel: string; args: unknown[] }> = [];
return {
calls,
ipc: {
invoke: async (channel: string, ...args: unknown[]) => {
calls.push({ channel, args });
return undefined;
},
on: () => {},
removeListener: () => {},
},
};
}
test('anime browser API keeps one opaque session per renderer bridge', async () => {
const first = createIpcRecorder();
const second = createIpcRecorder();
const firstApi = createAnimeBrowserAPI(first.ipc as never);
const secondApi = createAnimeBrowserAPI(second.ipc as never);
await firstApi.getSnapshot();
await firstApi.selectSource('source.one');
await firstApi.search('frieren', 2);
await firstApi.getDetails('/frieren');
await firstApi.getEpisodes('/frieren', 'source.one');
await secondApi.getPopular(3);
const firstSessionId = first.calls[0]?.args[0];
const secondSessionId = second.calls[0]?.args[0];
assert.equal(typeof firstSessionId, 'string');
assert.notEqual(firstSessionId, secondSessionId);
assert.deepEqual(first.calls, [
{
channel: IPC_CHANNELS.request.animeBrowserGetSnapshot,
args: [firstSessionId],
},
{
channel: IPC_CHANNELS.request.animeBrowserSelectSource,
args: [firstSessionId, 'source.one'],
},
{
channel: IPC_CHANNELS.request.animeBrowserSearch,
args: [firstSessionId, 'frieren', 2],
},
{
channel: IPC_CHANNELS.request.animeBrowserGetDetails,
args: [firstSessionId, '/frieren', undefined],
},
{
channel: IPC_CHANNELS.request.animeBrowserGetEpisodes,
args: [firstSessionId, '/frieren', 'source.one'],
},
]);
assert.deepEqual(second.calls, [
{
channel: IPC_CHANNELS.request.animeBrowserGetPopular,
args: [secondSessionId, 3],
},
]);
});
+105
View File
@@ -0,0 +1,105 @@
import type { IpcRenderer } from 'electron';
import { IPC_CHANNELS } from './shared/ipc/contracts';
import type {
AnimeBrowserAPI,
AnimeBrowserBridgeState,
AnimeBrowserDetails,
AnimeBrowserEpisode,
AnimeBrowserEpisodeWatchState,
AnimeBrowserPlayRequest,
AnimeBrowserPlayResult,
AnimeBrowserQueueState,
AnimeBrowserSearchResult,
AnimeBrowserSearchUpdate,
AnimeBrowserSetWatchedRequest,
AnimeBrowserSnapshot,
AnimeBrowserWatchStateRequest,
AvailableExtensionsResult,
SourcePreferenceView,
} from './types/anime-browser';
type AnimeBrowserIpcRenderer = Pick<IpcRenderer, 'invoke' | 'on' | 'removeListener'>;
/**
* Build the browser bridge for one renderer. The opaque session id keeps
* source selection and in-flight searches independent between the standalone
* window and the player-hosted modal.
*/
export function createAnimeBrowserAPI(ipcRenderer: AnimeBrowserIpcRenderer): AnimeBrowserAPI {
const request = IPC_CHANNELS.request;
// Sandboxed Electron preloads cannot require arbitrary Node built-ins.
// Web Crypto is available in the isolated renderer world; the fallback only
// covers test or legacy contexts where randomUUID is absent.
const sessionId =
globalThis.crypto?.randomUUID?.() ??
`${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
return {
getSnapshot: (): Promise<AnimeBrowserSnapshot> =>
ipcRenderer.invoke(request.animeBrowserGetSnapshot, sessionId),
ensureBridge: (): Promise<AnimeBrowserBridgeState> =>
ipcRenderer.invoke(request.animeBrowserEnsureBridge, sessionId),
selectSource: (sourceId: string): Promise<void> =>
ipcRenderer.invoke(request.animeBrowserSelectSource, sessionId, sourceId),
search: (query: string, page?: number): Promise<AnimeBrowserSearchResult> =>
ipcRenderer.invoke(request.animeBrowserSearch, sessionId, query, page),
getPopular: (page?: number): Promise<AnimeBrowserSearchResult> =>
ipcRenderer.invoke(request.animeBrowserGetPopular, sessionId, page),
getDetails: (animeUrl: string, sourceId?: string): Promise<AnimeBrowserDetails> =>
ipcRenderer.invoke(request.animeBrowserGetDetails, sessionId, animeUrl, sourceId),
getEpisodes: (animeUrl: string, sourceId?: string): Promise<AnimeBrowserEpisode[]> =>
ipcRenderer.invoke(request.animeBrowserGetEpisodes, sessionId, animeUrl, sourceId),
getWatchState: (
watchRequest: AnimeBrowserWatchStateRequest,
): Promise<AnimeBrowserEpisodeWatchState[]> =>
ipcRenderer.invoke(request.animeBrowserGetWatchState, watchRequest),
setWatched: (
watchedRequest: AnimeBrowserSetWatchedRequest,
): Promise<AnimeBrowserEpisodeWatchState[]> =>
ipcRenderer.invoke(request.animeBrowserSetWatched, watchedRequest),
playEpisode: (playRequest: AnimeBrowserPlayRequest): Promise<AnimeBrowserPlayResult> =>
ipcRenderer.invoke(request.animeBrowserPlayEpisode, playRequest),
queueEpisode: (playRequest: AnimeBrowserPlayRequest): Promise<AnimeBrowserQueueState> =>
ipcRenderer.invoke(request.animeBrowserQueueEpisode, playRequest),
dequeueEpisode: (sourceId: string, episodeUrl: string): Promise<AnimeBrowserQueueState> =>
ipcRenderer.invoke(request.animeBrowserDequeueEpisode, sourceId, episodeUrl),
clearQueue: (): Promise<AnimeBrowserQueueState> =>
ipcRenderer.invoke(request.animeBrowserClearQueue),
getQueue: (): Promise<AnimeBrowserQueueState> =>
ipcRenderer.invoke(request.animeBrowserGetQueue),
isPlaying: (): Promise<boolean> => ipcRenderer.invoke(request.animeBrowserIsPlaying),
getPreferences: (sourceId: string): Promise<SourcePreferenceView[]> =>
ipcRenderer.invoke(request.animeBrowserGetPreferences, sourceId),
setPreference: (
sourceId: string,
key: string,
value: string | string[] | boolean,
): Promise<SourcePreferenceView[]> =>
ipcRenderer.invoke(request.animeBrowserSetPreference, sourceId, key, value),
listAvailableExtensions: (): Promise<AvailableExtensionsResult> =>
ipcRenderer.invoke(request.animeBrowserListAvailableExtensions),
installExtension: (pkg: string): Promise<void> =>
ipcRenderer.invoke(request.animeBrowserInstallExtension, pkg),
removeExtension: (pkg: string): Promise<void> =>
ipcRenderer.invoke(request.animeBrowserRemoveExtension, pkg),
rescanExtensions: (): Promise<void> => ipcRenderer.invoke(request.animeBrowserRescanExtensions),
addRepo: (url: string): Promise<void> => ipcRenderer.invoke(request.animeBrowserAddRepo, url),
removeRepo: (url: string): Promise<void> =>
ipcRenderer.invoke(request.animeBrowserRemoveRepo, url),
onBridgeState: (listener: (state: AnimeBrowserBridgeState) => void): (() => void) => {
const handler = (_event: unknown, state: AnimeBrowserBridgeState): void => listener(state);
ipcRenderer.on(IPC_CHANNELS.event.animeBrowserBridgeState, handler);
return () => ipcRenderer.removeListener(IPC_CHANNELS.event.animeBrowserBridgeState, handler);
},
onSearchUpdate: (listener: (update: AnimeBrowserSearchUpdate) => void): (() => void) => {
const handler = (_event: unknown, update: AnimeBrowserSearchUpdate): void => listener(update);
ipcRenderer.on(IPC_CHANNELS.event.animeBrowserSearchUpdate, handler);
return () => ipcRenderer.removeListener(IPC_CHANNELS.event.animeBrowserSearchUpdate, handler);
},
onQueueState: (listener: (state: AnimeBrowserQueueState) => void): (() => void) => {
const handler = (_event: unknown, state: AnimeBrowserQueueState): void => listener(state);
ipcRenderer.on(IPC_CHANNELS.event.animeBrowserQueueState, handler);
return () => ipcRenderer.removeListener(IPC_CHANNELS.event.animeBrowserQueueState, handler);
},
};
}
+2 -89
View File
@@ -1,91 +1,4 @@
import { contextBridge, ipcRenderer } from 'electron';
import { IPC_CHANNELS } from './shared/ipc/contracts';
import type {
AnimeBrowserAPI,
AnimeBrowserBridgeState,
AnimeBrowserDetails,
AnimeBrowserEpisode,
AnimeBrowserEpisodeWatchState,
AnimeBrowserWatchStateRequest,
AnimeBrowserSetWatchedRequest,
AnimeBrowserPlayRequest,
AnimeBrowserPlayResult,
AnimeBrowserQueueState,
AnimeBrowserSearchResult,
AnimeBrowserSearchUpdate,
AnimeBrowserSnapshot,
AvailableExtensionsResult,
SourcePreferenceView,
} from './types/anime-browser';
import { createAnimeBrowserAPI } from './preload-anime-browser-api';
const request = IPC_CHANNELS.request;
const animeBrowserAPI: AnimeBrowserAPI = {
getSnapshot: (): Promise<AnimeBrowserSnapshot> =>
ipcRenderer.invoke(request.animeBrowserGetSnapshot),
ensureBridge: (): Promise<AnimeBrowserBridgeState> =>
ipcRenderer.invoke(request.animeBrowserEnsureBridge),
selectSource: (sourceId: string): Promise<void> =>
ipcRenderer.invoke(request.animeBrowserSelectSource, sourceId),
search: (query: string, page?: number): Promise<AnimeBrowserSearchResult> =>
ipcRenderer.invoke(request.animeBrowserSearch, query, page),
getPopular: (page?: number): Promise<AnimeBrowserSearchResult> =>
ipcRenderer.invoke(request.animeBrowserGetPopular, page),
getDetails: (animeUrl: string, sourceId?: string): Promise<AnimeBrowserDetails> =>
ipcRenderer.invoke(request.animeBrowserGetDetails, animeUrl, sourceId),
getEpisodes: (animeUrl: string, sourceId?: string): Promise<AnimeBrowserEpisode[]> =>
ipcRenderer.invoke(request.animeBrowserGetEpisodes, animeUrl, sourceId),
getWatchState: (
watchRequest: AnimeBrowserWatchStateRequest,
): Promise<AnimeBrowserEpisodeWatchState[]> =>
ipcRenderer.invoke(request.animeBrowserGetWatchState, watchRequest),
setWatched: (
watchedRequest: AnimeBrowserSetWatchedRequest,
): Promise<AnimeBrowserEpisodeWatchState[]> =>
ipcRenderer.invoke(request.animeBrowserSetWatched, watchedRequest),
playEpisode: (playRequest: AnimeBrowserPlayRequest): Promise<AnimeBrowserPlayResult> =>
ipcRenderer.invoke(request.animeBrowserPlayEpisode, playRequest),
queueEpisode: (playRequest: AnimeBrowserPlayRequest): Promise<AnimeBrowserQueueState> =>
ipcRenderer.invoke(request.animeBrowserQueueEpisode, playRequest),
dequeueEpisode: (sourceId: string, episodeUrl: string): Promise<AnimeBrowserQueueState> =>
ipcRenderer.invoke(request.animeBrowserDequeueEpisode, sourceId, episodeUrl),
clearQueue: (): Promise<AnimeBrowserQueueState> =>
ipcRenderer.invoke(request.animeBrowserClearQueue),
getQueue: (): Promise<AnimeBrowserQueueState> => ipcRenderer.invoke(request.animeBrowserGetQueue),
isPlaying: (): Promise<boolean> => ipcRenderer.invoke(request.animeBrowserIsPlaying),
getPreferences: (sourceId: string): Promise<SourcePreferenceView[]> =>
ipcRenderer.invoke(request.animeBrowserGetPreferences, sourceId),
setPreference: (
sourceId: string,
key: string,
value: string | string[] | boolean,
): Promise<SourcePreferenceView[]> =>
ipcRenderer.invoke(request.animeBrowserSetPreference, sourceId, key, value),
listAvailableExtensions: (): Promise<AvailableExtensionsResult> =>
ipcRenderer.invoke(request.animeBrowserListAvailableExtensions),
installExtension: (pkg: string): Promise<void> =>
ipcRenderer.invoke(request.animeBrowserInstallExtension, pkg),
removeExtension: (pkg: string): Promise<void> =>
ipcRenderer.invoke(request.animeBrowserRemoveExtension, pkg),
rescanExtensions: (): Promise<void> => ipcRenderer.invoke(request.animeBrowserRescanExtensions),
addRepo: (url: string): Promise<void> => ipcRenderer.invoke(request.animeBrowserAddRepo, url),
removeRepo: (url: string): Promise<void> =>
ipcRenderer.invoke(request.animeBrowserRemoveRepo, url),
onBridgeState: (listener: (state: AnimeBrowserBridgeState) => void): (() => void) => {
const handler = (_event: unknown, state: AnimeBrowserBridgeState): void => listener(state);
ipcRenderer.on(IPC_CHANNELS.event.animeBrowserBridgeState, handler);
return () => ipcRenderer.removeListener(IPC_CHANNELS.event.animeBrowserBridgeState, handler);
},
onSearchUpdate: (listener: (update: AnimeBrowserSearchUpdate) => void): (() => void) => {
const handler = (_event: unknown, update: AnimeBrowserSearchUpdate): void => listener(update);
ipcRenderer.on(IPC_CHANNELS.event.animeBrowserSearchUpdate, handler);
return () => ipcRenderer.removeListener(IPC_CHANNELS.event.animeBrowserSearchUpdate, handler);
},
onQueueState: (listener: (state: AnimeBrowserQueueState) => void): (() => void) => {
const handler = (_event: unknown, state: AnimeBrowserQueueState): void => listener(state);
ipcRenderer.on(IPC_CHANNELS.event.animeBrowserQueueState, handler);
return () => ipcRenderer.removeListener(IPC_CHANNELS.event.animeBrowserQueueState, handler);
},
};
contextBridge.exposeInMainWorld('animeBrowserAPI', animeBrowserAPI);
contextBridge.exposeInMainWorld('animeBrowserAPI', createAnimeBrowserAPI(ipcRenderer));
+4
View File
@@ -71,6 +71,7 @@ import type {
ChangelogSnapshot,
} from './types';
import { IPC_CHANNELS } from './shared/ipc/contracts';
import { createAnimeBrowserAPI } from './preload-anime-browser-api';
const overlayLayer = resolveOverlayLayerFromArgv(process.argv);
@@ -168,6 +169,7 @@ function createLatestValueIpcListenerWithPayload<T>(
const onOpenRuntimeOptionsEvent = createQueuedIpcListener(IPC_CHANNELS.event.runtimeOptionsOpen);
const onOpenSessionHelpEvent = createQueuedIpcListener(IPC_CHANNELS.event.sessionHelpOpen);
const onOpenChangelogEvent = createQueuedIpcListener(IPC_CHANNELS.event.changelogOpen);
const onOpenAnimeBrowserEvent = createQueuedIpcListener(IPC_CHANNELS.event.animeBrowserOpen);
const onOpenCharacterDictionaryManagerEvent = createQueuedIpcListener(
IPC_CHANNELS.event.characterDictionaryManagerOpen,
);
@@ -451,6 +453,7 @@ const electronAPI: ElectronAPI = {
onOpenRuntimeOptions: onOpenRuntimeOptionsEvent,
onOpenSessionHelp: onOpenSessionHelpEvent,
onOpenChangelog: onOpenChangelogEvent,
onOpenAnimeBrowser: onOpenAnimeBrowserEvent,
getChangelogSnapshot: (options?: { refresh?: boolean }): Promise<ChangelogSnapshot> =>
ipcRenderer.invoke(IPC_CHANNELS.request.getChangelogSnapshot, options),
onOpenControllerSelect: onOpenControllerSelectEvent,
@@ -528,3 +531,4 @@ const electronAPI: ElectronAPI = {
};
contextBridge.exposeInMainWorld('electronAPI', electronAPI);
contextBridge.exposeInMainWorld('animeBrowserAPI', createAnimeBrowserAPI(ipcRenderer));
+1
View File
@@ -990,6 +990,7 @@ test('default keybindings dispatch through overlay keyboard handling', async ()
const specialActionIds: Record<string, string> = {
[SPECIAL_COMMANDS.YOUTUBE_PICKER_OPEN]: 'openYoutubePicker',
[SPECIAL_COMMANDS.PLAYLIST_BROWSER_OPEN]: 'openPlaylistBrowser',
[SPECIAL_COMMANDS.ANIME_BROWSER_OPEN]: 'openAnimeBrowser',
[SPECIAL_COMMANDS.REPLAY_SUBTITLE]: 'replayCurrentSubtitle',
[SPECIAL_COMMANDS.PLAY_NEXT_SUBTITLE]: 'playNextSubtitle',
};
+7
View File
@@ -19,6 +19,7 @@ export function createKeyboardHandlers(
handleTsukihimeKeydown: (e: KeyboardEvent) => boolean;
handleYoutubePickerKeydown: (e: KeyboardEvent) => boolean;
handlePlaylistBrowserKeydown: (e: KeyboardEvent) => boolean;
handleAnimeBrowserKeydown?: (e: KeyboardEvent) => boolean;
handleControllerSelectKeydown: (e: KeyboardEvent) => boolean;
handleControllerDebugKeydown: (e: KeyboardEvent) => boolean;
handleSessionHelpKeydown: (e: KeyboardEvent) => boolean;
@@ -1096,6 +1097,12 @@ export function createKeyboardHandlers(
}
}
if (ctx.state.animeBrowserModalOpen) {
if (options.handleAnimeBrowserKeydown?.(e)) {
return;
}
}
// Ahead of the keyboard-driven lookup controls: the changelog modal binds
// arrows/H/L for folding, and those would otherwise move the subtitle word
// selection (and seek mpv) behind the open modal.
+19
View File
@@ -520,6 +520,25 @@
</div>
</div>
</div>
<div id="animeBrowserModal" class="modal hidden" aria-hidden="true">
<div class="modal-content anime-browser-modal-content">
<div class="modal-header anime-browser-modal-header">
<div>
<div class="modal-title">Anime Browser</div>
<div class="anime-browser-modal-kicker">Playing inside mpv</div>
</div>
<button id="animeBrowserClose" class="modal-close" type="button">Close</button>
</div>
<div class="modal-body anime-browser-modal-body">
<iframe
id="animeBrowserFrame"
class="anime-browser-frame"
title="Anime Browser"
data-src="../animeui/index.html?embedded=overlay-modal"
></iframe>
</div>
</div>
</div>
</div>
<script type="module" src="renderer.js"></script>
</body>
+92
View File
@@ -0,0 +1,92 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createAnimeBrowserModal } from './anime-browser';
function createClassList(initial: string[] = []) {
const classes = new Set(initial);
return {
add: (...names: string[]) => names.forEach((name) => classes.add(name)),
remove: (...names: string[]) => names.forEach((name) => classes.delete(name)),
contains: (name: string) => classes.has(name),
};
}
test('embedded Anime Browser closes only for its own close message', () => {
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, 'window');
const messages: Array<(event: MessageEvent) => void> = [];
const notifications: string[] = [];
const frameWindow = {};
const closeListeners: Array<() => void> = [];
const removedCloseListeners: Array<() => void> = [];
const overlay = { classList: createClassList() };
const modalElement = {
classList: createClassList(['hidden']),
attributes: new Map<string, string>(),
setAttribute(name: string, value: string) {
this.attributes.set(name, value);
},
};
const frame = {
src: '',
dataset: { src: '../animeui/index.html?embedded=overlay-modal' },
contentWindow: frameWindow,
};
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: {
electronAPI: {
notifyOverlayModalOpened: (name: string) => notifications.push(`open:${name}`),
notifyOverlayModalClosed: (name: string) => notifications.push(`close:${name}`),
},
addEventListener: (name: string, listener: (event: MessageEvent) => void) => {
if (name === 'message') messages.push(listener);
},
removeEventListener: () => undefined,
},
});
try {
const state = { animeBrowserModalOpen: false, isOverSubtitle: false };
const modal = createAnimeBrowserModal(
{
state,
dom: {
overlay,
animeBrowserModal: modalElement,
animeBrowserFrame: frame,
animeBrowserClose: {
addEventListener: (_name: string, listener: () => void) =>
closeListeners.push(listener),
removeEventListener: (_name: string, listener: () => void) =>
removedCloseListeners.push(listener),
},
},
} as never,
{
modalStateReader: { isAnyModalOpen: () => state.animeBrowserModalOpen },
syncSettingsModalSubtitleSuppression: () => undefined,
},
);
modal.wireDomEvents();
modal.openAnimeBrowserModal();
assert.equal(state.animeBrowserModalOpen, true);
assert.equal(frame.src, frame.dataset.src);
assert.deepEqual(notifications, ['open:anime-browser']);
messages[0]?.({ source: {}, data: 'subminer:anime-browser-close' } as MessageEvent);
messages[0]?.({ source: frameWindow, data: 'not-the-close-message' } as MessageEvent);
assert.equal(state.animeBrowserModalOpen, true);
messages[0]?.({ source: frameWindow, data: 'subminer:anime-browser-close' } as MessageEvent);
assert.equal(state.animeBrowserModalOpen, false);
assert.equal(modalElement.classList.contains('hidden'), true);
assert.deepEqual(notifications, ['open:anime-browser', 'close:anime-browser']);
modal.disposeDomEvents();
assert.deepEqual(removedCloseListeners, closeListeners);
} finally {
if (previousWindow) Object.defineProperty(globalThis, 'window', previousWindow);
else Reflect.deleteProperty(globalThis, 'window');
}
});
+73
View File
@@ -0,0 +1,73 @@
import type { ModalStateReader, RendererContext } from '../context';
const CLOSE_MESSAGE = 'subminer:anime-browser-close';
export function createAnimeBrowserModal(
ctx: RendererContext,
options: {
modalStateReader: Pick<ModalStateReader, 'isAnyModalOpen'>;
syncSettingsModalSubtitleSuppression: () => void;
},
) {
function ensureFrameLoaded(): void {
if (ctx.dom.animeBrowserFrame.src) return;
const source = ctx.dom.animeBrowserFrame.dataset.src;
if (!source) throw new Error('Anime Browser modal is missing its embedded source.');
ctx.dom.animeBrowserFrame.src = source;
}
function openAnimeBrowserModal(): void {
if (ctx.state.animeBrowserModalOpen || options.modalStateReader.isAnyModalOpen()) return;
ctx.state.animeBrowserModalOpen = true;
options.syncSettingsModalSubtitleSuppression();
ctx.dom.overlay.classList.add('interactive');
ctx.dom.animeBrowserModal.classList.remove('hidden');
ctx.dom.animeBrowserModal.setAttribute('aria-hidden', 'false');
ensureFrameLoaded();
window.electronAPI.notifyOverlayModalOpened('anime-browser');
}
function closeAnimeBrowserModal(): void {
if (!ctx.state.animeBrowserModalOpen) return;
ctx.state.animeBrowserModalOpen = false;
ctx.dom.animeBrowserModal.classList.add('hidden');
ctx.dom.animeBrowserModal.setAttribute('aria-hidden', 'true');
window.electronAPI.notifyOverlayModalClosed('anime-browser');
options.syncSettingsModalSubtitleSuppression();
if (!ctx.state.isOverSubtitle && !options.modalStateReader.isAnyModalOpen()) {
ctx.dom.overlay.classList.remove('interactive');
}
}
function handleAnimeBrowserKeydown(event: KeyboardEvent): boolean {
if (!ctx.state.animeBrowserModalOpen || event.key !== 'Escape') return false;
event.preventDefault();
closeAnimeBrowserModal();
return true;
}
function handleFrameMessage(event: MessageEvent): void {
if (event.source === ctx.dom.animeBrowserFrame.contentWindow && event.data === CLOSE_MESSAGE) {
closeAnimeBrowserModal();
}
}
function wireDomEvents(): void {
ctx.dom.animeBrowserClose.addEventListener('click', closeAnimeBrowserModal);
window.addEventListener('message', handleFrameMessage);
}
function disposeDomEvents(): void {
ctx.dom.animeBrowserClose.removeEventListener('click', closeAnimeBrowserModal);
window.removeEventListener('message', handleFrameMessage);
}
return {
openAnimeBrowserModal,
closeAnimeBrowserModal,
handleAnimeBrowserKeydown,
wireDomEvents,
disposeDomEvents,
};
}
@@ -106,6 +106,7 @@ function describeCommand(command: (string | number)[]): string {
if (first === SPECIAL_COMMANDS.JIMAKU_OPEN) return 'Open jimaku';
if (first === SPECIAL_COMMANDS.TSUKIHIME_OPEN) return 'Open TsukiHime';
if (first === SPECIAL_COMMANDS.PLAYLIST_BROWSER_OPEN) return 'Open playlist browser';
if (first === SPECIAL_COMMANDS.ANIME_BROWSER_OPEN) return 'Open Anime Browser';
if (first === SPECIAL_COMMANDS.REPLAY_SUBTITLE) return 'Replay current subtitle';
if (first === SPECIAL_COMMANDS.PLAY_NEXT_SUBTITLE) return 'Play next subtitle';
if (first.startsWith(SPECIAL_COMMANDS.RUNTIME_OPTION_CYCLE_PREFIX)) {
@@ -151,6 +152,7 @@ function sectionForCommand(command: (string | number)[]): string {
first === SPECIAL_COMMANDS.JIMAKU_OPEN ||
first === SPECIAL_COMMANDS.TSUKIHIME_OPEN ||
first === SPECIAL_COMMANDS.PLAYLIST_BROWSER_OPEN ||
first === SPECIAL_COMMANDS.ANIME_BROWSER_OPEN ||
first.startsWith(SPECIAL_COMMANDS.RUNTIME_OPTION_CYCLE_PREFIX)
) {
return 'Runtime settings';
@@ -229,6 +231,8 @@ function describeSessionAction(
return 'Open YouTube subtitle picker';
case 'openPlaylistBrowser':
return 'Open playlist browser';
case 'openAnimeBrowser':
return 'Open Anime Browser';
case 'replayCurrentSubtitle':
return 'Replay current subtitle';
case 'playNextSubtitle':
@@ -270,6 +274,7 @@ function sectionForSessionBinding(binding: CompiledSessionBinding): string {
case 'openControllerDebug':
case 'openYoutubePicker':
case 'openPlaylistBrowser':
case 'openAnimeBrowser':
case 'openSessionHelp':
return 'Modals and tools';
case 'replayCurrentSubtitle':
+19
View File
@@ -37,6 +37,7 @@ import { createTsukihimeModal } from './modals/tsukihime.js';
import { createKikuModal } from './modals/kiku.js';
import { prepareForKikuFieldGroupingOpen } from './kiku-open.js';
import { createPlaylistBrowserModal } from './modals/playlist-browser.js';
import { createAnimeBrowserModal } from './modals/anime-browser.js';
import { createSessionHelpModal } from './modals/session-help.js';
import { createChangelogModal } from './modals/changelog.js';
import { createSubtitleSidebarModal } from './modals/subtitle-sidebar.js';
@@ -120,6 +121,12 @@ const modalDescriptors = [
close: () => playlistBrowserModal.closePlaylistBrowserModal(),
suppressesSubtitles: true,
},
{
id: 'anime-browser',
isOpen: () => ctx.state.animeBrowserModalOpen,
close: () => animeBrowserModal.closeAnimeBrowserModal(),
suppressesSubtitles: true,
},
{
id: 'kiku',
isOpen: () => ctx.state.kikuModalOpen,
@@ -265,6 +272,10 @@ const playlistBrowserModal = createPlaylistBrowserModal(ctx, {
modalStateReader: { isAnyModalOpen },
syncSettingsModalSubtitleSuppression,
});
const animeBrowserModal = createAnimeBrowserModal(ctx, {
modalStateReader: { isAnyModalOpen },
syncSettingsModalSubtitleSuppression,
});
const keyboardHandlers = createKeyboardHandlers(ctx, {
handleRuntimeOptionsKeydown: runtimeOptionsModal.handleRuntimeOptionsKeydown,
handleCharacterDictionaryKeydown: characterDictionaryModal.handleCharacterDictionaryKeydown,
@@ -274,6 +285,7 @@ const keyboardHandlers = createKeyboardHandlers(ctx, {
handleTsukihimeKeydown: tsukihimeModal.handleTsukihimeKeydown,
handleYoutubePickerKeydown: youtubePickerModal.handleYoutubePickerKeydown,
handlePlaylistBrowserKeydown: playlistBrowserModal.handlePlaylistBrowserKeydown,
handleAnimeBrowserKeydown: animeBrowserModal.handleAnimeBrowserKeydown,
handleControllerSelectKeydown: controllerSelectModal.handleControllerSelectKeydown,
handleControllerDebugKeydown: controllerDebugModal.handleControllerDebugKeydown,
handleSessionHelpKeydown: sessionHelpModal.handleSessionHelpKeydown,
@@ -579,6 +591,11 @@ function registerModalOpenHandlers(): void {
await playlistBrowserModal.openPlaylistBrowserModal();
});
});
window.electronAPI.onOpenAnimeBrowser(() => {
runGuarded('anime-browser:open', () => {
animeBrowserModal.openAnimeBrowserModal();
});
});
window.electronAPI.onCancelYoutubeTrackPicker(() => {
runGuarded('youtube:picker-cancel', () => {
youtubePickerModal.closeYoutubePickerModal();
@@ -806,6 +823,7 @@ async function init(): Promise<void> {
tsukihimeModal.wireDomEvents();
youtubePickerModal.wireDomEvents();
playlistBrowserModal.wireDomEvents();
animeBrowserModal.wireDomEvents();
kikuModal.wireDomEvents();
runtimeOptionsModal.wireDomEvents();
subsyncModal.wireDomEvents();
@@ -817,6 +835,7 @@ async function init(): Promise<void> {
characterDictionaryModal.wireDomEvents();
window.addEventListener('beforeunload', () => {
subtitleSidebarModal.disposeDomEvents();
animeBrowserModal.disposeDomEvents();
});
window.electronAPI.onRuntimeOptionsChanged((options: RuntimeOptionState[]) => {
+2
View File
@@ -103,6 +103,7 @@ export type RendererState = {
changelogModalOpen: boolean;
changelogSelectedIndex: number;
playlistBrowserModalOpen: boolean;
animeBrowserModalOpen: boolean;
playlistBrowserSnapshot: PlaylistBrowserSnapshot | null;
playlistBrowserStatus: string;
playlistBrowserActivePane: 'directory' | 'playlist';
@@ -233,6 +234,7 @@ export function createRendererState(): RendererState {
changelogModalOpen: false,
changelogSelectedIndex: 0,
playlistBrowserModalOpen: false,
animeBrowserModalOpen: false,
playlistBrowserSnapshot: null,
playlistBrowserStatus: '',
playlistBrowserActivePane: 'playlist',
+55
View File
@@ -1116,6 +1116,54 @@ body:focus-visible,
backdrop-filter: blur(18px);
}
.anime-browser-modal-content {
width: min(1500px, calc(100vw - 32px));
max-width: none;
height: min(920px, calc(100vh - 32px));
max-height: none;
padding: 14px;
gap: 10px;
overflow: hidden;
background:
radial-gradient(circle at 12% 0%, rgba(138, 173, 244, 0.14), transparent 30%),
linear-gradient(180deg, rgba(36, 39, 58, 0.99), rgba(24, 25, 38, 0.99));
border-color: rgba(138, 173, 244, 0.3);
box-shadow:
0 28px 90px rgba(0, 0, 0, 0.5),
inset 0 1px 0 rgba(255, 255, 255, 0.04);
backdrop-filter: blur(18px);
}
.anime-browser-modal-header {
padding-inline: 3px;
}
.anime-browser-modal-kicker {
margin-top: 1px;
color: var(--ctp-overlay1);
font-size: 11px;
font-weight: 650;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.anime-browser-modal-body {
flex: 1;
min-height: 0;
overflow: hidden;
border: 1px solid rgba(110, 115, 141, 0.22);
border-radius: 10px;
background: var(--ctp-base);
}
.anime-browser-frame {
display: block;
width: 100%;
height: 100%;
border: 0;
background: var(--ctp-base);
}
.playlist-browser-body {
flex: 1;
min-height: 0;
@@ -1297,6 +1345,13 @@ body:focus-visible,
border-radius: 14px;
}
.anime-browser-modal-content {
width: calc(100vw - 16px);
height: calc(100vh - 16px);
padding: 10px;
border-radius: 12px;
}
.playlist-browser-grid {
grid-template-columns: 1fr;
}
+8
View File
@@ -144,6 +144,10 @@ export type RendererDom = {
playlistBrowserDirectoryList: HTMLUListElement;
playlistBrowserPlaylistList: HTMLUListElement;
playlistBrowserClose: HTMLButtonElement;
animeBrowserModal: HTMLDivElement;
animeBrowserClose: HTMLButtonElement;
animeBrowserFrame: HTMLIFrameElement;
};
function getRequiredElement<T extends HTMLElement>(id: string): T {
@@ -326,5 +330,9 @@ export function resolveRendererDom(): RendererDom {
'playlistBrowserPlaylistList',
),
playlistBrowserClose: getRequiredElement<HTMLButtonElement>('playlistBrowserClose'),
animeBrowserModal: getRequiredElement<HTMLDivElement>('animeBrowserModal'),
animeBrowserClose: getRequiredElement<HTMLButtonElement>('animeBrowserClose'),
animeBrowserFrame: getRequiredElement<HTMLIFrameElement>('animeBrowserFrame'),
};
}
+2
View File
@@ -15,6 +15,7 @@ export const OVERLAY_HOSTED_MODALS = [
'session-help',
'character-dictionary',
'changelog',
'anime-browser',
] as const;
export type OverlayHostedModal = (typeof OVERLAY_HOSTED_MODALS)[number];
@@ -172,6 +173,7 @@ export const IPC_CHANNELS = {
lookupWindowToggleRequested: 'lookup-window-toggle:requested',
sessionHelpOpen: 'session-help:open',
changelogOpen: 'changelog:open',
animeBrowserOpen: 'anime-browser:open',
characterDictionaryManagerOpen: 'character-dictionary:manager-open',
controllerSelectOpen: 'controller-select:open',
controllerDebugOpen: 'controller-debug:open',
+1
View File
@@ -46,6 +46,7 @@ const SESSION_ACTION_IDS: SessionActionId[] = [
'openTsukihime',
'openYoutubePicker',
'openPlaylistBrowser',
'openAnimeBrowser',
'replayCurrentSubtitle',
'playNextSubtitle',
'cycleRuntimeOption',
+4 -32
View File
@@ -5,6 +5,7 @@ import type {
KikuMergePreviewResponse,
} from './anki';
import type { ChangelogSnapshot } from './changelog';
import type { OverlayHostedModal } from '../shared/ipc/contracts';
import type { ResolvedConfig, ShortcutsConfig } from './config';
import type {
CompiledSessionBinding,
@@ -510,6 +511,7 @@ export interface ElectronAPI {
onOpenRuntimeOptions: (callback: () => void) => void;
onOpenSessionHelp: (callback: () => void) => void;
onOpenChangelog: (callback: () => void) => void;
onOpenAnimeBrowser: (callback: () => void) => void;
getChangelogSnapshot: (options?: { refresh?: boolean }) => Promise<ChangelogSnapshot>;
onOpenControllerSelect: (callback: () => void) => void;
onOpenControllerDebug: (callback: () => void) => void;
@@ -554,38 +556,8 @@ export interface ElectronAPI {
mediaId: number,
direction: 1 | -1,
) => Promise<CharacterDictionaryManagerMutationResult>;
notifyOverlayModalClosed: (
modal:
| 'runtime-options'
| 'subsync'
| 'jimaku'
| 'tsukihime'
| 'youtube-track-picker'
| 'playlist-browser'
| 'kiku'
| 'controller-select'
| 'controller-debug'
| 'subtitle-sidebar'
| 'session-help'
| 'character-dictionary'
| 'changelog',
) => void;
notifyOverlayModalOpened: (
modal:
| 'runtime-options'
| 'subsync'
| 'jimaku'
| 'tsukihime'
| 'youtube-track-picker'
| 'playlist-browser'
| 'kiku'
| 'controller-select'
| 'controller-debug'
| 'subtitle-sidebar'
| 'session-help'
| 'character-dictionary'
| 'changelog',
) => void;
notifyOverlayModalClosed: (modal: OverlayHostedModal) => void;
notifyOverlayModalOpened: (modal: OverlayHostedModal) => void;
reportOverlayContentBounds: (measurement: OverlayContentMeasurement) => void;
onConfigHotReload: (callback: (payload: ConfigHotReloadPayload) => void) => void;
}
+1
View File
@@ -25,6 +25,7 @@ export type SessionActionId =
| 'openTsukihime'
| 'openYoutubePicker'
| 'openPlaylistBrowser'
| 'openAnimeBrowser'
| 'replayCurrentSubtitle'
| 'playNextSubtitle'
| 'cycleRuntimeOption';