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
+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';