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
@@ -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;