fix(anime): keep browser state in sync and alias HLS segments

- Toggle the player Anime Browser without losing its state
- Share active playback state and support rotating fake segment extensions
This commit is contained in:
2026-08-15 23:35:37 -07:00
parent 7a1450ddb7
commit 877f350353
34 changed files with 612 additions and 43 deletions
+31
View File
@@ -330,6 +330,37 @@ for (const platform of ['darwin', 'win32'] as const) {
});
}
test('anime browser modal keeps its document warm across close on Linux', () => {
const modalWindow = createMockWindow();
const runtime = createOverlayModalRuntimeService(
{
getMainWindow: () => null,
getModalWindow: () => modalWindow as never,
createModalWindow: () => modalWindow as never,
getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }),
setModalWindowBounds: () => {},
},
{ platform: 'linux' },
);
runtime.sendToActiveOverlayWindow('anime-browser:open', undefined, {
restoreOnModalClose: 'anime-browser',
preferModalWindow: true,
});
runtime.notifyOverlayModalOpened('anime-browser');
runtime.handleOverlayModalClosed('anime-browser');
assert.equal(modalWindow.isDestroyed(), false);
assert.equal(modalWindow.isVisible(), false);
assert.equal(runtime.isModalOpen('anime-browser'), false);
runtime.sendToActiveOverlayWindow('anime-browser:open', undefined, {
restoreOnModalClose: 'anime-browser',
preferModalWindow: true,
});
assert.equal(modalWindow.isVisible(), true);
});
test('primeModalWindow leaves Linux modal creation lazy', () => {
let createCalls = 0;
const runtime = createOverlayModalRuntimeService(
+6 -1
View File
@@ -56,6 +56,7 @@ export interface OverlayModalRuntime {
openTsukihime: () => void;
handleOverlayModalClosed: (modal: OverlayHostedModal) => void;
notifyOverlayModalOpened: (modal: OverlayHostedModal) => void;
isModalOpen: (modal: OverlayHostedModal) => boolean;
waitForModalOpen: (modal: OverlayHostedModal, timeoutMs: number) => Promise<boolean>;
getRestoreVisibleOverlayOnModalClose: () => Set<OverlayHostedModal>;
}
@@ -84,10 +85,12 @@ export function createOverlayModalRuntimeService(
let modalWindowPrimedForImmediateShow = false;
let pendingModalWindowReveal: BrowserWindow | null = null;
let pendingModalWindowRevealTimeout: RevealFallbackHandle | null = null;
let retainModalWindowState = false;
const modalWindowBoundsReconcileGenerations = new WeakMap<BrowserWindow, number>();
const modalWindowPrimeListenersRegistered = new WeakSet<BrowserWindow>();
const platform = options.platform ?? process.platform;
const keepModalWindowWarm = platform === 'darwin' || platform === 'win32';
const shouldKeepModalWindowWarm = (): boolean => keepModalWindowWarm || retainModalWindowState;
const focusApplication = options.focusApplication ?? requestOverlayApplicationFocus;
const scheduleRevealFallback = (callback: () => void, delayMs: number): RevealFallbackHandle =>
(options.scheduleRevealFallback ?? globalThis.setTimeout)(callback, delayMs);
@@ -515,7 +518,7 @@ export function createOverlayModalRuntimeService(
if (restoreVisibleOverlayOnModalClose.size === 0) {
clearPendingModalWindowReveal();
if (modalWindow && !modalWindow.isDestroyed()) {
if (keepModalWindowWarm) {
if (shouldKeepModalWindowWarm()) {
modalWindow.setIgnoreMouseEvents(true, { forward: true });
modalWindow.hide();
markModalWindowPrimed(modalWindow);
@@ -538,6 +541,7 @@ export function createOverlayModalRuntimeService(
const notifyOverlayModalOpened = (modal: OverlayHostedModal): void => {
if (!restoreVisibleOverlayOnModalClose.has(modal)) return;
if (modal === 'anime-browser') retainModalWindowState = true;
openedModals.add(modal);
const waiters = modalOpenWaiters.get(modal) ?? [];
modalOpenWaiters.delete(modal);
@@ -595,6 +599,7 @@ export function createOverlayModalRuntimeService(
openTsukihime,
handleOverlayModalClosed,
notifyOverlayModalOpened,
isModalOpen: (modal) => openedModals.has(modal),
waitForModalOpen,
getRestoreVisibleOverlayOnModalClose: () => restoreVisibleOverlayOnModalClose,
};
@@ -12,6 +12,7 @@ export interface AnimeBrowserIpcDeps {
handle(channel: string, listener: (event: unknown, ...args: unknown[]) => unknown): unknown;
};
runtime: AnimeBrowserRuntime;
getPlaybackState?: () => unknown;
registerSession?: (sessionId: string, sender: AnimeBrowserIpcSender) => void;
}
@@ -97,6 +98,7 @@ export function registerAnimeBrowserIpcHandlers(deps: AnimeBrowserIpcDeps): void
);
handle(channels.animeBrowserClearQueue, () => runtime.clearQueue());
handle(channels.animeBrowserGetQueue, () => runtime.getQueue());
handle(channels.animeBrowserGetPlaybackState, () => deps.getPlaybackState?.() ?? null);
handle(channels.animeBrowserIsPlaying, () => runtime.isPlaying());
handle(channels.animeBrowserGetPreferences, (_event, sourceId) =>
runtime.getPreferences(String(sourceId)),
@@ -19,6 +19,7 @@ test('anime browser open uses a dedicated player-bounded modal window', async ()
return true;
},
waitForModalOpen: async () => true,
isModalOpen: () => false,
logWarn: () => {},
});
@@ -36,9 +37,35 @@ test('anime browser open retries on a fresh modal window after a missed acknowle
attempts += 1;
return attempts === 2;
},
isModalOpen: () => false,
logWarn: () => {},
});
assert.equal(opened, true);
assert.equal(attempts, 2);
});
test('anime browser shortcut closes the open modal without waiting for another open', async () => {
const calls: string[] = [];
const toggled = await openAnimeBrowserModal({
ensureOverlayStartupPrereqs: () => calls.push('prereqs'),
ensureOverlayWindowsReadyForVisibilityActions: () => calls.push('windows'),
sendToActiveOverlayWindow: (channel, payload, runtimeOptions) => {
calls.push(channel);
assert.equal(payload, undefined);
assert.deepEqual(runtimeOptions, {
restoreOnModalClose: 'anime-browser',
preferModalWindow: true,
});
return true;
},
waitForModalOpen: async () => {
assert.fail('closing must not wait for an open acknowledgement');
},
isModalOpen: (modal) => modal === 'anime-browser',
logWarn: () => {},
});
assert.equal(toggled, true);
assert.deepEqual(calls, [IPC_CHANNELS.event.animeBrowserClose]);
});
+8
View File
@@ -17,8 +17,16 @@ export async function openAnimeBrowserModal(deps: {
},
) => boolean;
waitForModalOpen: (modal: OverlayHostedModal, timeoutMs: number) => Promise<boolean>;
isModalOpen: (modal: OverlayHostedModal) => boolean;
logWarn: (message: string) => void;
}): Promise<boolean> {
if (deps.isModalOpen(ANIME_BROWSER_MODAL)) {
return deps.sendToActiveOverlayWindow(IPC_CHANNELS.event.animeBrowserClose, undefined, {
restoreOnModalClose: ANIME_BROWSER_MODAL,
preferModalWindow: true,
});
}
return await retryOverlayModalOpen(
{
waitForModalOpen: deps.waitForModalOpen,
@@ -25,6 +25,9 @@ function prepared(input: AnimeBrowserPlayRequest): PreparedAnimeBrowserPlayback
request: input,
stream: { url: mediaPath, quality: '1080p', headers: {}, audios: [], subtitles: [] },
metadata: {
sourceId: input.sourceId,
animeUrl: input.animeUrl,
episodeUrl: input.episodeUrl,
mediaPath,
statsPath: `animebrowser://${encodeURIComponent(input.episodeUrl)}`,
seriesTitle: input.animeTitle,
@@ -3,6 +3,7 @@ import assert from 'node:assert/strict';
import {
createStreamPlaybackMetadataStore,
matchRequestedStreamPlaybackMetadata,
toAnimeBrowserPlaybackState,
toAnilistMediaGuess,
toJimakuMediaInfo,
} from './stream-playback-metadata';
@@ -10,6 +11,9 @@ import type { AnimeStreamMetadata } from '../../anime-bridge/episode-metadata';
function metadata(overrides: Partial<AnimeStreamMetadata> = {}): AnimeStreamMetadata {
return {
sourceId: '9001',
animeUrl: '/anime/mushoku',
episodeUrl: '/watch/ep-4',
mediaPath: 'http://127.0.0.1:41234/video/abc.m3u8',
statsPath: 'animebrowser://9001/%2Fanime%2Fmushoku/%2Fwatch%2Fep-4',
seriesTitle: 'Mushoku Tensei: Jobless Reincarnation',
@@ -21,6 +25,15 @@ function metadata(overrides: Partial<AnimeStreamMetadata> = {}): AnimeStreamMeta
};
}
test('browser playback state keeps the source episode identity and drops other media', () => {
assert.deepEqual(toAnimeBrowserPlaybackState(metadata()), {
sourceId: '9001',
animeUrl: '/anime/mushoku',
episodeUrl: '/watch/ep-4',
});
assert.equal(toAnimeBrowserPlaybackState(null), null);
});
test('the store answers for the stream URL and for the stats path', () => {
const store = createStreamPlaybackMetadataStore();
const current = metadata();
@@ -1,6 +1,7 @@
import type { AnimeStreamMetadata } from '../../anime-bridge/episode-metadata';
import type { AnilistMediaGuess } from '../../core/services/anilist/anilist-updater';
import type { JimakuMediaInfo } from '../../types';
import type { AnimeBrowserPlaybackState } from '../../types/anime-browser';
/**
* Holds what the anime browser resolved for active and queued streams.
@@ -53,6 +54,17 @@ export function matchRequestedStreamPlaybackMetadata(
return store.match(requestedMediaPath ?? currentMediaPath);
}
export function toAnimeBrowserPlaybackState(
metadata: AnimeStreamMetadata | null,
): AnimeBrowserPlaybackState | null {
if (!metadata) return null;
return {
sourceId: metadata.sourceId,
animeUrl: metadata.animeUrl,
episodeUrl: metadata.episodeUrl,
};
}
/** AniList counts whole episodes, so a special numbered 6.5 cannot drive it. */
function wholeEpisode(episode: number | null): number | null {
return typeof episode === 'number' && Number.isInteger(episode) && episode > 0 ? episode : null;