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