mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-08-16 13:55:51 -07:00
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:
@@ -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,
|
||||
|
||||
@@ -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 ---------- */
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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' },
|
||||
);
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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, [
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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'
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user