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
+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) } : {}),