mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-08-16 13:55:51 -07:00
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:
+24
-4
@@ -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',
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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'] },
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
|
||||
@@ -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
@@ -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' };
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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) } : {}),
|
||||
|
||||
@@ -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],
|
||||
},
|
||||
]);
|
||||
});
|
||||
@@ -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
@@ -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));
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
});
|
||||
@@ -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':
|
||||
|
||||
@@ -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[]) => {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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'),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -46,6 +46,7 @@ const SESSION_ACTION_IDS: SessionActionId[] = [
|
||||
'openTsukihime',
|
||||
'openYoutubePicker',
|
||||
'openPlaylistBrowser',
|
||||
'openAnimeBrowser',
|
||||
'replayCurrentSubtitle',
|
||||
'playNextSubtitle',
|
||||
'cycleRuntimeOption',
|
||||
|
||||
+4
-32
@@ -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;
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ export type SessionActionId =
|
||||
| 'openTsukihime'
|
||||
| 'openYoutubePicker'
|
||||
| 'openPlaylistBrowser'
|
||||
| 'openAnimeBrowser'
|
||||
| 'replayCurrentSubtitle'
|
||||
| 'playNextSubtitle'
|
||||
| 'cycleRuntimeOption';
|
||||
|
||||
Reference in New Issue
Block a user