mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-09-21 17:16:20 -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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user