fix(anime): keep browser state in sync and alias HLS segments

- Toggle the player Anime Browser without losing its state
- Share active playback state and support rotating fake segment extensions
This commit is contained in:
2026-08-15 23:35:37 -07:00
parent 7a1450ddb7
commit 877f350353
34 changed files with 612 additions and 43 deletions
+29 -1
View File
@@ -21,6 +21,10 @@ import type {
AnimeBrowserEntry,
AnimeBrowserSource,
} from '../types/anime-browser';
import {
ANIME_BROWSER_CLOSE_MESSAGE,
createAnimeBrowserKeydownMessage,
} from '../shared/anime-browser-embed';
const embeddedInOverlay =
new URLSearchParams(window.location.search).get('embedded') === 'overlay-modal';
@@ -95,10 +99,21 @@ function setStatus(message: string, tone: 'info' | 'ok' | 'error' = 'info'): voi
const detailPanel = createDetailPanel({ api, setStatus });
if (embeddedInOverlay) {
const overlayOrigin = window.location.origin;
// Keyboard events do not bubble out of an iframe. Forward the physical key
// chord so the overlay can apply the user's configured Anime Browser binding.
document.addEventListener(
'keydown',
(event) => {
if (event.key === 'Escape' && detailPanel.isOpen()) return;
window.parent.postMessage(createAnimeBrowserKeydownMessage(event), overlayOrigin);
},
true,
);
document.addEventListener('keydown', (event) => {
if (event.key !== 'Escape' || event.defaultPrevented || detailPanel.isOpen()) return;
event.preventDefault();
window.parent.postMessage('subminer:anime-browser-close', '*');
window.parent.postMessage(ANIME_BROWSER_CLOSE_MESSAGE, overlayOrigin);
});
}
@@ -428,12 +443,25 @@ api.onBridgeState(renderBridgeState);
// The queue changes without this window asking: it advances by itself when an
// episode ends, whether or not anyone is looking at the browser.
api.onQueueState((state) => detailPanel.setQueue(state));
let receivedPlaybackStateEvent = false;
api.onPlaybackState((state) => {
receivedPlaybackStateEvent = true;
detailPanel.setPlaybackState(state);
});
void (async () => {
renderBridgeState({ stage: 'idle', progress: null, message: null });
// A queue survives the window being closed and reopened, so start from what
// the main process already holds rather than from empty.
void api.getQueue().then((state) => detailPanel.setQueue(state));
void api.getPlaybackState().then(
(state) => {
if (!receivedPlaybackStateEvent) detailPanel.setPlaybackState(state);
},
() => {
// A live playback event can still populate the cue after a failed snapshot request.
},
);
try {
const state = await api.ensureBridge();
renderBridgeState(state);
+7 -1
View File
@@ -1,7 +1,11 @@
import { LatestRequest } from './browse-state';
import { describe, el } from './dom';
import { createEpisodeList, type SelectedAnime } from './episode-list';
import type { AnimeBrowserAPI, AnimeBrowserEntry } from '../types/anime-browser';
import type {
AnimeBrowserAPI,
AnimeBrowserEntry,
AnimeBrowserPlaybackState,
} from '../types/anime-browser';
interface DetailPanelOptions {
api: AnimeBrowserAPI;
@@ -102,5 +106,7 @@ export function createDetailPanel({ api, setStatus }: DetailPanelOptions) {
isOpen: (): boolean => !detail.classList.contains('hidden'),
/** The queue outlives the open anime, so it is pushed in from outside. */
setQueue: episodeList.setQueue,
setPlaybackState: (state: AnimeBrowserPlaybackState | null) =>
episodeList.setPlaybackState(state),
};
}
+51
View File
@@ -0,0 +1,51 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { nextPlaybackCue, playingEpisodeForAnime } from './episode-playback';
const selected = { sourceId: 'source.one', url: '/anime/one', title: 'One' };
test('playing episode sync applies only to the matching source and anime', () => {
assert.equal(
playingEpisodeForAnime(
{ sourceId: 'source.one', animeUrl: '/anime/one', episodeUrl: '/episode/3' },
selected,
),
'/episode/3',
);
assert.equal(
playingEpisodeForAnime(
{ sourceId: 'source.two', animeUrl: '/anime/one', episodeUrl: '/episode/3' },
selected,
),
null,
);
assert.equal(
playingEpisodeForAnime(
{ sourceId: 'source.one', animeUrl: '/anime/two', episodeUrl: '/episode/3' },
selected,
),
null,
);
assert.equal(playingEpisodeForAnime(null, selected), null);
});
test('live playback sync preserves a pending episode cue until resolution finishes', () => {
const loading = { url: '/episode/4', state: 'loading' as const };
assert.equal(
nextPlaybackCue(
{ sourceId: 'source.one', animeUrl: '/anime/one', episodeUrl: '/episode/3' },
selected,
loading,
),
loading,
);
assert.deepEqual(
nextPlaybackCue(
{ sourceId: 'source.one', animeUrl: '/anime/one', episodeUrl: '/episode/3' },
selected,
{ url: '/episode/2', state: 'playing' },
),
{ url: '/episode/3', state: 'playing' },
);
});
+33 -8
View File
@@ -5,7 +5,12 @@ import { filterEpisodes } from './episode-filter';
import { describeMarkCount, episodesInScope } from './episode-marks';
import { describeQueuePosition } from './episode-queue';
import { createEpisodeQueueControls } from './episode-queue-controls';
import type { AnimeBrowserAPI, AnimeBrowserEpisode } from '../types/anime-browser';
import { nextPlaybackCue, type EpisodePlaybackCue } from './episode-playback';
import type {
AnimeBrowserAPI,
AnimeBrowserEpisode,
AnimeBrowserPlaybackState,
} from '../types/anime-browser';
export interface SelectedAnime {
url: string;
@@ -47,7 +52,8 @@ export function createEpisodeList({ api, setStatus, selectedAnime }: EpisodeList
* Which episode is resolving or playing. Kept here rather than only on the
* button, so filtering mid-playback repaints the cue instead of dropping it.
*/
let cueState: { url: string; state: 'loading' | 'playing' } | null = null;
let cueState: EpisodePlaybackCue | null = null;
let activePlayback: AnimeBrowserPlaybackState | null = null;
const watchStateRequests = new LatestRequest();
/**
* Mark writes carry their own token: a background refresh starting mid-write
@@ -69,6 +75,11 @@ export function createEpisodeList({ api, setStatus, selectedAnime }: EpisodeList
}
}
function syncPlaybackCue(): void {
cueState = nextPlaybackCue(activePlayback, selectedAnime(), cueState);
applyCueState();
}
const queue = createEpisodeQueueControls({
api,
setStatus,
@@ -79,11 +90,12 @@ export function createEpisodeList({ api, setStatus, selectedAnime }: EpisodeList
// The queue started this one, so the cue this window was holding belongs
// to an episode that has finished. The new one only earns the cue when it
// is an episode of the anime on screen.
const anime = selectedAnime();
const mine =
anime !== null && anime.sourceId === entry.sourceId && anime.url === entry.animeUrl;
cueState = mine ? { url: entry.episodeUrl, state: 'playing' } : null;
applyCueState();
activePlayback = {
sourceId: entry.sourceId,
animeUrl: entry.animeUrl,
episodeUrl: entry.episodeUrl,
};
syncPlaybackCue();
setStatus(`Queue started ${entry.episodeName}.`, 'ok');
},
});
@@ -385,6 +397,7 @@ export function createEpisodeList({ api, setStatus, selectedAnime }: EpisodeList
displayIndex: list.length - index,
name: episode.name,
}));
syncPlaybackCue();
paint();
void refreshWatchState();
}
@@ -412,5 +425,17 @@ export function createEpisodeList({ api, setStatus, selectedAnime }: EpisodeList
});
window.addEventListener('focus', () => void refreshWatchState());
return { render, clear, refreshWatchState, setQueue: queue.setState };
function setPlaybackState(state: AnimeBrowserPlaybackState | null): void {
activePlayback = state;
syncPlaybackCue();
void refreshWatchState();
}
return {
render,
clear,
refreshWatchState,
setQueue: queue.setState,
setPlaybackState,
};
}
+36
View File
@@ -0,0 +1,36 @@
import type { AnimeBrowserPlaybackState } from '../types/anime-browser';
interface AnimeIdentity {
sourceId: string;
url: string;
}
export interface EpisodePlaybackCue {
url: string;
state: 'loading' | 'playing';
}
export function playingEpisodeForAnime(
playback: AnimeBrowserPlaybackState | null,
anime: AnimeIdentity | null,
): string | null {
if (
!playback ||
!anime ||
anime.sourceId !== playback.sourceId ||
anime.url !== playback.animeUrl
) {
return null;
}
return playback.episodeUrl;
}
export function nextPlaybackCue(
playback: AnimeBrowserPlaybackState | null,
anime: AnimeIdentity | null,
currentCue: EpisodePlaybackCue | null,
): EpisodePlaybackCue | null {
if (currentCue?.state === 'loading') return currentCue;
const episodeUrl = playingEpisodeForAnime(playback, anime);
return episodeUrl ? { url: episodeUrl, state: 'playing' } : null;
}