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
+6
View File
@@ -10,6 +10,9 @@
/** Where a stream came from, resolved into the fields consumers actually want. */
export interface AnimeStreamMetadata {
sourceId: string;
animeUrl: string;
episodeUrl: string;
/** The URL handed to mpv. Matches what mpv reports as `path`. */
mediaPath: string;
/**
@@ -192,6 +195,9 @@ export function buildAnimeStreamMetadata(input: AnimeStreamMetadataInput): Anime
const displayTitle = buildStreamDisplayTitle(seriesTitle, season, episodeNumber, label.title);
return {
sourceId: input.sourceId,
animeUrl: input.animeUrl,
episodeUrl: input.episodeUrl,
mediaPath: input.mediaPath,
statsPath: buildAnimeStreamStatsPath(input.sourceId, input.animeUrl, input.episodeUrl),
seriesTitle,
+28 -1
View File
@@ -61,7 +61,7 @@ test('rewritePlaylistOrigins swaps absolute upstream URLs and keeps relative lin
assert.ok(!rewritten.includes('41569'));
});
test('rewritePlaylistOrigins gives proxied image segments an ffmpeg-safe TS suffix', () => {
test('rewritePlaylistOrigins gives proxied disguised segments an ffmpeg-safe TS suffix', () => {
const body = [
'#EXTM3U',
'#EXTINF:6.006,',
@@ -70,6 +70,14 @@ test('rewritePlaylistOrigins gives proxied image segments an ffmpeg-safe TS suff
'http://127.0.0.1:41569/video/absolute.image',
'#EXTINF:3,',
'https://cdn.example/video/external.image',
'#EXTINF:2,',
'/video/rotating.jpg',
'#EXTINF:2,',
'/video/rotating.css',
'#EXTINF:2,',
'/video/rotating.html',
'#EXTINF:2,',
'/video/extensionless',
].join('\n');
const rewritten = rewritePlaylistOrigins(body, 'http://127.0.0.1:41569', 'http://127.0.0.1:9999');
@@ -78,6 +86,25 @@ test('rewritePlaylistOrigins gives proxied image segments an ffmpeg-safe TS suff
rewritten.includes(`http://127.0.0.1:9999/video/absolute.image${TS_SEGMENT_ALIAS_SUFFIX}`),
);
assert.ok(rewritten.includes('https://cdn.example/video/external.image'));
assert.ok(rewritten.includes(`/video/rotating.jpg${TS_SEGMENT_ALIAS_SUFFIX}`));
assert.ok(rewritten.includes(`/video/rotating.css${TS_SEGMENT_ALIAS_SUFFIX}`));
assert.ok(rewritten.includes(`/video/rotating.html${TS_SEGMENT_ALIAS_SUFFIX}`));
assert.ok(rewritten.includes(`/video/extensionless${TS_SEGMENT_ALIAS_SUFFIX}`));
});
test('rewritePlaylistOrigins leaves recognized media extensions unaliased', () => {
const body = [
'#EXTM3U',
'#EXTINF:6,',
'/video/plain.ts',
'#EXTINF:6,',
'/video/fragmented.m4s',
'#EXTINF:6,',
'/subs/line.vtt',
'/video/nested.m3u8',
].join('\n');
const rewritten = rewritePlaylistOrigins(body, 'http://127.0.0.1:41569', 'http://127.0.0.1:9999');
assert.ok(!rewritten.includes(TS_SEGMENT_ALIAS_SUFFIX));
});
/* ---------- proxy end-to-end ---------- */
+47 -3
View File
@@ -25,10 +25,54 @@ const TS_SYNC_BYTE = 0x47;
const SYNC_RUN = 5;
/**
* FFmpeg 8.1 rejects HLS media whose URL suffix is not in its segment allowlist.
* The disguised MPEG-TS segments seen in the wild use `.image`, so the local
* playlist gives them this safe alias and removes it again before forwarding.
* Hosts disguise MPEG-TS segments behind rotating fake extensions (`.image`,
* `.jpg`, `.css`, ...), so the local playlist gives every proxied segment
* without a recognized media extension this safe alias and removes it again
* before forwarding.
*/
export const TS_SEGMENT_ALIAS_SUFFIX = '.subminer.ts';
/** ffmpeg 8.1 hls demuxer `allowed_segment_extensions` defaults (minus `html`,
* which only newer builds accept and is a disguise whenever it shows up here). */
const FFMPEG_SAFE_SEGMENT_EXTENSIONS = new Set([
'3gp',
'aac',
'avi',
'ac3',
'eac3',
'flac',
'mkv',
'm3u8',
'm4a',
'm4s',
'm4v',
'mpg',
'mov',
'mp2',
'mp3',
'mp4',
'mpeg',
'mpegts',
'ogg',
'ogv',
'oga',
'ts',
'vob',
'vtt',
'wav',
'webvtt',
'cmfv',
'cmfa',
'ec3',
'fmp4',
]);
/** True when ffmpeg's picky segment-extension check would reject this path. */
function needsTsSegmentAlias(pathname: string): boolean {
const name = pathname.slice(pathname.lastIndexOf('/') + 1).toLowerCase();
const dot = name.lastIndexOf('.');
if (dot === -1) return true;
return !FFMPEG_SAFE_SEGMENT_EXTENSIONS.has(name.slice(dot + 1));
}
/** A disguise prefix is small; give up scanning after this much. */
export const DEFAULT_SCAN_LIMIT_BYTES = 1024 * 1024;
/** Bytes needed to either find a run within the limit or rule one out. */
@@ -80,7 +124,7 @@ export function rewritePlaylistOrigins(
} catch {
return line;
}
if (resolved.origin !== proxyOrigin || !resolved.pathname.toLowerCase().endsWith('.image')) {
if (resolved.origin !== proxyOrigin || !needsTsSegmentAlias(resolved.pathname)) {
return line;
}
+29 -1
View File
@@ -21,6 +21,10 @@ import type {
AnimeBrowserEntry,
AnimeBrowserSource,
} from '../types/anime-browser';
import {
ANIME_BROWSER_CLOSE_MESSAGE,
createAnimeBrowserKeydownMessage,
} from '../shared/anime-browser-embed';
const embeddedInOverlay =
new URLSearchParams(window.location.search).get('embedded') === 'overlay-modal';
@@ -95,10 +99,21 @@ function setStatus(message: string, tone: 'info' | 'ok' | 'error' = 'info'): voi
const detailPanel = createDetailPanel({ api, setStatus });
if (embeddedInOverlay) {
const overlayOrigin = window.location.origin;
// Keyboard events do not bubble out of an iframe. Forward the physical key
// chord so the overlay can apply the user's configured Anime Browser binding.
document.addEventListener(
'keydown',
(event) => {
if (event.key === 'Escape' && detailPanel.isOpen()) return;
window.parent.postMessage(createAnimeBrowserKeydownMessage(event), overlayOrigin);
},
true,
);
document.addEventListener('keydown', (event) => {
if (event.key !== 'Escape' || event.defaultPrevented || detailPanel.isOpen()) return;
event.preventDefault();
window.parent.postMessage('subminer:anime-browser-close', '*');
window.parent.postMessage(ANIME_BROWSER_CLOSE_MESSAGE, overlayOrigin);
});
}
@@ -428,12 +443,25 @@ api.onBridgeState(renderBridgeState);
// The queue changes without this window asking: it advances by itself when an
// episode ends, whether or not anyone is looking at the browser.
api.onQueueState((state) => detailPanel.setQueue(state));
let receivedPlaybackStateEvent = false;
api.onPlaybackState((state) => {
receivedPlaybackStateEvent = true;
detailPanel.setPlaybackState(state);
});
void (async () => {
renderBridgeState({ stage: 'idle', progress: null, message: null });
// A queue survives the window being closed and reopened, so start from what
// the main process already holds rather than from empty.
void api.getQueue().then((state) => detailPanel.setQueue(state));
void api.getPlaybackState().then(
(state) => {
if (!receivedPlaybackStateEvent) detailPanel.setPlaybackState(state);
},
() => {
// A live playback event can still populate the cue after a failed snapshot request.
},
);
try {
const state = await api.ensureBridge();
renderBridgeState(state);
+7 -1
View File
@@ -1,7 +1,11 @@
import { LatestRequest } from './browse-state';
import { describe, el } from './dom';
import { createEpisodeList, type SelectedAnime } from './episode-list';
import type { AnimeBrowserAPI, AnimeBrowserEntry } from '../types/anime-browser';
import type {
AnimeBrowserAPI,
AnimeBrowserEntry,
AnimeBrowserPlaybackState,
} from '../types/anime-browser';
interface DetailPanelOptions {
api: AnimeBrowserAPI;
@@ -102,5 +106,7 @@ export function createDetailPanel({ api, setStatus }: DetailPanelOptions) {
isOpen: (): boolean => !detail.classList.contains('hidden'),
/** The queue outlives the open anime, so it is pushed in from outside. */
setQueue: episodeList.setQueue,
setPlaybackState: (state: AnimeBrowserPlaybackState | null) =>
episodeList.setPlaybackState(state),
};
}
+51
View File
@@ -0,0 +1,51 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { nextPlaybackCue, playingEpisodeForAnime } from './episode-playback';
const selected = { sourceId: 'source.one', url: '/anime/one', title: 'One' };
test('playing episode sync applies only to the matching source and anime', () => {
assert.equal(
playingEpisodeForAnime(
{ sourceId: 'source.one', animeUrl: '/anime/one', episodeUrl: '/episode/3' },
selected,
),
'/episode/3',
);
assert.equal(
playingEpisodeForAnime(
{ sourceId: 'source.two', animeUrl: '/anime/one', episodeUrl: '/episode/3' },
selected,
),
null,
);
assert.equal(
playingEpisodeForAnime(
{ sourceId: 'source.one', animeUrl: '/anime/two', episodeUrl: '/episode/3' },
selected,
),
null,
);
assert.equal(playingEpisodeForAnime(null, selected), null);
});
test('live playback sync preserves a pending episode cue until resolution finishes', () => {
const loading = { url: '/episode/4', state: 'loading' as const };
assert.equal(
nextPlaybackCue(
{ sourceId: 'source.one', animeUrl: '/anime/one', episodeUrl: '/episode/3' },
selected,
loading,
),
loading,
);
assert.deepEqual(
nextPlaybackCue(
{ sourceId: 'source.one', animeUrl: '/anime/one', episodeUrl: '/episode/3' },
selected,
{ url: '/episode/2', state: 'playing' },
),
{ url: '/episode/3', state: 'playing' },
);
});
+33 -8
View File
@@ -5,7 +5,12 @@ import { filterEpisodes } from './episode-filter';
import { describeMarkCount, episodesInScope } from './episode-marks';
import { describeQueuePosition } from './episode-queue';
import { createEpisodeQueueControls } from './episode-queue-controls';
import type { AnimeBrowserAPI, AnimeBrowserEpisode } from '../types/anime-browser';
import { nextPlaybackCue, type EpisodePlaybackCue } from './episode-playback';
import type {
AnimeBrowserAPI,
AnimeBrowserEpisode,
AnimeBrowserPlaybackState,
} from '../types/anime-browser';
export interface SelectedAnime {
url: string;
@@ -47,7 +52,8 @@ export function createEpisodeList({ api, setStatus, selectedAnime }: EpisodeList
* Which episode is resolving or playing. Kept here rather than only on the
* button, so filtering mid-playback repaints the cue instead of dropping it.
*/
let cueState: { url: string; state: 'loading' | 'playing' } | null = null;
let cueState: EpisodePlaybackCue | null = null;
let activePlayback: AnimeBrowserPlaybackState | null = null;
const watchStateRequests = new LatestRequest();
/**
* Mark writes carry their own token: a background refresh starting mid-write
@@ -69,6 +75,11 @@ export function createEpisodeList({ api, setStatus, selectedAnime }: EpisodeList
}
}
function syncPlaybackCue(): void {
cueState = nextPlaybackCue(activePlayback, selectedAnime(), cueState);
applyCueState();
}
const queue = createEpisodeQueueControls({
api,
setStatus,
@@ -79,11 +90,12 @@ export function createEpisodeList({ api, setStatus, selectedAnime }: EpisodeList
// The queue started this one, so the cue this window was holding belongs
// to an episode that has finished. The new one only earns the cue when it
// is an episode of the anime on screen.
const anime = selectedAnime();
const mine =
anime !== null && anime.sourceId === entry.sourceId && anime.url === entry.animeUrl;
cueState = mine ? { url: entry.episodeUrl, state: 'playing' } : null;
applyCueState();
activePlayback = {
sourceId: entry.sourceId,
animeUrl: entry.animeUrl,
episodeUrl: entry.episodeUrl,
};
syncPlaybackCue();
setStatus(`Queue started ${entry.episodeName}.`, 'ok');
},
});
@@ -385,6 +397,7 @@ export function createEpisodeList({ api, setStatus, selectedAnime }: EpisodeList
displayIndex: list.length - index,
name: episode.name,
}));
syncPlaybackCue();
paint();
void refreshWatchState();
}
@@ -412,5 +425,17 @@ export function createEpisodeList({ api, setStatus, selectedAnime }: EpisodeList
});
window.addEventListener('focus', () => void refreshWatchState());
return { render, clear, refreshWatchState, setQueue: queue.setState };
function setPlaybackState(state: AnimeBrowserPlaybackState | null): void {
activePlayback = state;
syncPlaybackCue();
void refreshWatchState();
}
return {
render,
clear,
refreshWatchState,
setQueue: queue.setState,
setPlaybackState,
};
}
+36
View File
@@ -0,0 +1,36 @@
import type { AnimeBrowserPlaybackState } from '../types/anime-browser';
interface AnimeIdentity {
sourceId: string;
url: string;
}
export interface EpisodePlaybackCue {
url: string;
state: 'loading' | 'playing';
}
export function playingEpisodeForAnime(
playback: AnimeBrowserPlaybackState | null,
anime: AnimeIdentity | null,
): string | null {
if (
!playback ||
!anime ||
anime.sourceId !== playback.sourceId ||
anime.url !== playback.animeUrl
) {
return null;
}
return playback.episodeUrl;
}
export function nextPlaybackCue(
playback: AnimeBrowserPlaybackState | null,
anime: AnimeIdentity | null,
currentCue: EpisodePlaybackCue | null,
): EpisodePlaybackCue | null {
if (currentCue?.state === 'loading') return currentCue;
const episodeUrl = playingEpisodeForAnime(playback, anime);
return episodeUrl ? { url: episodeUrl, state: 'playing' } : null;
}
+15
View File
@@ -491,6 +491,7 @@ import { createMediaRuntimeService } from './main/media-runtime';
import {
createStreamPlaybackMetadataStore,
matchRequestedStreamPlaybackMetadata,
toAnimeBrowserPlaybackState,
toAnilistMediaGuess,
toJimakuMediaInfo,
} from './main/runtime/stream-playback-metadata';
@@ -2468,6 +2469,7 @@ const mediaRuntime = createMediaRuntimeService(
getSubtitlePositionsDir: () => SUBTITLE_POSITIONS_DIR,
setCurrentMediaPath: (nextPath: string | null) => {
appState.currentMediaPath = nextPath;
publishAnimeBrowserPlaybackState(nextPath);
},
clearPendingSubtitlePosition: () => {
appState.pendingSubtitlePosition = null;
@@ -2842,6 +2844,7 @@ function createOverlayHostedModalOpenDeps(): {
},
) => boolean;
waitForModalOpen: (modal: OverlayHostedModal, timeoutMs: number) => Promise<boolean>;
isModalOpen: (modal: OverlayHostedModal) => boolean;
logWarn: (message: string) => void;
} {
return {
@@ -2851,6 +2854,7 @@ function createOverlayHostedModalOpenDeps(): {
sendToActiveOverlayWindow: (channel, payload, runtimeOptions) =>
overlayVisibilityComposer.sendToActiveOverlayWindow(channel, payload, runtimeOptions),
waitForModalOpen: (modal, timeoutMs) => overlayModalRuntime.waitForModalOpen(modal, timeoutMs),
isModalOpen: (modal) => overlayModalRuntime.isModalOpen(modal),
logWarn: (message) => logger.warn(message),
};
}
@@ -3326,6 +3330,7 @@ const {
const DEFAULT_ANIME_EXTENSIONS_DIR = path.join(USER_DATA_PATH, 'anime-extensions');
const animeBrowserSessions = new Map<string, AnimeBrowserIpcSender>();
let animeBrowserPlaybackState = toAnimeBrowserPlaybackState(getActiveStreamMetadata());
function broadcastAnimeBrowserEvent(channel: string, payload: unknown): void {
const targets = new Set<AnimeBrowserIpcSender>();
@@ -3337,6 +3342,14 @@ function broadcastAnimeBrowserEvent(channel: string, payload: unknown): void {
for (const sender of targets) sender.send(channel, payload);
}
function publishAnimeBrowserPlaybackState(mediaPath: string | null): void {
animeBrowserPlaybackState = toAnimeBrowserPlaybackState(streamPlaybackMetadata.match(mediaPath));
broadcastAnimeBrowserEvent(
IPC_CHANNELS.event.animeBrowserPlaybackState,
animeBrowserPlaybackState,
);
}
function resolveAnimeExtensionsDir(): string {
const configured = configService.getConfig().anime?.extensionsDir?.trim();
return configured && configured.length > 0 ? configured : DEFAULT_ANIME_EXTENSIONS_DIR;
@@ -3396,6 +3409,7 @@ const animeBrowserRuntime = createAnimeBrowserRuntime({
},
onPlaybackMetadata: (metadata) => {
streamPlaybackMetadata.set(metadata);
publishAnimeBrowserPlaybackState(metadata.mediaPath);
// Set before mpv reports the path change, so the session that change starts
// is titled and grouped from the source's own listing rather than from the
// proxy URL, whose only readable part is the `.m3u8` extension.
@@ -3442,6 +3456,7 @@ const animeBrowserRuntime = createAnimeBrowserRuntime({
registerAnimeBrowserIpcHandlers({
ipcMain,
runtime: animeBrowserRuntime,
getPlaybackState: () => animeBrowserPlaybackState,
registerSession: (sessionId, sender) => {
if (animeBrowserSessions.get(sessionId) === sender) return;
animeBrowserSessions.set(sessionId, sender);
+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;
+5
View File
@@ -29,6 +29,7 @@ test('anime browser API keeps one opaque session per renderer bridge', async ()
await firstApi.search('frieren', 2);
await firstApi.getDetails('/frieren');
await firstApi.getEpisodes('/frieren', 'source.one');
await firstApi.getPlaybackState();
await secondApi.getPopular(3);
const firstSessionId = first.calls[0]?.args[0];
@@ -56,6 +57,10 @@ test('anime browser API keeps one opaque session per renderer bridge', async ()
channel: IPC_CHANNELS.request.animeBrowserGetEpisodes,
args: [firstSessionId, '/frieren', 'source.one'],
},
{
channel: IPC_CHANNELS.request.animeBrowserGetPlaybackState,
args: [],
},
]);
assert.deepEqual(second.calls, [
{
+12
View File
@@ -8,6 +8,7 @@ import type {
AnimeBrowserEpisodeWatchState,
AnimeBrowserPlayRequest,
AnimeBrowserPlayResult,
AnimeBrowserPlaybackState,
AnimeBrowserQueueState,
AnimeBrowserSearchResult,
AnimeBrowserSearchUpdate,
@@ -67,6 +68,8 @@ export function createAnimeBrowserAPI(ipcRenderer: AnimeBrowserIpcRenderer): Ani
ipcRenderer.invoke(request.animeBrowserClearQueue),
getQueue: (): Promise<AnimeBrowserQueueState> =>
ipcRenderer.invoke(request.animeBrowserGetQueue),
getPlaybackState: (): Promise<AnimeBrowserPlaybackState | null> =>
ipcRenderer.invoke(request.animeBrowserGetPlaybackState),
isPlaying: (): Promise<boolean> => ipcRenderer.invoke(request.animeBrowserIsPlaying),
getPreferences: (sourceId: string): Promise<SourcePreferenceView[]> =>
ipcRenderer.invoke(request.animeBrowserGetPreferences, sourceId),
@@ -101,5 +104,14 @@ export function createAnimeBrowserAPI(ipcRenderer: AnimeBrowserIpcRenderer): Ani
ipcRenderer.on(IPC_CHANNELS.event.animeBrowserQueueState, handler);
return () => ipcRenderer.removeListener(IPC_CHANNELS.event.animeBrowserQueueState, handler);
},
onPlaybackState: (
listener: (state: AnimeBrowserPlaybackState | null) => void,
): (() => void) => {
const handler = (_event: unknown, state: AnimeBrowserPlaybackState | null): void =>
listener(state);
ipcRenderer.on(IPC_CHANNELS.event.animeBrowserPlaybackState, handler);
return () =>
ipcRenderer.removeListener(IPC_CHANNELS.event.animeBrowserPlaybackState, handler);
},
};
}
+2
View File
@@ -170,6 +170,7 @@ const onOpenRuntimeOptionsEvent = createQueuedIpcListener(IPC_CHANNELS.event.run
const onOpenSessionHelpEvent = createQueuedIpcListener(IPC_CHANNELS.event.sessionHelpOpen);
const onOpenChangelogEvent = createQueuedIpcListener(IPC_CHANNELS.event.changelogOpen);
const onOpenAnimeBrowserEvent = createQueuedIpcListener(IPC_CHANNELS.event.animeBrowserOpen);
const onCloseAnimeBrowserEvent = createQueuedIpcListener(IPC_CHANNELS.event.animeBrowserClose);
const onOpenCharacterDictionaryManagerEvent = createQueuedIpcListener(
IPC_CHANNELS.event.characterDictionaryManagerOpen,
);
@@ -454,6 +455,7 @@ const electronAPI: ElectronAPI = {
onOpenSessionHelp: onOpenSessionHelpEvent,
onOpenChangelog: onOpenChangelogEvent,
onOpenAnimeBrowser: onOpenAnimeBrowserEvent,
onCloseAnimeBrowser: onCloseAnimeBrowserEvent,
getChangelogSnapshot: (options?: { refresh?: boolean }): Promise<ChangelogSnapshot> =>
ipcRenderer.invoke(IPC_CHANNELS.request.getChangelogSnapshot, options),
onOpenControllerSelect: onOpenControllerSelectEvent,
+22
View File
@@ -53,3 +53,25 @@ test('modal registry dismisses every open descriptor and skips closed descriptor
assert.deepEqual(closed, ['first-open', 'second-open']);
});
test('modal registry can dismiss stale modals while preserving the requested one', () => {
const openIds = new Set(['anime-browser', 'subtitle-sidebar']);
const closed: string[] = [];
const descriptors: ModalDescriptor<string>[] = ['anime-browser', 'subtitle-sidebar'].map(
(id) => ({
id,
isOpen: () => openIds.has(id),
close: () => {
openIds.delete(id);
closed.push(id);
},
suppressesSubtitles: true,
}),
);
const registry = createModalRegistry(descriptors);
registry.dismissOpenExcept('anime-browser');
assert.deepEqual(closed, ['subtitle-sidebar']);
assert.equal(openIds.has('anime-browser'), true);
});
+7
View File
@@ -20,5 +20,12 @@ export function createModalRegistry<TId extends string>(
}
}
},
dismissOpenExcept: (id: TId): void => {
for (const descriptor of descriptors) {
if (descriptor.id !== id && descriptor.isOpen()) {
descriptor.close();
}
}
},
};
}
+107 -6
View File
@@ -1,5 +1,6 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createAnimeBrowserKeydownMessage } from '../../shared/anime-browser-embed';
import { createAnimeBrowserModal } from './anime-browser';
function createClassList(initial: string[] = []) {
@@ -15,6 +16,8 @@ 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[] = [];
let staleModalOpen = true;
let dismissOtherModalCalls = 0;
const frameWindow = {};
const closeListeners: Array<() => void> = [];
const removedCloseListeners: Array<() => void> = [];
@@ -39,6 +42,7 @@ test('embedded Anime Browser closes only for its own close message', () => {
notifyOverlayModalOpened: (name: string) => notifications.push(`open:${name}`),
notifyOverlayModalClosed: (name: string) => notifications.push(`close:${name}`),
},
location: { origin: 'file://', protocol: 'file:' },
addEventListener: (name: string, listener: (event: MessageEvent) => void) => {
if (name === 'message') messages.push(listener);
},
@@ -47,7 +51,22 @@ test('embedded Anime Browser closes only for its own close message', () => {
});
try {
const state = { animeBrowserModalOpen: false, isOverSubtitle: false };
const state = {
animeBrowserModalOpen: false,
isOverSubtitle: false,
sessionBindingMap: new Map([
[
'Ctrl+Alt+KeyA',
{
actionType: 'session-action',
actionId: 'openAnimeBrowser',
sourcePath: 'animeBrowser.toggleKey',
originalKey: 'Ctrl+Alt+A',
key: { code: 'KeyA', modifiers: ['ctrl', 'alt'] },
},
],
]),
};
const modal = createAnimeBrowserModal(
{
state,
@@ -64,7 +83,13 @@ test('embedded Anime Browser closes only for its own close message', () => {
},
} as never,
{
modalStateReader: { isAnyModalOpen: () => state.animeBrowserModalOpen },
modalStateReader: {
isAnyModalOpen: () => state.animeBrowserModalOpen || staleModalOpen,
},
dismissOtherModals: () => {
dismissOtherModalCalls += 1;
staleModalOpen = false;
},
syncSettingsModalSubtitleSuppression: () => undefined,
},
);
@@ -72,17 +97,93 @@ test('embedded Anime Browser closes only for its own close message', () => {
modal.wireDomEvents();
modal.openAnimeBrowserModal();
assert.equal(state.animeBrowserModalOpen, true);
assert.equal(dismissOtherModalCalls, 1, 'open clears stale retained-window modal state');
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);
messages[0]?.({
origin: 'null',
source: {},
data: 'subminer:anime-browser-close',
} as MessageEvent);
messages[0]?.({
origin: 'https://example.com',
source: frameWindow,
data: 'subminer:anime-browser-close',
} as MessageEvent);
messages[0]?.({
origin: 'null',
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);
messages[0]?.({
origin: 'null',
source: frameWindow,
data: createAnimeBrowserKeydownMessage({
code: 'KeyB',
ctrlKey: true,
altKey: true,
shiftKey: false,
metaKey: false,
repeat: false,
}),
} as MessageEvent);
assert.equal(state.animeBrowserModalOpen, true, 'unrelated binding leaves modal open');
messages[0]?.({
origin: 'null',
source: frameWindow,
data: createAnimeBrowserKeydownMessage({
code: 'KeyA',
ctrlKey: true,
altKey: true,
shiftKey: false,
metaKey: false,
repeat: false,
}),
} as MessageEvent);
assert.equal(state.animeBrowserModalOpen, false, 'configured binding toggles modal closed');
assert.deepEqual(notifications, ['open:anime-browser', 'close:anime-browser']);
modal.openAnimeBrowserModal();
messages[0]?.({
origin: 'null',
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']);
assert.deepEqual(notifications, [
'open:anime-browser',
'close:anime-browser',
'open:anime-browser',
'close:anime-browser',
]);
modal.openAnimeBrowserModal();
assert.equal(state.animeBrowserModalOpen, true);
assert.equal(modalElement.classList.contains('hidden'), false);
assert.equal(frame.src, frame.dataset.src, 'reopen keeps the existing iframe document');
assert.deepEqual(notifications, [
'open:anime-browser',
'close:anime-browser',
'open:anime-browser',
'close:anime-browser',
'open:anime-browser',
]);
const dismissCallsBeforeRepair = dismissOtherModalCalls;
modalElement.classList.add('hidden');
modal.openAnimeBrowserModal();
assert.equal(
modalElement.classList.contains('hidden'),
false,
'repeated open repairs stale retained-window DOM state',
);
assert.equal(dismissOtherModalCalls, dismissCallsBeforeRepair);
assert.equal(notifications.at(-1), 'open:anime-browser');
modal.disposeDomEvents();
assert.deepEqual(removedCloseListeners, closeListeners);
} finally {
+27 -5
View File
@@ -1,11 +1,14 @@
import type { ModalStateReader, RendererContext } from '../context';
const CLOSE_MESSAGE = 'subminer:anime-browser-close';
import {
ANIME_BROWSER_CLOSE_MESSAGE,
isAnimeBrowserKeydownMessage,
} from '../../shared/anime-browser-embed';
export function createAnimeBrowserModal(
ctx: RendererContext,
options: {
modalStateReader: Pick<ModalStateReader, 'isAnyModalOpen'>;
dismissOtherModals: () => void;
syncSettingsModalSubtitleSuppression: () => void;
},
) {
@@ -16,8 +19,11 @@ export function createAnimeBrowserModal(
ctx.dom.animeBrowserFrame.src = source;
}
function openAnimeBrowserModal(): void {
if (ctx.state.animeBrowserModalOpen || options.modalStateReader.isAnyModalOpen()) return;
function openAnimeBrowserModal(): boolean {
if (!ctx.state.animeBrowserModalOpen) {
options.dismissOtherModals();
if (options.modalStateReader.isAnyModalOpen()) return false;
}
ctx.state.animeBrowserModalOpen = true;
options.syncSettingsModalSubtitleSuppression();
@@ -26,6 +32,7 @@ export function createAnimeBrowserModal(
ctx.dom.animeBrowserModal.setAttribute('aria-hidden', 'false');
ensureFrameLoaded();
window.electronAPI.notifyOverlayModalOpened('anime-browser');
return true;
}
function closeAnimeBrowserModal(): void {
@@ -48,7 +55,22 @@ export function createAnimeBrowserModal(
}
function handleFrameMessage(event: MessageEvent): void {
if (event.source === ctx.dom.animeBrowserFrame.contentWindow && event.data === CLOSE_MESSAGE) {
// Chromium serializes file URL message origins as "null" even though
// Location.origin reports "file://" for these bundled pages.
const expectedOrigin = window.location.protocol === 'file:' ? 'null' : window.location.origin;
if (
event.origin !== expectedOrigin ||
event.source !== ctx.dom.animeBrowserFrame.contentWindow
) {
return;
}
if (event.data === ANIME_BROWSER_CLOSE_MESSAGE) {
closeAnimeBrowserModal();
return;
}
if (!isAnimeBrowserKeydownMessage(event.data) || event.data.repeat) return;
const binding = ctx.state.sessionBindingMap.get(event.data.bindingKey);
if (binding?.actionType === 'session-action' && binding.actionId === 'openAnimeBrowser') {
closeAnimeBrowserModal();
}
}
+2 -2
View File
@@ -106,7 +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.ANIME_BROWSER_OPEN) return 'Toggle 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)) {
@@ -232,7 +232,7 @@ function describeSessionAction(
case 'openPlaylistBrowser':
return 'Open playlist browser';
case 'openAnimeBrowser':
return 'Open Anime Browser';
return 'Toggle Anime Browser';
case 'replayCurrentSubtitle':
return 'Replay current subtitle';
case 'playNextSubtitle':
+6
View File
@@ -274,6 +274,7 @@ const playlistBrowserModal = createPlaylistBrowserModal(ctx, {
});
const animeBrowserModal = createAnimeBrowserModal(ctx, {
modalStateReader: { isAnyModalOpen },
dismissOtherModals: () => modalRegistry.dismissOpenExcept('anime-browser'),
syncSettingsModalSubtitleSuppression,
});
const keyboardHandlers = createKeyboardHandlers(ctx, {
@@ -596,6 +597,11 @@ function registerModalOpenHandlers(): void {
animeBrowserModal.openAnimeBrowserModal();
});
});
window.electronAPI.onCloseAnimeBrowser(() => {
runGuarded('anime-browser:close', () => {
animeBrowserModal.closeAnimeBrowserModal();
});
});
window.electronAPI.onCancelYoutubeTrackPicker(() => {
runGuarded('youtube:picker-cancel', () => {
youtubePickerModal.closeYoutubePickerModal();
+36
View File
@@ -0,0 +1,36 @@
export const ANIME_BROWSER_CLOSE_MESSAGE = 'subminer:anime-browser-close';
const ANIME_BROWSER_KEYDOWN_MESSAGE = 'subminer:anime-browser-keydown';
export interface AnimeBrowserKeydownMessage {
type: typeof ANIME_BROWSER_KEYDOWN_MESSAGE;
bindingKey: string;
repeat: boolean;
}
export function createAnimeBrowserKeydownMessage(
event: Pick<KeyboardEvent, 'altKey' | 'code' | 'ctrlKey' | 'metaKey' | 'repeat' | 'shiftKey'>,
): AnimeBrowserKeydownMessage {
const parts: string[] = [];
if (event.ctrlKey) parts.push('Ctrl');
if (event.altKey) parts.push('Alt');
if (event.shiftKey) parts.push('Shift');
if (event.metaKey) parts.push('Meta');
parts.push(event.code);
return {
type: ANIME_BROWSER_KEYDOWN_MESSAGE,
bindingKey: parts.join('+'),
repeat: event.repeat,
};
}
export function isAnimeBrowserKeydownMessage(value: unknown): value is AnimeBrowserKeydownMessage {
if (!value || typeof value !== 'object') return false;
const message = value as Partial<AnimeBrowserKeydownMessage>;
return (
message.type === ANIME_BROWSER_KEYDOWN_MESSAGE &&
typeof message.bindingKey === 'string' &&
message.bindingKey.length > 0 &&
typeof message.repeat === 'boolean'
);
}
+3
View File
@@ -140,6 +140,7 @@ export const IPC_CHANNELS = {
animeBrowserDequeueEpisode: 'anime-browser:dequeue-episode',
animeBrowserClearQueue: 'anime-browser:clear-queue',
animeBrowserGetQueue: 'anime-browser:get-queue',
animeBrowserGetPlaybackState: 'anime-browser:get-playback-state',
animeBrowserIsPlaying: 'anime-browser:is-playing',
animeBrowserGetPreferences: 'anime-browser:get-preferences',
animeBrowserSetPreference: 'anime-browser:set-preference',
@@ -174,6 +175,7 @@ export const IPC_CHANNELS = {
sessionHelpOpen: 'session-help:open',
changelogOpen: 'changelog:open',
animeBrowserOpen: 'anime-browser:open',
animeBrowserClose: 'anime-browser:close',
characterDictionaryManagerOpen: 'character-dictionary:manager-open',
controllerSelectOpen: 'controller-select:open',
controllerDebugOpen: 'controller-debug:open',
@@ -187,6 +189,7 @@ export const IPC_CHANNELS = {
animeBrowserBridgeState: 'anime-browser:bridge-state',
animeBrowserSearchUpdate: 'anime-browser:search-update',
animeBrowserQueueState: 'anime-browser:queue-state',
animeBrowserPlaybackState: 'anime-browser:playback-state',
},
} as const;
+10
View File
@@ -222,6 +222,13 @@ export interface AnimeBrowserPlayResult {
quality: string | null;
}
/** The Anime Browser episode mpv is currently playing, shared by every browser surface. */
export interface AnimeBrowserPlaybackState {
sourceId: string;
animeUrl: string;
episodeUrl: string;
}
/** One resolved episode waiting in mpv's playlist. */
export type AnimeBrowserQueueEntry = AnimeBrowserPlayRequest;
@@ -267,6 +274,7 @@ export interface AnimeBrowserAPI {
dequeueEpisode: (sourceId: string, episodeUrl: string) => Promise<AnimeBrowserQueueState>;
clearQueue: () => Promise<AnimeBrowserQueueState>;
getQueue: () => Promise<AnimeBrowserQueueState>;
getPlaybackState: () => Promise<AnimeBrowserPlaybackState | null>;
/**
* Whether mpv has a file open. False when it is idle or not running at all,
* which is when queueing has no end to wait for.
@@ -288,6 +296,8 @@ export interface AnimeBrowserAPI {
onSearchUpdate: (listener: (update: AnimeBrowserSearchUpdate) => void) => () => void;
/** Pushed whenever the queue changes, including when it advances by itself. */
onQueueState: (listener: (state: AnimeBrowserQueueState) => void) => () => void;
/** Pushed when mpv starts an Anime Browser episode or moves to other media. */
onPlaybackState: (listener: (state: AnimeBrowserPlaybackState | null) => void) => () => void;
}
export type { SourcePreferenceView } from '../anime-bridge/preferences';
+1
View File
@@ -512,6 +512,7 @@ export interface ElectronAPI {
onOpenSessionHelp: (callback: () => void) => void;
onOpenChangelog: (callback: () => void) => void;
onOpenAnimeBrowser: (callback: () => void) => void;
onCloseAnimeBrowser: (callback: () => void) => void;
getChangelogSnapshot: (options?: { refresh?: boolean }) => Promise<ChangelogSnapshot>;
onOpenControllerSelect: (callback: () => void) => void;
onOpenControllerDebug: (callback: () => void) => void;