mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-08-16 01:55:51 -07:00
fix(anime): harden browser playback and modal lifecycle
- Abort upstream fetches when clients disconnect - Validate embedded modal readiness before opening - Centralize browser IPC and window lifecycle
This commit is contained in:
@@ -14,7 +14,7 @@ area: anime
|
||||
- Anime playback targets Japanese audio: dub-labelled entries are skipped when the source offers an alternative, `alang` prefers Japanese, and the source's own audio and subtitle tracks are loaded into mpv (Japanese selected) instead of being discarded, so all of them can be switched from mpv's track menu.
|
||||
- The primary subtitle slot stays reserved for Japanese: a source that only has, say, English subtitles gets them added with a normalized language tag (`English` → `en`) but not selected, so the regular `secondarySub` auto-load can route them to the secondary slot instead.
|
||||
- HLS streams pass through a local strip proxy that removes fake image headers some hosts glue onto their video segments and gives disguised segment URLs (`.image`, `.jpg`, `.css`, and other rotating fake extensions) a media-safe local alias, so those streams play in mpv and support Anki audio and image extraction with current ffmpeg releases.
|
||||
- The strip proxy retries a failed segment fetch once after a short pause and logs upstream error statuses; a host that errors on the very first fetches right after an episode resolves no longer kills the whole playback.
|
||||
- The strip proxy retries a failed segment fetch once after a short pause and logs upstream error statuses; a host that errors on the very first fetches right after an episode resolves no longer kills the whole playback, and disconnecting clients release their active upstream fetch instead of consuming the socket and bandwidth in the background.
|
||||
- The strip proxy no longer forwards `Range` headers to the bridge: ffmpeg opens every HLS segment with `Range: bytes=0-`, the bridge answers some of those with 206, and a partial response bypassed the disguise strip, so whether an episode played depended on the bridge's cache state.
|
||||
- A bridge that dies out from under the app (killed, crashed, or stopped mid-operation) no longer leaves the browser failing every request until an app restart: the exit is detected, surfaced in the status bar, and the bridge restarts on the next request.
|
||||
- "Playing" is only reported once mpv actually configures a video output; when a stream fails to decode, the browser shows mpv's error instead of claiming playback started while no window ever appeared.
|
||||
@@ -29,4 +29,4 @@ area: anime
|
||||
- Right-clicking an episode opens a menu for marking it watched or unwatched by hand, plus "Mark this and N below watched/unwatched" for the episode and every episode listed under it. Sources list newest first, so a span covers the back catalogue, which is how a series watched elsewhere gets caught up. A filter never narrows what a span covers, and the status bar reports how many episodes were touched.
|
||||
- Marking an episode that was never played creates its stats row, carrying the same series, season and episode fields playback would have recorded. Both stats library views join the lifetime tables, so a manual mark does not show up there as watch time nobody spent, and clearing a mark creates nothing.
|
||||
- Episodes can be queued instead of replacing what is playing. Every episode row has **Play** and **Queue** buttons (clicking the row still plays now), the right-click menu offers the same two, and a queued episode shows its place in line ("next up", "#2 in queue") with a queue count and **Clear queue** in the episode header. The queue spans anime, resolves and appends each episode to mpv's real playlist as soon as it is queued while subtitle tracks cache in the background, so next/previous navigation works immediately and the next episode starts without a resolution pause when the current one ends. Queueing with nothing playing just plays.
|
||||
- Added an in-player Anime Browser modal on `Ctrl+Alt+A`. The shortcut toggles it without losing its page or scroll position. It stays within the player bounds and shares the active episode, playback queue, source configuration, and watch history with the standalone browser, while each surface keeps independent search and navigation state.
|
||||
- Added an in-player Anime Browser modal on `Ctrl+Alt+A`. The shortcut toggles it without losing its page or scroll position. It stays within the player bounds and shares the active episode, playback queue, source configuration, and watch history with the standalone browser, while each surface keeps independent search and navigation state. The modal validates its embedded page before changing overlay state, so a load setup failure leaves it closed rather than revealing a broken modal.
|
||||
|
||||
@@ -165,6 +165,11 @@ export interface StreamStripProxyHandle {
|
||||
close: () => Promise<void>;
|
||||
}
|
||||
|
||||
interface ClientRequestLifecycle {
|
||||
activeUpstreamRequest: http.ClientRequest | null;
|
||||
closed: boolean;
|
||||
}
|
||||
|
||||
/** Response headers that must not be forwarded verbatim. */
|
||||
const DROPPED_HEADERS = new Set([
|
||||
'connection',
|
||||
@@ -212,7 +217,20 @@ export function startStreamStripProxy(
|
||||
delete requestHeaders.range;
|
||||
res.on('error', () => {});
|
||||
|
||||
requestUpstream(req, res, upstreamUrl, requestHeaders, 0);
|
||||
const lifecycle: ClientRequestLifecycle = { activeUpstreamRequest: null, closed: false };
|
||||
const destroyUpstreamOnClientClose = (): void => {
|
||||
lifecycle.closed = true;
|
||||
lifecycle.activeUpstreamRequest?.destroy();
|
||||
};
|
||||
req.once('aborted', destroyUpstreamOnClientClose);
|
||||
res.once('close', destroyUpstreamOnClientClose);
|
||||
res.once('finish', () => {
|
||||
req.off('aborted', destroyUpstreamOnClientClose);
|
||||
res.off('close', destroyUpstreamOnClientClose);
|
||||
lifecycle.activeUpstreamRequest = null;
|
||||
});
|
||||
|
||||
requestUpstream(req, res, upstreamUrl, requestHeaders, 0, lifecycle);
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -227,23 +245,31 @@ export function startStreamStripProxy(
|
||||
upstreamUrl: URL,
|
||||
requestHeaders: http.OutgoingHttpHeaders,
|
||||
attempt: number,
|
||||
lifecycle: ClientRequestLifecycle,
|
||||
): void {
|
||||
if (lifecycle.closed || res.destroyed) return;
|
||||
const mayRetry = req.method === 'GET' && attempt === 0;
|
||||
// Once the response is handed off, its headers (and often part of its body)
|
||||
// are already on the wire: a later upstream error can only be reported by
|
||||
// killing the connection, never by retrying or writing a 502.
|
||||
let handedOff = false;
|
||||
const retry = (): void => {
|
||||
setTimeout(
|
||||
() => requestUpstream(req, res, upstreamUrl, requestHeaders, attempt + 1),
|
||||
retryDelayMs,
|
||||
);
|
||||
setTimeout(() => {
|
||||
requestUpstream(req, res, upstreamUrl, requestHeaders, attempt + 1, lifecycle);
|
||||
}, retryDelayMs);
|
||||
};
|
||||
|
||||
const upstreamRequest = http.request(
|
||||
upstreamUrl,
|
||||
{ method: req.method, headers: requestHeaders, timeout: UPSTREAM_TIMEOUT_MS },
|
||||
(upstream) => {
|
||||
const clearActiveRequest = (): void => {
|
||||
if (lifecycle.activeUpstreamRequest === upstreamRequest) {
|
||||
lifecycle.activeUpstreamRequest = null;
|
||||
}
|
||||
};
|
||||
upstream.once('end', clearActiveRequest);
|
||||
upstream.once('close', clearActiveRequest);
|
||||
// Body streaming has its own pace; only the wait for headers is capped.
|
||||
upstreamRequest.setTimeout(0);
|
||||
const status = upstream.statusCode ?? 502;
|
||||
@@ -260,6 +286,7 @@ export function startStreamStripProxy(
|
||||
handleUpstreamResponse(req, res, upstream);
|
||||
},
|
||||
);
|
||||
lifecycle.activeUpstreamRequest = upstreamRequest;
|
||||
// Destroying with an error routes the stall through the retry/502 path.
|
||||
upstreamRequest.on('timeout', () => {
|
||||
upstreamRequest.destroy(new Error(`upstream silent for ${UPSTREAM_TIMEOUT_MS}ms`));
|
||||
|
||||
@@ -453,7 +453,12 @@ 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.getQueue().then(
|
||||
(state) => detailPanel.setQueue(state),
|
||||
() => {
|
||||
// A live queue event can still populate the panel after a failed snapshot request.
|
||||
},
|
||||
);
|
||||
void api.getPlaybackState().then(
|
||||
(state) => {
|
||||
if (!receivedPlaybackStateEvent) detailPanel.setPlaybackState(state);
|
||||
|
||||
+83
-197
@@ -427,7 +427,6 @@ import { createCoverArtFetcher } from './core/services/anilist/cover-art-fetcher
|
||||
import { createAnilistRateLimiter } from './core/services/anilist/rate-limiter';
|
||||
import { createJellyfinTokenStore } from './core/services/jellyfin-token-store';
|
||||
import { applyRuntimeOptionResultRuntime } from './core/services/runtime-options-ipc';
|
||||
import { releaseDockIcon, retainDockIcon } from './core/services/dock-icon-visibility';
|
||||
import { createAnilistTokenStore } from './core/services/anilist/anilist-token-store';
|
||||
import { dispatchSessionAction as dispatchSessionActionCore } from './core/services/session-actions';
|
||||
import { createBuildOverlayShortcutsRuntimeMainDepsHandler } from './main/runtime/domains/shortcuts';
|
||||
@@ -491,7 +490,6 @@ import { createMediaRuntimeService } from './main/media-runtime';
|
||||
import {
|
||||
createStreamPlaybackMetadataStore,
|
||||
matchRequestedStreamPlaybackMetadata,
|
||||
toAnimeBrowserPlaybackState,
|
||||
toAnilistMediaGuess,
|
||||
toJimakuMediaInfo,
|
||||
} from './main/runtime/stream-playback-metadata';
|
||||
@@ -542,14 +540,9 @@ import {
|
||||
createCreateFirstRunSetupWindowHandler,
|
||||
createCreateJellyfinSetupWindowHandler,
|
||||
createCreateSyncUiWindowHandler,
|
||||
createCreateAnimeBrowserWindowHandler,
|
||||
} from './main/runtime/setup-window-factory';
|
||||
import { createAnimeBrowserRuntime } from './main/runtime/anime-browser-runtime';
|
||||
import { createAnimeBrowserApplicationRuntime } from './main/runtime/anime-browser-application-runtime';
|
||||
import { openAnimeBrowserModal as openAnimeBrowserModalRuntime } from './main/runtime/anime-browser-open';
|
||||
import {
|
||||
registerAnimeBrowserIpcHandlers,
|
||||
type AnimeBrowserIpcSender,
|
||||
} from './main/runtime/anime-browser-ipc-handlers';
|
||||
import { ensureBridgeBinaries } from './main/runtime/anime-bridge-installer';
|
||||
import { createConfigSettingsRuntime } from './main/runtime/config-settings-runtime';
|
||||
import { createOpenConfigSettingsWindowHandler } from './main/runtime/config-settings-window';
|
||||
@@ -2469,7 +2462,7 @@ const mediaRuntime = createMediaRuntimeService(
|
||||
getSubtitlePositionsDir: () => SUBTITLE_POSITIONS_DIR,
|
||||
setCurrentMediaPath: (nextPath: string | null) => {
|
||||
appState.currentMediaPath = nextPath;
|
||||
publishAnimeBrowserPlaybackState(nextPath);
|
||||
animeBrowserApplicationRuntime.publishPlaybackState(nextPath);
|
||||
},
|
||||
clearPendingSubtitlePosition: () => {
|
||||
appState.pendingSubtitlePosition = null;
|
||||
@@ -3328,200 +3321,93 @@ 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>();
|
||||
const standalone = appState.animeBrowserWindow;
|
||||
if (standalone && !standalone.isDestroyed()) targets.add(standalone.webContents);
|
||||
for (const sender of animeBrowserSessions.values()) {
|
||||
if (!sender.isDestroyed()) targets.add(sender);
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
const animeBrowserRuntime = createAnimeBrowserRuntime({
|
||||
extensionsDir: () => resolveAnimeExtensionsDir(),
|
||||
repos: () => configService.getConfig().anime?.repos ?? [],
|
||||
setRepos: (repos) => {
|
||||
configService.patchRawConfig({ anime: { repos } });
|
||||
},
|
||||
preferredQuality: () => configService.getConfig().anime?.preferredQuality || undefined,
|
||||
preferencesFile: path.join(USER_DATA_PATH, 'anime-source-preferences.json'),
|
||||
ensureBinaries: (onProgress) =>
|
||||
ensureBridgeBinaries({
|
||||
installDir: path.join(USER_DATA_PATH, 'anime-bridge'),
|
||||
onProgress,
|
||||
}),
|
||||
sendMpvCommand: (command) => sendMpvCommandRuntime(appState.mpvClient, command),
|
||||
ensureMpvConnected: () => ensureMpvConnectedForPlayback(),
|
||||
onPlaybackEndFile: (listener) => {
|
||||
const client = appState.mpvClient;
|
||||
if (!client) return () => {};
|
||||
client.on('end-file', listener);
|
||||
return () => client.off('end-file', listener);
|
||||
},
|
||||
onPlaybackPathChange: (listener) => {
|
||||
const client = appState.mpvClient;
|
||||
if (!client) return () => {};
|
||||
const handler = ({ path: mediaPath }: { path: string }) => listener(mediaPath);
|
||||
client.on('media-path-change', handler);
|
||||
return () => client.off('media-path-change', handler);
|
||||
},
|
||||
readMpvProperty: (name) => {
|
||||
const client = appState.mpvClient;
|
||||
if (!client) return Promise.reject(new Error('mpv is not connected.'));
|
||||
return client.requestProperty(name);
|
||||
},
|
||||
showVisibleOverlay: () => {
|
||||
// Launching a video turns a browse-only instance into a regular SubMiner
|
||||
// playback session: tray icon plus the overlay runtime that
|
||||
// setVisibleOverlayVisible initializes on demand.
|
||||
ensureTrayHandler();
|
||||
setVisibleOverlayVisible(true);
|
||||
},
|
||||
showMpvOsd: (text) =>
|
||||
overlayNotificationsRuntime.showConfiguredStatusNotification(text, { title: 'Anime' }),
|
||||
getWatchState: async (statsPaths) => {
|
||||
// Browsing can precede any playback, so the tracker may not be up yet; it
|
||||
// is the same instance playback records into once it is.
|
||||
ensureImmersionTrackerStarted();
|
||||
return (await appState.immersionTracker?.getStreamWatchState(statsPaths)) ?? new Map();
|
||||
},
|
||||
setWatchState: async (episodes, watched) => {
|
||||
ensureImmersionTrackerStarted();
|
||||
return (await appState.immersionTracker?.setStreamWatchState(episodes, watched)) ?? 0;
|
||||
},
|
||||
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.
|
||||
mediaRuntime.updateCurrentMediaTitle(metadata.displayTitle);
|
||||
ensureImmersionTrackerStarted();
|
||||
appState.immersionTracker?.recordStreamPlaybackMetadata({
|
||||
mediaPath: metadata.mediaPath,
|
||||
statsPath: metadata.statsPath,
|
||||
displayTitle: metadata.displayTitle,
|
||||
seriesTitle: metadata.seriesTitle,
|
||||
seasonNumber: metadata.seasonNumber,
|
||||
episodeNumber: metadata.episodeNumber,
|
||||
});
|
||||
},
|
||||
onPreparedPlaybackMetadata: (metadata) => {
|
||||
streamPlaybackMetadata.set(metadata);
|
||||
// Register the URL alias before mpv can advance to it, but do not replace
|
||||
// the title of the file that is still playing now.
|
||||
ensureImmersionTrackerStarted();
|
||||
appState.immersionTracker?.recordStreamPlaybackMetadata({
|
||||
mediaPath: metadata.mediaPath,
|
||||
statsPath: metadata.statsPath,
|
||||
displayTitle: metadata.displayTitle,
|
||||
seriesTitle: metadata.seriesTitle,
|
||||
seasonNumber: metadata.seasonNumber,
|
||||
episodeNumber: metadata.episodeNumber,
|
||||
});
|
||||
},
|
||||
onBridgeState: (state) => {
|
||||
broadcastAnimeBrowserEvent(IPC_CHANNELS.event.animeBrowserBridgeState, state);
|
||||
},
|
||||
onSearchUpdate: (update, sessionId) => {
|
||||
const sender = animeBrowserSessions.get(sessionId);
|
||||
if (sender && !sender.isDestroyed()) {
|
||||
sender.send(IPC_CHANNELS.event.animeBrowserSearchUpdate, update);
|
||||
}
|
||||
},
|
||||
onQueueState: (state) => {
|
||||
broadcastAnimeBrowserEvent(IPC_CHANNELS.event.animeBrowserQueueState, state);
|
||||
},
|
||||
log: (message) => logger.info(message),
|
||||
});
|
||||
|
||||
registerAnimeBrowserIpcHandlers({
|
||||
ipcMain,
|
||||
runtime: animeBrowserRuntime,
|
||||
getPlaybackState: () => animeBrowserPlaybackState,
|
||||
registerSession: (sessionId, sender) => {
|
||||
if (animeBrowserSessions.get(sessionId) === sender) return;
|
||||
animeBrowserSessions.set(sessionId, sender);
|
||||
sender.once('destroyed', () => {
|
||||
if (animeBrowserSessions.get(sessionId) !== sender) return;
|
||||
animeBrowserSessions.delete(sessionId);
|
||||
animeBrowserRuntime.releaseSession(sessionId);
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
let animeBrowserDockIconRetained = false;
|
||||
const releaseAnimeBrowserDockIcon = (): void => {
|
||||
if (!animeBrowserDockIconRetained) return;
|
||||
animeBrowserDockIconRetained = false;
|
||||
releaseDockIcon({
|
||||
dock: app.dock,
|
||||
shouldRehide: () => {
|
||||
const mainWindow = overlayManager.getMainWindow();
|
||||
return Boolean(mainWindow && !mainWindow.isDestroyed());
|
||||
const animeBrowserApplicationRuntime = createAnimeBrowserApplicationRuntime({
|
||||
userDataPath: USER_DATA_PATH,
|
||||
mainModuleDir: __dirname,
|
||||
configuredExtensionsDir: () => configService.getConfig().anime?.extensionsDir,
|
||||
runtime: {
|
||||
repos: () => configService.getConfig().anime?.repos ?? [],
|
||||
setRepos: (repos) => configService.patchRawConfig({ anime: { repos } }),
|
||||
preferredQuality: () => configService.getConfig().anime?.preferredQuality || undefined,
|
||||
preferencesFile: path.join(USER_DATA_PATH, 'anime-source-preferences.json'),
|
||||
ensureBinaries: (onProgress) =>
|
||||
ensureBridgeBinaries({
|
||||
installDir: path.join(USER_DATA_PATH, 'anime-bridge'),
|
||||
onProgress,
|
||||
}),
|
||||
sendMpvCommand: (command) => sendMpvCommandRuntime(appState.mpvClient, command),
|
||||
ensureMpvConnected: () => ensureMpvConnectedForPlayback(),
|
||||
onPlaybackEndFile: (listener) => {
|
||||
const client = appState.mpvClient;
|
||||
if (!client) return () => {};
|
||||
client.on('end-file', listener);
|
||||
return () => client.off('end-file', listener);
|
||||
},
|
||||
});
|
||||
};
|
||||
const openAnimeBrowserWindowBase = createOpenConfigSettingsWindowHandler({
|
||||
getSettingsWindow: () => appState.animeBrowserWindow,
|
||||
setSettingsWindow: (window) => {
|
||||
onPlaybackPathChange: (listener) => {
|
||||
const client = appState.mpvClient;
|
||||
if (!client) return () => {};
|
||||
const handler = ({ path: mediaPath }: { path: string }) => listener(mediaPath);
|
||||
client.on('media-path-change', handler);
|
||||
return () => client.off('media-path-change', handler);
|
||||
},
|
||||
readMpvProperty: (name) => {
|
||||
const client = appState.mpvClient;
|
||||
if (!client) return Promise.reject(new Error('mpv is not connected.'));
|
||||
return client.requestProperty(name);
|
||||
},
|
||||
showVisibleOverlay: () => {
|
||||
ensureTrayHandler();
|
||||
setVisibleOverlayVisible(true);
|
||||
},
|
||||
showMpvOsd: (text) =>
|
||||
overlayNotificationsRuntime.showConfiguredStatusNotification(text, { title: 'Anime' }),
|
||||
getWatchState: async (statsPaths) => {
|
||||
ensureImmersionTrackerStarted();
|
||||
return (await appState.immersionTracker?.getStreamWatchState(statsPaths)) ?? new Map();
|
||||
},
|
||||
setWatchState: async (episodes, watched) => {
|
||||
ensureImmersionTrackerStarted();
|
||||
return (await appState.immersionTracker?.setStreamWatchState(episodes, watched)) ?? 0;
|
||||
},
|
||||
log: (message) => logger.info(message),
|
||||
},
|
||||
ipcMain,
|
||||
playbackMetadata: streamPlaybackMetadata,
|
||||
getInitialPlaybackMetadata: () => getActiveStreamMetadata(),
|
||||
handlePlaybackMetadata: (metadata, prepared) => {
|
||||
if (!prepared) {
|
||||
// Set before mpv reports the path change so stats use the source title, not the proxy URL.
|
||||
mediaRuntime.updateCurrentMediaTitle(metadata.displayTitle);
|
||||
}
|
||||
ensureImmersionTrackerStarted();
|
||||
appState.immersionTracker?.recordStreamPlaybackMetadata({
|
||||
mediaPath: metadata.mediaPath,
|
||||
statsPath: metadata.statsPath,
|
||||
displayTitle: metadata.displayTitle,
|
||||
seriesTitle: metadata.seriesTitle,
|
||||
seasonNumber: metadata.seasonNumber,
|
||||
episodeNumber: metadata.episodeNumber,
|
||||
});
|
||||
},
|
||||
getAnimeBrowserWindow: () => appState.animeBrowserWindow,
|
||||
setAnimeBrowserWindow: (window) => {
|
||||
appState.animeBrowserWindow = window as BrowserWindow | null;
|
||||
},
|
||||
createSettingsWindow: createCreateAnimeBrowserWindowHandler({
|
||||
createBrowserWindow: (options) => new BrowserWindow(options),
|
||||
preloadPath: path.join(__dirname, 'preload-animeui.js'),
|
||||
}),
|
||||
settingsHtmlPath: path.join(__dirname, 'animeui', 'index.html'),
|
||||
promoteSettingsWindowAboveOverlay: (window) =>
|
||||
promoteSettingsWindowAboveOverlay(window as BrowserWindow),
|
||||
createBrowserWindow: (options) => new BrowserWindow(options),
|
||||
promoteWindowAboveOverlay: (window) => promoteSettingsWindowAboveOverlay(window as BrowserWindow),
|
||||
activateApp: activateAppForForegroundWindow,
|
||||
onClosed: () => {
|
||||
releaseAnimeBrowserDockIcon();
|
||||
// The bridge holds the stream-proxy tokens mpv is playing through, so it
|
||||
// must outlive the window. Only tear it down when the window was the
|
||||
// app's whole reason to be running — and once a video has launched, the
|
||||
// instance has become a regular playback session (tray, overlay, mpv
|
||||
// stream), so closing the browser must not kill it either.
|
||||
if (appState.initialArgs?.animeBrowser && !appState.mpvClient?.connected) {
|
||||
void animeBrowserRuntime.dispose().finally(() => requestAppQuit());
|
||||
}
|
||||
dock: app.dock,
|
||||
shouldRehideDockIcon: () => {
|
||||
const mainWindow = overlayManager.getMainWindow();
|
||||
return Boolean(mainWindow && !mainWindow.isDestroyed());
|
||||
},
|
||||
log: (message) => logger.error(message),
|
||||
isStandaloneAnimeBrowserLaunch: () => Boolean(appState.initialArgs?.animeBrowser),
|
||||
isMpvConnected: () => Boolean(appState.mpvClient?.connected),
|
||||
ensureTray: () => ensureTrayHandler(),
|
||||
requestAppQuit,
|
||||
logError: (message) => logger.error(message),
|
||||
});
|
||||
const openAnimeBrowserWindowHandler = (): boolean => {
|
||||
// Restore the Dock icon *before* showing the window: while the overlay's fullscreen
|
||||
// transform has left the app as a macOS accessory process it cannot become frontmost at
|
||||
// all, so activating first and un-hiding after would leave the window buried.
|
||||
if (!animeBrowserDockIconRetained) {
|
||||
animeBrowserDockIconRetained = true;
|
||||
retainDockIcon({ dock: app.dock });
|
||||
}
|
||||
const opened = openAnimeBrowserWindowBase();
|
||||
if (!opened) {
|
||||
releaseAnimeBrowserDockIcon();
|
||||
return false;
|
||||
}
|
||||
ensureTrayHandler();
|
||||
return true;
|
||||
};
|
||||
const openAnimeBrowserWindowHandler = animeBrowserApplicationRuntime.openWindow;
|
||||
|
||||
const maybeFocusExistingFirstRunSetupWindow = createMaybeFocusExistingFirstRunSetupWindowHandler({
|
||||
getSetupWindow: () => appState.firstRunSetupWindow,
|
||||
|
||||
@@ -330,6 +330,30 @@ for (const platform of ['darwin', 'win32'] as const) {
|
||||
});
|
||||
}
|
||||
|
||||
test('priming and IPC delivery both accept an unset document-loaded flag', () => {
|
||||
const modalWindow = createMockWindow();
|
||||
Reflect.deleteProperty(modalWindow, '__subminerOverlayDocumentLoaded');
|
||||
const runtime = createOverlayModalRuntimeService(
|
||||
{
|
||||
getMainWindow: () => null,
|
||||
getModalWindow: () => modalWindow as never,
|
||||
createModalWindow: () => modalWindow as never,
|
||||
getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }),
|
||||
setModalWindowBounds: () => {},
|
||||
},
|
||||
{ platform: 'darwin' },
|
||||
);
|
||||
|
||||
assert.equal(runtime.primeModalWindow(), true);
|
||||
assert.equal(
|
||||
runtime.sendToActiveOverlayWindow('runtime-options:open', undefined, {
|
||||
restoreOnModalClose: 'runtime-options',
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assert.deepEqual(modalWindow.sent, [['runtime-options:open']]);
|
||||
});
|
||||
|
||||
test('anime browser modal keeps its document warm across close on Linux', () => {
|
||||
const modalWindow = createMockWindow();
|
||||
const runtime = createOverlayModalRuntimeService(
|
||||
|
||||
@@ -138,6 +138,13 @@ export function createOverlayModalRuntimeService(
|
||||
return null;
|
||||
};
|
||||
|
||||
const isWindowDocumentLoaded = (window: BrowserWindow): boolean => {
|
||||
const overlayWindow = window as BrowserWindow & {
|
||||
[OVERLAY_WINDOW_DOCUMENT_LOADED_FLAG]?: boolean;
|
||||
};
|
||||
return overlayWindow[OVERLAY_WINDOW_DOCUMENT_LOADED_FLAG] !== false;
|
||||
};
|
||||
|
||||
const isWindowReadyForIpc = (window: BrowserWindow): boolean => {
|
||||
if (window.isDestroyed()) {
|
||||
return false;
|
||||
@@ -145,13 +152,12 @@ export function createOverlayModalRuntimeService(
|
||||
if (window.webContents.isLoading()) {
|
||||
return false;
|
||||
}
|
||||
const overlayWindow = window as BrowserWindow & {
|
||||
[OVERLAY_WINDOW_CONTENT_READY_FLAG]?: boolean;
|
||||
[OVERLAY_WINDOW_DOCUMENT_LOADED_FLAG]?: boolean;
|
||||
};
|
||||
if (overlayWindow[OVERLAY_WINDOW_DOCUMENT_LOADED_FLAG] === false) {
|
||||
if (!isWindowDocumentLoaded(window)) {
|
||||
return false;
|
||||
}
|
||||
const overlayWindow = window as BrowserWindow & {
|
||||
[OVERLAY_WINDOW_CONTENT_READY_FLAG]?: boolean;
|
||||
};
|
||||
if (
|
||||
typeof overlayWindow[OVERLAY_WINDOW_CONTENT_READY_FLAG] === 'boolean' &&
|
||||
overlayWindow[OVERLAY_WINDOW_CONTENT_READY_FLAG] !== true
|
||||
@@ -166,10 +172,7 @@ export function createOverlayModalRuntimeService(
|
||||
if (window.isDestroyed() || window.webContents.isLoading()) {
|
||||
return false;
|
||||
}
|
||||
const overlayWindow = window as BrowserWindow & {
|
||||
[OVERLAY_WINDOW_DOCUMENT_LOADED_FLAG]?: boolean;
|
||||
};
|
||||
if (overlayWindow[OVERLAY_WINDOW_DOCUMENT_LOADED_FLAG] !== true) {
|
||||
if (!isWindowDocumentLoaded(window)) {
|
||||
return false;
|
||||
}
|
||||
const currentURL = window.webContents.getURL();
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
import * as path from 'node:path';
|
||||
import type { BrowserWindow, BrowserWindowConstructorOptions } from 'electron';
|
||||
import type { AnimeStreamMetadata } from '../../anime-bridge/episode-metadata';
|
||||
import { releaseDockIcon, retainDockIcon } from '../../core/services/dock-icon-visibility';
|
||||
import { IPC_CHANNELS } from '../../shared/ipc/contracts';
|
||||
import type { AnimeBrowserPlaybackState } from '../../types/anime-browser';
|
||||
import {
|
||||
registerAnimeBrowserIpcHandlers,
|
||||
type AnimeBrowserIpcSender,
|
||||
} from './anime-browser-ipc-handlers';
|
||||
import { createAnimeBrowserRuntime, type AnimeBrowserRuntimeDeps } from './anime-browser-runtime';
|
||||
import { createOpenConfigSettingsWindowHandler } from './config-settings-window';
|
||||
import { createCreateAnimeBrowserWindowHandler } from './setup-window-factory';
|
||||
import {
|
||||
toAnimeBrowserPlaybackState,
|
||||
type StreamPlaybackMetadataStore,
|
||||
} from './stream-playback-metadata';
|
||||
|
||||
type AnimeBrowserWindow = Pick<
|
||||
BrowserWindow,
|
||||
'destroy' | 'focus' | 'isDestroyed' | 'loadFile' | 'on' | 'show' | 'webContents'
|
||||
>;
|
||||
|
||||
interface DockLike {
|
||||
show: () => Promise<void> | void;
|
||||
hide: () => void;
|
||||
}
|
||||
|
||||
export interface AnimeBrowserApplicationRuntimeDeps {
|
||||
userDataPath: string;
|
||||
mainModuleDir: string;
|
||||
runtime: Omit<
|
||||
AnimeBrowserRuntimeDeps,
|
||||
| 'extensionsDir'
|
||||
| 'onBridgeState'
|
||||
| 'onPlaybackMetadata'
|
||||
| 'onPreparedPlaybackMetadata'
|
||||
| 'onQueueState'
|
||||
| 'onSearchUpdate'
|
||||
>;
|
||||
configuredExtensionsDir: () => string | undefined;
|
||||
ipcMain: {
|
||||
handle(channel: string, listener: (event: unknown, ...args: unknown[]) => unknown): unknown;
|
||||
};
|
||||
playbackMetadata: StreamPlaybackMetadataStore;
|
||||
getInitialPlaybackMetadata: () => AnimeStreamMetadata | null;
|
||||
handlePlaybackMetadata: (metadata: AnimeStreamMetadata, prepared: boolean) => void;
|
||||
getAnimeBrowserWindow: () => AnimeBrowserWindow | null;
|
||||
setAnimeBrowserWindow: (window: AnimeBrowserWindow | null) => void;
|
||||
createBrowserWindow: (options: BrowserWindowConstructorOptions) => AnimeBrowserWindow;
|
||||
promoteWindowAboveOverlay: (window: AnimeBrowserWindow) => void;
|
||||
activateApp: () => void;
|
||||
dock: DockLike | null | undefined;
|
||||
shouldRehideDockIcon: () => boolean;
|
||||
isStandaloneAnimeBrowserLaunch: () => boolean;
|
||||
isMpvConnected: () => boolean;
|
||||
ensureTray: () => void;
|
||||
requestAppQuit: () => void;
|
||||
logError: (message: string) => void;
|
||||
}
|
||||
|
||||
export interface AnimeBrowserApplicationRuntime {
|
||||
publishPlaybackState: (mediaPath: string | null) => void;
|
||||
openWindow: () => boolean;
|
||||
}
|
||||
|
||||
/** Compose the anime browser runtime, IPC sessions, event fanout, and standalone window. */
|
||||
export function createAnimeBrowserApplicationRuntime(
|
||||
deps: AnimeBrowserApplicationRuntimeDeps,
|
||||
): AnimeBrowserApplicationRuntime {
|
||||
const sessions = new Map<string, AnimeBrowserIpcSender>();
|
||||
let playbackState: AnimeBrowserPlaybackState | null = toAnimeBrowserPlaybackState(
|
||||
deps.getInitialPlaybackMetadata(),
|
||||
);
|
||||
|
||||
const broadcast = (channel: string, payload: unknown): void => {
|
||||
const targets = new Set<AnimeBrowserIpcSender>();
|
||||
const standalone = deps.getAnimeBrowserWindow();
|
||||
if (standalone && !standalone.isDestroyed()) targets.add(standalone.webContents);
|
||||
for (const sender of sessions.values()) {
|
||||
if (!sender.isDestroyed()) targets.add(sender);
|
||||
}
|
||||
for (const sender of targets) sender.send(channel, payload);
|
||||
};
|
||||
|
||||
const publishPlaybackState = (mediaPath: string | null): void => {
|
||||
playbackState = toAnimeBrowserPlaybackState(deps.playbackMetadata.match(mediaPath));
|
||||
broadcast(IPC_CHANNELS.event.animeBrowserPlaybackState, playbackState);
|
||||
};
|
||||
|
||||
const runtime = createAnimeBrowserRuntime({
|
||||
...deps.runtime,
|
||||
extensionsDir: () => {
|
||||
const configured = deps.configuredExtensionsDir()?.trim();
|
||||
return configured && configured.length > 0
|
||||
? configured
|
||||
: path.join(deps.userDataPath, 'anime-extensions');
|
||||
},
|
||||
onPlaybackMetadata: (metadata) => {
|
||||
deps.playbackMetadata.set(metadata);
|
||||
publishPlaybackState(metadata.mediaPath);
|
||||
deps.handlePlaybackMetadata(metadata, false);
|
||||
},
|
||||
onPreparedPlaybackMetadata: (metadata) => {
|
||||
deps.playbackMetadata.set(metadata);
|
||||
deps.handlePlaybackMetadata(metadata, true);
|
||||
},
|
||||
onBridgeState: (state) => broadcast(IPC_CHANNELS.event.animeBrowserBridgeState, state),
|
||||
onSearchUpdate: (update, sessionId) => {
|
||||
const sender = sessions.get(sessionId);
|
||||
if (sender && !sender.isDestroyed()) {
|
||||
sender.send(IPC_CHANNELS.event.animeBrowserSearchUpdate, update);
|
||||
}
|
||||
},
|
||||
onQueueState: (state) => broadcast(IPC_CHANNELS.event.animeBrowserQueueState, state),
|
||||
});
|
||||
|
||||
registerAnimeBrowserIpcHandlers({
|
||||
ipcMain: deps.ipcMain,
|
||||
runtime,
|
||||
getPlaybackState: () => playbackState,
|
||||
registerSession: (sessionId, sender) => {
|
||||
if (sessions.get(sessionId) === sender) return;
|
||||
sessions.set(sessionId, sender);
|
||||
sender.once('destroyed', () => {
|
||||
if (sessions.get(sessionId) !== sender) return;
|
||||
sessions.delete(sessionId);
|
||||
runtime.releaseSession(sessionId);
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
let dockIconRetained = false;
|
||||
const releaseBrowserDockIcon = (): void => {
|
||||
if (!dockIconRetained) return;
|
||||
dockIconRetained = false;
|
||||
releaseDockIcon({ dock: deps.dock, shouldRehide: deps.shouldRehideDockIcon });
|
||||
};
|
||||
|
||||
const openWindowBase = createOpenConfigSettingsWindowHandler({
|
||||
getSettingsWindow: deps.getAnimeBrowserWindow,
|
||||
setSettingsWindow: deps.setAnimeBrowserWindow,
|
||||
createSettingsWindow: createCreateAnimeBrowserWindowHandler({
|
||||
createBrowserWindow: deps.createBrowserWindow,
|
||||
preloadPath: path.join(deps.mainModuleDir, 'preload-animeui.js'),
|
||||
}),
|
||||
settingsHtmlPath: path.join(deps.mainModuleDir, 'animeui', 'index.html'),
|
||||
promoteSettingsWindowAboveOverlay: deps.promoteWindowAboveOverlay,
|
||||
activateApp: deps.activateApp,
|
||||
onClosed: () => {
|
||||
releaseBrowserDockIcon();
|
||||
if (deps.isStandaloneAnimeBrowserLaunch() && !deps.isMpvConnected()) {
|
||||
void runtime.dispose().finally(deps.requestAppQuit);
|
||||
}
|
||||
},
|
||||
log: deps.logError,
|
||||
});
|
||||
|
||||
const openWindow = (): boolean => {
|
||||
if (!dockIconRetained) {
|
||||
dockIconRetained = true;
|
||||
retainDockIcon({ dock: deps.dock });
|
||||
}
|
||||
const opened = openWindowBase();
|
||||
if (!opened) {
|
||||
releaseBrowserDockIcon();
|
||||
return false;
|
||||
}
|
||||
deps.ensureTray();
|
||||
return true;
|
||||
};
|
||||
|
||||
return { publishPlaybackState, openWindow };
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { IPC_CHANNELS } from '../../shared/ipc/contracts';
|
||||
import { registerAnimeBrowserIpcHandlers } from './anime-browser-ipc-handlers';
|
||||
|
||||
test('anime browser preference IPC coerces values at the renderer boundary', () => {
|
||||
const handlers = new Map<string, (event: unknown, ...args: unknown[]) => unknown>();
|
||||
const received: unknown[][] = [];
|
||||
registerAnimeBrowserIpcHandlers({
|
||||
ipcMain: {
|
||||
handle: (channel, listener) => handlers.set(channel, listener),
|
||||
},
|
||||
runtime: {
|
||||
setPreference: (...args: unknown[]) => received.push(args),
|
||||
} as never,
|
||||
});
|
||||
|
||||
const setPreference = handlers.get(IPC_CHANNELS.request.animeBrowserSetPreference);
|
||||
assert.ok(setPreference);
|
||||
setPreference({}, 'source', 'text', 'value');
|
||||
setPreference({}, 'source', 'enabled', true);
|
||||
setPreference({}, 'source', 'choices', ['one', 2, false, 'two']);
|
||||
setPreference({}, 'source', 'invalid', { nested: 'value' });
|
||||
|
||||
assert.deepEqual(received, [
|
||||
['source', 'text', 'value'],
|
||||
['source', 'enabled', true],
|
||||
['source', 'choices', ['one', 'two']],
|
||||
['source', 'invalid', ''],
|
||||
]);
|
||||
});
|
||||
@@ -104,7 +104,7 @@ export function registerAnimeBrowserIpcHandlers(deps: AnimeBrowserIpcDeps): void
|
||||
runtime.getPreferences(String(sourceId)),
|
||||
);
|
||||
handle(channels.animeBrowserSetPreference, (_event, sourceId, key, value) =>
|
||||
runtime.setPreference(String(sourceId), String(key), value as string | string[] | boolean),
|
||||
runtime.setPreference(String(sourceId), String(key), toPreferenceValue(value)),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -169,6 +169,15 @@ function toOptionalId(value: unknown): string | undefined {
|
||||
return typeof value === 'string' && value.length > 0 ? value : undefined;
|
||||
}
|
||||
|
||||
/** Keep preference values within the bridge schema's supported scalar and list types. */
|
||||
function toPreferenceValue(value: unknown): string | string[] | boolean {
|
||||
if (typeof value === 'string' || typeof value === 'boolean') return value;
|
||||
if (Array.isArray(value)) {
|
||||
return value.filter((entry): entry is string => typeof entry === 'string');
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
/** Bridge pages are 1-based; anything unusable falls back to the first page. */
|
||||
function toPage(value: unknown): number {
|
||||
const page = Number(value);
|
||||
|
||||
@@ -17,6 +17,7 @@ test('embedded Anime Browser closes only for its own close message', () => {
|
||||
const messages: Array<(event: MessageEvent) => void> = [];
|
||||
const notifications: string[] = [];
|
||||
let staleModalOpen = true;
|
||||
let dismissClearsOtherModal = true;
|
||||
let dismissOtherModalCalls = 0;
|
||||
const frameWindow = {};
|
||||
const closeListeners: Array<() => void> = [];
|
||||
@@ -88,7 +89,7 @@ test('embedded Anime Browser closes only for its own close message', () => {
|
||||
},
|
||||
dismissOtherModals: () => {
|
||||
dismissOtherModalCalls += 1;
|
||||
staleModalOpen = false;
|
||||
if (dismissClearsOtherModal) staleModalOpen = false;
|
||||
},
|
||||
syncSettingsModalSubtitleSuppression: () => undefined,
|
||||
},
|
||||
@@ -184,6 +185,42 @@ test('embedded Anime Browser closes only for its own close message', () => {
|
||||
);
|
||||
assert.equal(dismissOtherModalCalls, dismissCallsBeforeRepair);
|
||||
assert.equal(notifications.at(-1), 'open:anime-browser');
|
||||
|
||||
let escapePrevented = false;
|
||||
assert.equal(
|
||||
modal.handleAnimeBrowserKeydown({
|
||||
key: 'Escape',
|
||||
preventDefault: () => {
|
||||
escapePrevented = true;
|
||||
},
|
||||
} as KeyboardEvent),
|
||||
true,
|
||||
);
|
||||
assert.equal(escapePrevented, true);
|
||||
assert.equal(state.animeBrowserModalOpen, false);
|
||||
assert.equal(modalElement.classList.contains('hidden'), true);
|
||||
|
||||
staleModalOpen = true;
|
||||
dismissClearsOtherModal = false;
|
||||
const notificationsBeforeBlockedOpen = [...notifications];
|
||||
assert.equal(modal.openAnimeBrowserModal(), false);
|
||||
assert.equal(state.animeBrowserModalOpen, false);
|
||||
assert.equal(modalElement.classList.contains('hidden'), true);
|
||||
assert.deepEqual(notifications, notificationsBeforeBlockedOpen);
|
||||
|
||||
staleModalOpen = false;
|
||||
dismissClearsOtherModal = true;
|
||||
frame.src = '';
|
||||
frame.dataset.src = '';
|
||||
assert.throws(
|
||||
() => modal.openAnimeBrowserModal(),
|
||||
/missing its embedded source/,
|
||||
'iframe load failure is reported before modal state changes',
|
||||
);
|
||||
assert.equal(state.animeBrowserModalOpen, false);
|
||||
assert.equal(modalElement.classList.contains('hidden'), true);
|
||||
assert.deepEqual(notifications, notificationsBeforeBlockedOpen);
|
||||
|
||||
modal.disposeDomEvents();
|
||||
assert.deepEqual(removedCloseListeners, closeListeners);
|
||||
} finally {
|
||||
|
||||
@@ -25,12 +25,12 @@ export function createAnimeBrowserModal(
|
||||
if (options.modalStateReader.isAnyModalOpen()) return false;
|
||||
}
|
||||
|
||||
ensureFrameLoaded();
|
||||
ctx.state.animeBrowserModalOpen = true;
|
||||
options.syncSettingsModalSubtitleSuppression();
|
||||
ctx.dom.overlay.classList.add('interactive');
|
||||
ctx.dom.animeBrowserModal.classList.remove('hidden');
|
||||
ctx.dom.animeBrowserModal.setAttribute('aria-hidden', 'false');
|
||||
ensureFrameLoaded();
|
||||
window.electronAPI.notifyOverlayModalOpened('anime-browser');
|
||||
return true;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user