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:
@@ -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) } : {}),
|
||||
|
||||
Reference in New Issue
Block a user