fix(anime): scope preferences, paginate sources, harden installs

- Preference store keys entries by extension package + bridge source id; legacy unscoped entries are discarded once instead of being handed to whichever extension asks first
- Source picker's "Load more" appends the next page without duplicating streamed results
- Repository index fetches and subtitle/APK downloads now time out and are size-bounded instead of hanging or growing unbounded
- APK installs are staged to a temp file and renamed into place
- Stream metadata lookup matches the requested path, not only the currently playing one
- Reworked animeui into browse-state/detail-panel/panels.css modules
- Reverted premature CHANGELOG unreleased entries; refreshed anime-browser docs
This commit is contained in:
2026-08-14 22:41:48 -07:00
parent 77328f61ee
commit 593656d54f
48 changed files with 2158 additions and 1288 deletions
+103 -185
View File
@@ -2,13 +2,23 @@ import { describe, el } from './dom';
import { sourceOptionLabel, summarizeSearch } from './format';
import { applySearchUpdate, idleSearchProgress, summarizeProgress } from './search-progress';
import { createExtensionsPanel } from './extensions-panel';
import { createDetailPanel } from './detail-panel';
import { renderPreferences, renderPreferencesUnavailable } from './preferences-fields';
import {
beginBrowse,
beginNextPage,
createBrowseState,
failBrowse,
finishBrowse,
soleBrowseRequest,
takeUnseenEntries,
} from './browse-state';
import type { BrowseRequest } from './browse-state';
import { ALL_SOURCES_ID } from '../types/anime-browser';
import type {
AnimeBrowserAPI,
AnimeBrowserBridgeState,
AnimeBrowserEntry,
AnimeBrowserEpisode,
AnimeBrowserSource,
} from '../types/anime-browser';
@@ -26,15 +36,7 @@ const searchButton = el<HTMLButtonElement>('search-button');
const sourceSelect = el<HTMLSelectElement>('source-select');
const grid = el<HTMLDivElement>('grid');
const gridEmpty = el<HTMLParagraphElement>('grid-empty');
const results = el<HTMLElement>('results');
const detail = el<HTMLElement>('detail');
const detailBack = el<HTMLButtonElement>('detail-back');
const detailCover = el<HTMLImageElement>('detail-cover');
const detailTitle = el<HTMLHeadingElement>('detail-title');
const detailChips = el<HTMLDivElement>('detail-chips');
const detailDescription = el<HTMLParagraphElement>('detail-description');
const episodes = el<HTMLOListElement>('episodes');
const episodesCount = el<HTMLSpanElement>('episodes-count');
const loadMoreButton = el<HTMLButtonElement>('load-more');
const banner = el<HTMLDivElement>('bridge-banner');
const bannerMessage = el<HTMLSpanElement>('bridge-message');
const bannerMeter = el<HTMLSpanElement>('bridge-meter');
@@ -49,11 +51,8 @@ const settingsPanel = el<HTMLElement>('settings');
const settingsFields = el<HTMLDivElement>('settings-fields');
const settingsTitle = el<HTMLHeadingElement>('settings-title');
/** The anime the detail page is showing, with the source that produced it. */
let selectedAnime: { url: string; title: string; sourceId: string } | null = null;
/** Where the results grid was scrolled to before the detail page covered it. */
let resultsScrollTop = 0;
/** Last source accepted by the main process, used to roll back a rejected change. */
let selectedSourceId: string | null = null;
/* ---------- tabs ---------- */
@@ -81,6 +80,8 @@ function setStatus(message: string, tone: 'info' | 'ok' | 'error' = 'info'): voi
statusMessage.parentElement?.setAttribute('data-tone', tone);
}
const detailPanel = createDetailPanel({ api, setStatus });
const BRIDGE_LABELS: Record<AnimeBrowserBridgeState['stage'], string> = {
idle: 'Starting the extension bridge',
locating: 'Looking up the extension bridge release',
@@ -150,6 +151,7 @@ function renderSources(sources: AnimeBrowserSource[], selectedId: string | null)
sourceSelect.replaceChildren(...options);
sourceSelect.disabled = options.length <= 1;
selectedSourceId = selectedId;
}
function searchingAllSources(): boolean {
@@ -195,17 +197,18 @@ function createCard(entry: AnimeBrowserEntry, showSource: boolean): HTMLButtonEl
card.append(art, title);
card.title = showSource ? `${entry.title}${entry.sourceName}` : entry.title;
card.addEventListener('click', () => {
void openDetail(entry);
void detailPanel.open(entry);
});
return card;
}
function renderEntries(entries: AnimeBrowserEntry[], emptyMessage: string): void {
// Which source a cover came from only matters when they are mixed together.
const showSource = searchingAllSources();
grid.replaceChildren(...entries.map((entry) => createCard(entry, showSource)));
seenEntries.clear();
grid.replaceChildren();
appendEntries(entries);
const empty = entries.length === 0;
const empty = grid.childElementCount === 0;
gridEmpty.classList.toggle('hidden', !empty);
gridEmpty.textContent = emptyMessage;
}
@@ -213,139 +216,9 @@ function renderEntries(entries: AnimeBrowserEntry[], emptyMessage: string): void
/** Streamed results land at the end of the grid, in arrival order. */
function appendEntries(entries: AnimeBrowserEntry[]): void {
const showSource = searchingAllSources();
grid.append(...entries.map((entry) => createCard(entry, showSource)));
}
function formatEpisodeIndex(episode: AnimeBrowserEpisode, fallbackIndex: number): string {
const value = episode.number ?? fallbackIndex;
return Number.isInteger(value) ? String(value).padStart(2, '0') : value.toFixed(1);
}
function renderEpisodes(list: AnimeBrowserEpisode[]): void {
episodesCount.textContent = list.length === 0 ? '' : `${list.length}`;
episodes.replaceChildren(
...list.map((episode, index) => {
const item = document.createElement('li');
const button = document.createElement('button');
button.type = 'button';
button.className = 'cue';
const cueIndex = document.createElement('span');
cueIndex.className = 'cue-index';
cueIndex.textContent = formatEpisodeIndex(episode, list.length - index);
const name = document.createElement('span');
name.className = 'cue-name';
name.textContent = episode.name;
if (episode.uploadedAt !== null) {
const sub = document.createElement('span');
sub.className = 'cue-sub';
sub.textContent = new Date(episode.uploadedAt).toISOString().slice(0, 10);
name.append(sub);
}
button.append(cueIndex, name);
button.addEventListener('click', () => {
void playEpisode(button, episode);
});
item.append(button);
return item;
}),
);
}
/**
* The detail page replaces the results grid rather than squeezing in beside
* it. The grid stays in the DOM with its scroll position remembered, so Back
* returns to the same results without re-running the search.
*/
async function openDetail(entry: AnimeBrowserEntry): Promise<void> {
selectedAnime = { url: entry.url, title: entry.title, sourceId: entry.sourceId };
resultsScrollTop = results.scrollTop;
results.classList.add('hidden');
detail.classList.remove('hidden');
detail.scrollTop = 0;
detailTitle.textContent = entry.title;
detailDescription.textContent = 'Loading…';
detailChips.replaceChildren();
episodes.replaceChildren();
episodesCount.textContent = '';
detailCover.src = entry.thumbnailUrl ?? '';
try {
// Always ask the entry's own source: after an all-sources search the
// picker's selection says nothing about where this cover came from.
const [details, episodeList] = await Promise.all([
api.getDetails(entry.url, entry.sourceId),
api.getEpisodes(entry.url, entry.sourceId),
]);
detailTitle.textContent = details.title;
detailDescription.textContent = details.description ?? 'No description from this source.';
if (details.thumbnailUrl) detailCover.src = details.thumbnailUrl;
const chips: HTMLSpanElement[] = [];
const source = document.createElement('span');
source.className = 'chip source';
source.textContent = entry.sourceName;
chips.push(source);
if (details.status !== 'unknown') {
const status = document.createElement('span');
status.className = 'chip status';
status.textContent = details.status.replace(/-/g, ' ');
chips.push(status);
}
for (const genre of details.genres.slice(0, 6)) {
const chip = document.createElement('span');
chip.className = 'chip';
chip.textContent = genre;
chips.push(chip);
}
detailChips.replaceChildren(...chips);
renderEpisodes(episodeList);
setStatus(`${details.title} · ${episodeList.length} episodes`);
} catch (error) {
detailDescription.textContent = '';
setStatus(describe(error), 'error');
}
}
function closeDetail(): void {
detail.classList.add('hidden');
results.classList.remove('hidden');
results.scrollTop = resultsScrollTop;
selectedAnime = null;
}
async function playEpisode(button: HTMLButtonElement, episode: AnimeBrowserEpisode): Promise<void> {
if (!selectedAnime) return;
for (const other of episodes.querySelectorAll<HTMLButtonElement>('.cue')) {
other.removeAttribute('data-state');
}
button.dataset.state = 'loading';
setStatus(`Resolving ${episode.name}`);
const result = await api.playEpisode({
sourceId: selectedAnime.sourceId,
animeUrl: selectedAnime.url,
animeTitle: selectedAnime.title,
episodeUrl: episode.url,
episodeName: episode.name,
episodeNumber: episode.number,
});
if (result.ok) {
button.dataset.state = 'playing';
setStatus(
result.quality ? `Playing ${episode.name} · ${result.quality}` : `Playing ${episode.name}`,
'ok',
);
} else {
button.removeAttribute('data-state');
setStatus(result.error ?? 'Could not play that episode.', 'error');
}
const unseen = takeUnseenEntries(entries, seenEntries);
grid.append(...unseen.map((entry) => createCard(entry, showSource)));
if (unseen.length > 0) gridEmpty.classList.add('hidden');
}
/* ---------- streamed search ---------- */
@@ -358,8 +231,17 @@ async function playEpisode(button: HTMLButtonElement, episode: AnimeBrowserEpiso
*/
let progress = idleSearchProgress();
/** Orders runSearch calls so a slow search cannot finish over a newer one. */
let searchRequest = 0;
let browseState = createBrowseState();
const inFlightBrowses = new Map<number, BrowseRequest>();
let activeStreamRequestId = 0;
const seenEntries = new Set<string>();
function renderLoadMore(): void {
const loadingNextPage = browseState.loading && browseState.page > 1;
loadMoreButton.classList.toggle('hidden', !browseState.hasNextPage && !loadingNextPage);
loadMoreButton.disabled = browseState.loading;
loadMoreButton.textContent = loadingNextPage ? 'Loading…' : 'Load more';
}
api.onSearchUpdate((update) => {
const applied = applySearchUpdate(progress, update);
@@ -367,53 +249,88 @@ api.onSearchUpdate((update) => {
progress = applied.progress;
if (applied.started) {
grid.replaceChildren();
gridEmpty.classList.add('hidden');
// The runtime token does not carry the renderer request id. When calls
// overlap their starts may arrive in either order, so stream only when the
// association is unambiguous; the final response still backfills the grid.
const request = soleBrowseRequest(inFlightBrowses);
activeStreamRequestId = request?.id ?? 0;
const append = request?.append === true;
if (!append) {
seenEntries.clear();
grid.replaceChildren();
gridEmpty.classList.add('hidden');
}
return;
}
if (activeStreamRequestId !== browseState.requestId) return;
if (applied.entries.length > 0) appendEntries(applied.entries);
if (!progress.done) {
setStatus(summarizeProgress(progress), progress.failures.length > 0 ? 'error' : 'info');
}
});
async function runSearch(query: string): Promise<void> {
const request = ++searchRequest;
// A new search means new results; leave the detail page for them.
if (!detail.classList.contains('hidden')) closeDetail();
setStatus(query ? `Searching for “${query}”…` : 'Loading popular…');
grid.replaceChildren();
gridEmpty.classList.add('hidden');
async function runBrowse(request: BrowseRequest): Promise<void> {
inFlightBrowses.set(request.id, request);
renderLoadMore();
try {
const result = query ? await api.search(query) : await api.getPopular();
// A newer search owns the grid now; this one's result is history.
if (request !== searchRequest) return;
const result = request.query
? await api.search(request.query, request.page)
: await api.getPopular(request.page);
if (request.id !== browseState.requestId) return;
browseState = finishBrowse(browseState, request.id, result.hasNextPage);
renderLoadMore();
// Every source failing is an error, not an empty result set.
if (result.entries.length === 0 && result.failures.length > 0) {
const first = result.failures[0];
renderEntries([], `${first?.sourceName}: ${first?.error}`);
if (!request.append) renderEntries([], `${first?.sourceName}: ${first?.error}`);
setStatus(summarizeSearch(result), 'error');
return;
}
// The stream already filled the grid; only render from the result when no
// update arrived (covers a host without streaming wired up).
if (grid.childElementCount === 0 || result.entries.length === 0) {
// The final response backfills a host without streaming. Entries already
// pushed by the stream are filtered out, including sources that repeat a
// final page while another source still has more.
appendEntries(result.entries);
if (!request.append && grid.childElementCount === 0) {
renderEntries(
result.entries,
query ? `Nothing found for “${query}”.` : 'This source returned nothing.',
[],
request.query ? `Nothing found for “${request.query}”.` : 'This source returned nothing.',
);
}
setStatus(summarizeSearch(result), result.failures.length > 0 ? 'error' : 'info');
} catch (error) {
if (request !== searchRequest) return;
renderEntries([], describe(error));
if (request.id !== browseState.requestId) return;
browseState = failBrowse(browseState, request);
renderLoadMore();
if (!request.append) renderEntries([], describe(error));
setStatus(describe(error), 'error');
} finally {
inFlightBrowses.delete(request.id);
}
}
async function runSearch(query: string): Promise<void> {
const started = beginBrowse(browseState, query);
browseState = started.state;
// A new search means new results; leave the detail page for them.
if (detailPanel.isOpen()) detailPanel.close();
setStatus(query ? `Searching for “${query}”…` : 'Loading popular…');
seenEntries.clear();
grid.replaceChildren();
gridEmpty.classList.add('hidden');
await runBrowse(started.request);
}
async function loadNextPage(): Promise<void> {
const started = beginNextPage(browseState);
if (!started) return;
browseState = started.state;
setStatus(`Loading page ${started.request.page}`);
await runBrowse(started.request);
}
/* ---------- source settings ---------- */
async function openSettings(): Promise<void> {
@@ -470,20 +387,21 @@ searchForm.addEventListener('submit', (event) => {
sourceSelect.addEventListener('change', () => {
void (async () => {
await api.selectSource(sourceSelect.value);
// Settings belong to the source, so reload them rather than showing stale fields.
if (currentView === 'settings') await openSettings();
await runSearch(searchInput.value.trim());
const requestedSourceId = sourceSelect.value;
try {
await api.selectSource(requestedSourceId);
selectedSourceId = requestedSourceId;
// Settings belong to the source, so reload them rather than showing stale fields.
if (currentView === 'settings') await openSettings();
await runSearch(searchInput.value.trim());
} catch (error) {
if (selectedSourceId !== null) sourceSelect.value = selectedSourceId;
setStatus(describe(error), 'error');
}
})();
});
detailBack.addEventListener('click', closeDetail);
document.addEventListener('keydown', (event) => {
if (event.key === 'Escape' && !detail.classList.contains('hidden')) {
closeDetail();
}
});
loadMoreButton.addEventListener('click', () => void loadNextPage());
api.onBridgeState(renderBridgeState);
+114
View File
@@ -0,0 +1,114 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
beginBrowse,
beginNextPage,
capture,
createBrowseState,
failBrowse,
finishBrowse,
LatestRequest,
safeUploadDate,
soleBrowseRequest,
takeUnseenEntries,
} from './browse-state';
import type { AnimeBrowserEntry } from '../types/anime-browser';
test('a new browse replaces results and starts from page one', () => {
const started = beginBrowse(createBrowseState(), 'frieren');
assert.deepEqual(started.request, {
id: 1,
query: 'frieren',
page: 1,
append: false,
});
assert.equal(started.state.loading, true);
});
test('the next page appends only when the current result has another page', () => {
const first = beginBrowse(createBrowseState(), '');
const ready = finishBrowse(first.state, first.request.id, true);
const next = beginNextPage(ready);
assert.ok(next);
assert.deepEqual(next.request, {
id: 2,
query: '',
page: 2,
append: true,
});
assert.equal(beginNextPage(next.state), null, 'cannot overlap page requests');
assert.equal(beginNextPage(finishBrowse(next.state, next.request.id, false)), null);
});
test('a stale page completion cannot change the active browse state', () => {
const first = beginBrowse(createBrowseState(), 'old');
const current = beginBrowse(first.state, 'new');
assert.equal(finishBrowse(current.state, first.request.id, true), current.state);
});
test('stream updates are correlated only when one browse request is in flight', () => {
const first = beginBrowse(createBrowseState(), 'old');
const second = beginBrowse(first.state, 'new');
const inFlight = new Map([
[second.request.id, second.request],
[first.request.id, first.request],
]);
assert.equal(soleBrowseRequest(inFlight), null, 'start order is ambiguous while calls overlap');
inFlight.delete(first.request.id);
assert.equal(soleBrowseRequest(inFlight), second.request);
});
test('a failed next page remains retryable', () => {
const first = beginBrowse(createBrowseState(), '');
const ready = finishBrowse(first.state, first.request.id, true);
const next = beginNextPage(ready)!;
const failed = failBrowse(next.state, next.request);
const retry = beginNextPage(failed);
assert.ok(retry);
assert.equal(retry.request.page, 2);
});
test('latest request tokens invalidate closed and superseded details', () => {
const requests = new LatestRequest();
const first = requests.begin();
const second = requests.begin();
assert.equal(requests.isCurrent(first), false);
assert.equal(requests.isCurrent(second), true);
requests.cancel();
assert.equal(requests.isCurrent(second), false);
});
test('safeUploadDate ignores malformed timestamps', () => {
assert.equal(safeUploadDate(Date.UTC(2025, 3, 2)), '2025-04-02');
assert.equal(safeUploadDate(Number.NaN), null);
assert.equal(safeUploadDate(Number.POSITIVE_INFINITY), null);
});
test('capture turns a rejected IPC operation into a displayable failure', async () => {
const failure = new Error('bridge disconnected');
const result = await capture(async () => Promise.reject(failure));
assert.deepEqual(result, { ok: false, error: failure });
});
test('takeUnseenEntries deduplicates streamed and final-page entries', () => {
const entry = (sourceId: string, url: string): AnimeBrowserEntry => ({
sourceId,
sourceName: sourceId,
url,
title: url,
thumbnailUrl: null,
});
const seen = new Set<string>();
assert.deepEqual(takeUnseenEntries([entry('one', '/a')], seen), [entry('one', '/a')]);
assert.deepEqual(takeUnseenEntries([entry('one', '/a'), entry('two', '/a')], seen), [
entry('two', '/a'),
]);
});
+133
View File
@@ -0,0 +1,133 @@
import type { AnimeBrowserEntry } from '../types/anime-browser';
export interface BrowseState {
requestId: number;
query: string;
page: number;
loading: boolean;
hasNextPage: boolean;
}
export interface BrowseRequest {
id: number;
query: string;
page: number;
append: boolean;
}
export interface StartedBrowse {
state: BrowseState;
request: BrowseRequest;
}
export function createBrowseState(): BrowseState {
return { requestId: 0, query: '', page: 0, loading: false, hasNextPage: false };
}
export function beginBrowse(state: BrowseState, query: string): StartedBrowse {
const request: BrowseRequest = {
id: state.requestId + 1,
query,
page: 1,
append: false,
};
return {
state: {
requestId: request.id,
query,
page: request.page,
loading: true,
hasNextPage: false,
},
request,
};
}
export function beginNextPage(state: BrowseState): StartedBrowse | null {
if (state.loading || !state.hasNextPage) return null;
const request: BrowseRequest = {
id: state.requestId + 1,
query: state.query,
page: state.page + 1,
append: true,
};
return {
state: {
...state,
requestId: request.id,
page: request.page,
loading: true,
hasNextPage: false,
},
request,
};
}
export function soleBrowseRequest(
inFlight: ReadonlyMap<number, BrowseRequest>,
): BrowseRequest | null {
if (inFlight.size !== 1) return null;
return inFlight.values().next().value ?? null;
}
export function finishBrowse(
state: BrowseState,
requestId: number,
hasNextPage: boolean,
): BrowseState {
if (requestId !== state.requestId) return state;
return { ...state, loading: false, hasNextPage };
}
export function failBrowse(state: BrowseState, request: BrowseRequest): BrowseState {
if (request.id !== state.requestId) return state;
return {
...state,
page: request.append ? request.page - 1 : request.page,
loading: false,
hasNextPage: request.append,
};
}
export class LatestRequest {
private current = 0;
begin(): number {
return ++this.current;
}
cancel(): void {
this.current += 1;
}
isCurrent(request: number): boolean {
return request === this.current;
}
}
export function safeUploadDate(uploadedAt: number): string | null {
const date = new Date(uploadedAt);
return Number.isFinite(date.getTime()) ? date.toISOString().slice(0, 10) : null;
}
export type Captured<T> = { ok: true; value: T } | { ok: false; error: unknown };
export async function capture<T>(operation: () => Promise<T>): Promise<Captured<T>> {
try {
return { ok: true, value: await operation() };
} catch (error) {
return { ok: false, error };
}
}
export function takeUnseenEntries(
entries: AnimeBrowserEntry[],
seen: Set<string>,
): AnimeBrowserEntry[] {
return entries.filter((entry) => {
const key = `${entry.sourceId}\0${entry.url}`;
if (seen.has(key)) return false;
seen.add(key);
return true;
});
}
+182
View File
@@ -0,0 +1,182 @@
import { capture, LatestRequest, safeUploadDate } from './browse-state';
import { describe, el } from './dom';
import type {
AnimeBrowserAPI,
AnimeBrowserEntry,
AnimeBrowserEpisode,
} from '../types/anime-browser';
interface DetailPanelOptions {
api: AnimeBrowserAPI;
setStatus: (message: string, tone?: 'info' | 'ok' | 'error') => void;
}
export function createDetailPanel({ api, setStatus }: DetailPanelOptions) {
const results = el<HTMLElement>('results');
const detail = el<HTMLElement>('detail');
const detailBack = el<HTMLButtonElement>('detail-back');
const detailCover = el<HTMLImageElement>('detail-cover');
const detailTitle = el<HTMLHeadingElement>('detail-title');
const detailChips = el<HTMLDivElement>('detail-chips');
const detailDescription = el<HTMLParagraphElement>('detail-description');
const episodes = el<HTMLOListElement>('episodes');
const episodesCount = el<HTMLSpanElement>('episodes-count');
let selectedAnime: { url: string; title: string; sourceId: string } | null = null;
let resultsScrollTop = 0;
const requests = new LatestRequest();
function formatEpisodeIndex(episode: AnimeBrowserEpisode, fallbackIndex: number): string {
const value = episode.number ?? fallbackIndex;
return Number.isInteger(value) ? String(value).padStart(2, '0') : value.toFixed(1);
}
async function playEpisode(
button: HTMLButtonElement,
episode: AnimeBrowserEpisode,
): Promise<void> {
const anime = selectedAnime;
if (!anime) return;
for (const other of episodes.querySelectorAll<HTMLButtonElement>('.cue')) {
other.removeAttribute('data-state');
}
button.dataset.state = 'loading';
setStatus(`Resolving ${episode.name}`);
const attempt = await capture(() =>
api.playEpisode({
sourceId: anime.sourceId,
animeUrl: anime.url,
animeTitle: anime.title,
episodeUrl: episode.url,
episodeName: episode.name,
episodeNumber: episode.number,
}),
);
if (!attempt.ok) {
button.removeAttribute('data-state');
setStatus(describe(attempt.error), 'error');
return;
}
const result = attempt.value;
if (result.ok) {
button.dataset.state = 'playing';
setStatus(
result.quality ? `Playing ${episode.name} · ${result.quality}` : `Playing ${episode.name}`,
'ok',
);
} else {
button.removeAttribute('data-state');
setStatus(result.error ?? 'Could not play that episode.', 'error');
}
}
function renderEpisodes(list: AnimeBrowserEpisode[]): void {
episodesCount.textContent = list.length === 0 ? '' : `${list.length}`;
episodes.replaceChildren(
...list.map((episode, index) => {
const item = document.createElement('li');
const button = document.createElement('button');
button.type = 'button';
button.className = 'cue';
const cueIndex = document.createElement('span');
cueIndex.className = 'cue-index';
cueIndex.textContent = formatEpisodeIndex(episode, list.length - index);
const name = document.createElement('span');
name.className = 'cue-name';
name.textContent = episode.name;
if (episode.uploadedAt !== null) {
const uploaded = safeUploadDate(episode.uploadedAt);
if (uploaded) {
const sub = document.createElement('span');
sub.className = 'cue-sub';
sub.textContent = uploaded;
name.append(sub);
}
}
button.append(cueIndex, name);
button.addEventListener('click', () => void playEpisode(button, episode));
item.append(button);
return item;
}),
);
}
async function open(entry: AnimeBrowserEntry): Promise<void> {
const request = requests.begin();
selectedAnime = { url: entry.url, title: entry.title, sourceId: entry.sourceId };
resultsScrollTop = results.scrollTop;
results.classList.add('hidden');
detail.classList.remove('hidden');
detail.scrollTop = 0;
detailTitle.textContent = entry.title;
detailDescription.textContent = 'Loading…';
detailChips.replaceChildren();
episodes.replaceChildren();
episodesCount.textContent = '';
detailCover.src = entry.thumbnailUrl ?? '';
try {
const [details, episodeList] = await Promise.all([
api.getDetails(entry.url, entry.sourceId),
api.getEpisodes(entry.url, entry.sourceId),
]);
if (!requests.isCurrent(request)) return;
detailTitle.textContent = details.title;
detailDescription.textContent = details.description ?? 'No description from this source.';
if (details.thumbnailUrl) detailCover.src = details.thumbnailUrl;
const chips: HTMLSpanElement[] = [];
const source = document.createElement('span');
source.className = 'chip source';
source.textContent = entry.sourceName;
chips.push(source);
if (details.status !== 'unknown') {
const status = document.createElement('span');
status.className = 'chip status';
status.textContent = details.status.replace(/-/g, ' ');
chips.push(status);
}
for (const genre of details.genres.slice(0, 6)) {
const chip = document.createElement('span');
chip.className = 'chip';
chip.textContent = genre;
chips.push(chip);
}
detailChips.replaceChildren(...chips);
renderEpisodes(episodeList);
setStatus(`${details.title} · ${episodeList.length} episodes`);
} catch (error) {
if (!requests.isCurrent(request)) return;
detailDescription.textContent = '';
setStatus(describe(error), 'error');
}
}
function close(): void {
requests.cancel();
detail.classList.add('hidden');
results.classList.remove('hidden');
results.scrollTop = resultsScrollTop;
selectedAnime = null;
}
detailBack.addEventListener('click', close);
document.addEventListener('keydown', (event) => {
if (event.key === 'Escape' && !detail.classList.contains('hidden')) close();
});
return {
open,
close,
isOpen: (): boolean => !detail.classList.contains('hidden'),
};
}
+272
View File
@@ -0,0 +1,272 @@
/* ---------- detail page ---------- */
/*
* The detail view is a page, not a sidebar: it takes over the whole content
* region while the results grid waits, hidden, behind the Back button.
*/
.detail {
flex: 1 1 auto;
min-width: 0;
background: var(--panel);
overflow-y: auto;
padding: 20px clamp(20px, 5vw, 56px) 32px;
animation: detail-in 0.28s ease both;
}
@keyframes detail-in {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.detail-body {
max-width: 1040px;
margin: 0 auto;
}
.detail-back {
padding: 6px 14px;
font-size: 12px;
margin-bottom: 20px;
}
.detail-head {
display: flex;
gap: clamp(18px, 3vw, 36px);
margin-bottom: 28px;
}
.detail-cover {
flex: none;
align-self: flex-start;
width: clamp(140px, 18vw, 232px);
height: auto;
border-radius: 12px;
border: 1px solid var(--line);
background: var(--ctp-crust);
box-shadow: 0 18px 40px -24px var(--shadow);
}
.detail-title {
margin: 0 0 10px;
font-size: clamp(19px, 2.4vw, 27px);
font-weight: 600;
line-height: 1.25;
}
.detail-chips {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-bottom: 10px;
}
.chip {
padding: 2px 8px;
border-radius: 999px;
border: 1px solid var(--line);
font-size: 11px;
color: var(--muted);
}
.chip.status {
border-color: rgba(166, 218, 149, 0.4);
color: var(--ok);
}
/* Which extension answered — the one chip that is always present. */
.chip.source {
border-color: color-mix(in srgb, var(--accent) 40%, transparent);
color: var(--accent);
}
.detail-description {
margin: 0;
font-size: 13px;
max-width: 72ch;
color: var(--muted);
white-space: pre-wrap;
}
.episodes-head {
display: flex;
align-items: baseline;
justify-content: space-between;
margin-bottom: 10px;
padding-bottom: 8px;
border-bottom: 1px solid var(--line);
}
.episodes-title {
margin: 0;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.18em;
text-transform: uppercase;
color: var(--faint);
}
.episodes-count {
font-family: var(--mono);
font-size: 11px;
color: var(--faint);
}
/*
* The cue rail: episodes read as subtitle cues on a timeline, because that is
* what they are about to become. The rail is the spine, the index is the cue
* number, and the title is the cue text.
*/
.cue-rail {
list-style: none;
margin: 0;
padding: 0 0 0 2px;
position: relative;
}
.cue-rail::before {
content: '';
position: absolute;
left: 48px;
top: 6px;
bottom: 6px;
width: 1px;
background: var(--line);
}
.cue {
position: relative;
display: grid;
grid-template-columns: 34px 1fr;
gap: 26px;
align-items: baseline;
width: 100%;
padding: 9px 10px 9px 0;
border: none;
border-radius: 8px;
background: none;
color: inherit;
font: inherit;
text-align: left;
cursor: pointer;
transition: background 0.14s ease;
}
.cue::after {
content: '';
position: absolute;
left: 43px;
top: 15px;
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--ctp-surface0);
border: 1px solid var(--line);
transition:
background 0.14s ease,
border-color 0.14s ease;
}
.cue:hover,
.cue:focus-visible {
background: var(--panel-elevated);
}
.cue:hover::after,
.cue:focus-visible::after {
background: var(--mine);
border-color: var(--mine);
}
.cue-index {
font-family: var(--mono);
font-size: 12px;
color: var(--faint);
text-align: right;
font-variant-numeric: tabular-nums;
}
.cue-name {
font-size: 13px;
line-height: 1.4;
}
.cue-sub {
display: block;
font-family: var(--mono);
font-size: 11px;
color: var(--faint);
margin-top: 2px;
}
.cue[data-state='loading'] {
background: var(--panel-elevated);
}
.cue[data-state='loading']::after {
background: var(--accent);
border-color: var(--accent);
animation: pulse 1.1s ease-in-out infinite;
}
.cue[data-state='playing']::after {
background: var(--mine);
border-color: var(--mine);
}
/* ---------- status bar ---------- */
.statusbar {
flex: none;
padding: 7px 20px;
border-top: 1px solid var(--line);
background: var(--ctp-mantle);
font-size: 12px;
color: var(--faint);
min-height: 30px;
}
.statusbar[data-tone='error'] {
color: var(--danger);
}
.statusbar[data-tone='ok'] {
color: var(--ok);
}
/* ---------- scrollbars ---------- */
::-webkit-scrollbar {
width: 10px;
height: 10px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: var(--ctp-surface0);
border-radius: 6px;
border: 2px solid var(--bg);
}
::-webkit-scrollbar-thumb:hover {
background: var(--faint);
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
+8 -2
View File
@@ -186,8 +186,14 @@ export function createExtensionsPanel(options: ExtensionsPanelOptions) {
{
label: 'Remove',
onClick: async () => {
await api.removeRepo(repoUrl);
await refresh();
setStatus('Removing repository…');
try {
await api.removeRepo(repoUrl);
await refresh();
setStatus('Repository removed', 'ok');
} catch (error) {
setStatus(describe(error), 'error');
}
},
},
],
+5
View File
@@ -8,6 +8,8 @@
/>
<title>SubMiner Anime</title>
<link rel="stylesheet" href="./style.css" />
<link rel="stylesheet" href="./detail.css" />
<link rel="stylesheet" href="./panels.css" />
</head>
<body>
<header class="topbar">
@@ -126,6 +128,9 @@
<section class="results" id="results" aria-label="Results">
<div class="grid" id="grid"></div>
<p class="empty hidden" id="grid-empty"></p>
<button class="ghost-button load-more hidden" id="load-more" type="button">
Load more
</button>
</section>
<section class="detail hidden" id="detail" aria-label="Details">
+272
View File
@@ -0,0 +1,272 @@
/* ---------- source settings ---------- */
.settings {
/* A tab panel owns the whole content region; only one is ever visible. */
flex: 1 1 0;
min-height: 0;
overflow-y: auto;
padding: 16px 20px 18px;
background: var(--panel-elevated);
}
.settings-head {
display: flex;
align-items: baseline;
gap: 12px;
margin-bottom: 12px;
}
.settings-title {
margin: 0;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.18em;
text-transform: uppercase;
color: var(--faint);
}
.settings-note {
font-size: 12px;
color: var(--faint);
}
.settings-fields {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 14px 20px;
}
.field {
display: flex;
flex-direction: column;
gap: 5px;
min-width: 0;
}
.field-label {
font-size: 12px;
font-weight: 600;
}
.field-summary {
font-size: 11px;
color: var(--faint);
line-height: 1.4;
}
.field-row {
display: flex;
align-items: center;
gap: 8px;
}
.field-row .text-input {
flex: 1 1 auto;
min-width: 0;
}
.field-multi {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.field-check {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: var(--muted);
cursor: pointer;
}
.field-state {
font-size: 11px;
color: var(--faint);
min-height: 14px;
}
.field-state[data-tone='ok'] {
color: var(--ok);
}
.field-state[data-tone='error'] {
color: var(--danger);
}
/* ---------- extensions panel ---------- */
.repo-add {
display: flex;
gap: 8px;
margin-bottom: 8px;
}
.repo-add .text-input {
flex: 1 1 auto;
}
.repo-hint {
margin: 0 0 14px;
font-size: 12px;
color: var(--faint);
max-width: 78ch;
}
.repo-hint code {
font-family: var(--mono);
font-size: 11px;
}
.ext-group-title {
display: flex;
align-items: baseline;
gap: 8px;
margin: 0 0 8px;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.18em;
text-transform: uppercase;
color: var(--faint);
}
/* A rule between the groups, but not above the first one. */
.ext-list + .ext-group-title {
margin-top: 6px;
padding-top: 14px;
border-top: 1px solid var(--line);
}
.ext-group-count {
font-size: 11px;
letter-spacing: 0.06em;
color: var(--muted);
}
.ext-group-count:empty {
display: none;
}
.lang-filter {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.lang-filter:not(:empty) {
margin-bottom: 10px;
}
.lang-chip {
padding: 4px 11px;
border: 1px solid var(--line);
border-radius: 999px;
background: var(--ctp-crust);
color: var(--muted);
font: inherit;
font-size: 11px;
letter-spacing: 0.04em;
cursor: pointer;
transition:
color 120ms ease,
border-color 120ms ease;
}
.lang-chip:hover {
color: var(--text);
border-color: var(--accent);
}
.lang-chip.is-active {
border-color: var(--accent);
color: var(--accent);
}
.lang-chip:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.ext-list {
display: flex;
flex-direction: column;
gap: 6px;
}
.ext-list:not(:empty) {
margin-bottom: 14px;
}
.ext-row {
display: flex;
align-items: center;
gap: 12px;
padding: 9px 12px;
border: 1px solid var(--line);
border-radius: 10px;
background: var(--ctp-crust);
}
.ext-row.is-error {
border-color: rgba(237, 135, 150, 0.45);
}
.ext-main {
flex: 1 1 auto;
min-width: 0;
}
.ext-name {
font-size: 13px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ext-sub {
font-family: var(--mono);
font-size: 11px;
color: var(--faint);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ext-row.is-error .ext-sub {
color: var(--danger);
white-space: normal;
}
.ext-tag {
flex: none;
padding: 2px 8px;
border-radius: 999px;
border: 1px solid var(--line);
font-size: 10px;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--faint);
}
.ext-tag.installed {
border-color: rgba(166, 218, 149, 0.4);
color: var(--ok);
}
.ext-tag.nsfw {
border-color: rgba(237, 135, 150, 0.4);
color: var(--danger);
}
.ext-row .ghost-button,
.ext-row .primary-button {
flex: none;
padding: 5px 12px;
font-size: 12px;
}
.ext-empty {
padding: 14px 2px;
font-size: 13px;
color: var(--muted);
}
+9 -546
View File
@@ -315,6 +315,15 @@ body {
color: var(--muted);
}
.load-more {
display: block;
margin: 24px auto 4px;
}
.load-more.hidden {
display: none;
}
/* ---------- cover cards ---------- */
.card {
@@ -406,549 +415,3 @@ body {
-webkit-box-orient: vertical;
overflow: hidden;
}
/* ---------- detail page ---------- */
/*
* The detail view is a page, not a sidebar: it takes over the whole content
* region while the results grid waits, hidden, behind the Back button.
*/
.detail {
flex: 1 1 auto;
min-width: 0;
background: var(--panel);
overflow-y: auto;
padding: 20px clamp(20px, 5vw, 56px) 32px;
animation: detail-in 0.28s ease both;
}
@keyframes detail-in {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.detail-body {
max-width: 1040px;
margin: 0 auto;
}
.detail-back {
padding: 6px 14px;
font-size: 12px;
margin-bottom: 20px;
}
.detail-head {
display: flex;
gap: clamp(18px, 3vw, 36px);
margin-bottom: 28px;
}
.detail-cover {
flex: none;
align-self: flex-start;
width: clamp(140px, 18vw, 232px);
height: auto;
border-radius: 12px;
border: 1px solid var(--line);
background: var(--ctp-crust);
box-shadow: 0 18px 40px -24px var(--shadow);
}
.detail-title {
margin: 0 0 10px;
font-size: clamp(19px, 2.4vw, 27px);
font-weight: 600;
line-height: 1.25;
}
.detail-chips {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-bottom: 10px;
}
.chip {
padding: 2px 8px;
border-radius: 999px;
border: 1px solid var(--line);
font-size: 11px;
color: var(--muted);
}
.chip.status {
border-color: rgba(166, 218, 149, 0.4);
color: var(--ok);
}
/* Which extension answered — the one chip that is always present. */
.chip.source {
border-color: color-mix(in srgb, var(--accent) 40%, transparent);
color: var(--accent);
}
.detail-description {
margin: 0;
font-size: 13px;
max-width: 72ch;
color: var(--muted);
white-space: pre-wrap;
}
.episodes-head {
display: flex;
align-items: baseline;
justify-content: space-between;
margin-bottom: 10px;
padding-bottom: 8px;
border-bottom: 1px solid var(--line);
}
.episodes-title {
margin: 0;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.18em;
text-transform: uppercase;
color: var(--faint);
}
.episodes-count {
font-family: var(--mono);
font-size: 11px;
color: var(--faint);
}
/*
* The cue rail: episodes read as subtitle cues on a timeline, because that is
* what they are about to become. The rail is the spine, the index is the cue
* number, and the title is the cue text.
*/
.cue-rail {
list-style: none;
margin: 0;
padding: 0 0 0 2px;
position: relative;
}
.cue-rail::before {
content: '';
position: absolute;
left: 48px;
top: 6px;
bottom: 6px;
width: 1px;
background: var(--line);
}
.cue {
position: relative;
display: grid;
grid-template-columns: 34px 1fr;
gap: 26px;
align-items: baseline;
width: 100%;
padding: 9px 10px 9px 0;
border: none;
border-radius: 8px;
background: none;
color: inherit;
font: inherit;
text-align: left;
cursor: pointer;
transition: background 0.14s ease;
}
.cue::after {
content: '';
position: absolute;
left: 43px;
top: 15px;
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--ctp-surface0);
border: 1px solid var(--line);
transition:
background 0.14s ease,
border-color 0.14s ease;
}
.cue:hover,
.cue:focus-visible {
background: var(--panel-elevated);
}
.cue:hover::after,
.cue:focus-visible::after {
background: var(--mine);
border-color: var(--mine);
}
.cue-index {
font-family: var(--mono);
font-size: 12px;
color: var(--faint);
text-align: right;
font-variant-numeric: tabular-nums;
}
.cue-name {
font-size: 13px;
line-height: 1.4;
}
.cue-sub {
display: block;
font-family: var(--mono);
font-size: 11px;
color: var(--faint);
margin-top: 2px;
}
.cue[data-state='loading'] {
background: var(--panel-elevated);
}
.cue[data-state='loading']::after {
background: var(--accent);
border-color: var(--accent);
animation: pulse 1.1s ease-in-out infinite;
}
.cue[data-state='playing']::after {
background: var(--mine);
border-color: var(--mine);
}
/* ---------- status bar ---------- */
.statusbar {
flex: none;
padding: 7px 20px;
border-top: 1px solid var(--line);
background: var(--ctp-mantle);
font-size: 12px;
color: var(--faint);
min-height: 30px;
}
.statusbar[data-tone='error'] {
color: var(--danger);
}
.statusbar[data-tone='ok'] {
color: var(--ok);
}
/* ---------- scrollbars ---------- */
::-webkit-scrollbar {
width: 10px;
height: 10px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: var(--ctp-surface0);
border-radius: 6px;
border: 2px solid var(--bg);
}
::-webkit-scrollbar-thumb:hover {
background: var(--faint);
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
/* ---------- source settings ---------- */
.settings {
/* A tab panel owns the whole content region; only one is ever visible. */
flex: 1 1 0;
min-height: 0;
overflow-y: auto;
padding: 16px 20px 18px;
background: var(--panel-elevated);
}
.settings-head {
display: flex;
align-items: baseline;
gap: 12px;
margin-bottom: 12px;
}
.settings-title {
margin: 0;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.18em;
text-transform: uppercase;
color: var(--faint);
}
.settings-note {
font-size: 12px;
color: var(--faint);
}
.settings-fields {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 14px 20px;
}
.field {
display: flex;
flex-direction: column;
gap: 5px;
min-width: 0;
}
.field-label {
font-size: 12px;
font-weight: 600;
}
.field-summary {
font-size: 11px;
color: var(--faint);
line-height: 1.4;
}
.field-row {
display: flex;
align-items: center;
gap: 8px;
}
.field-row .text-input {
flex: 1 1 auto;
min-width: 0;
}
.field-multi {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.field-check {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: var(--muted);
cursor: pointer;
}
.field-state {
font-size: 11px;
color: var(--faint);
min-height: 14px;
}
.field-state[data-tone='ok'] {
color: var(--ok);
}
.field-state[data-tone='error'] {
color: var(--danger);
}
/* ---------- extensions panel ---------- */
.repo-add {
display: flex;
gap: 8px;
margin-bottom: 8px;
}
.repo-add .text-input {
flex: 1 1 auto;
}
.repo-hint {
margin: 0 0 14px;
font-size: 12px;
color: var(--faint);
max-width: 78ch;
}
.repo-hint code {
font-family: var(--mono);
font-size: 11px;
}
.ext-group-title {
display: flex;
align-items: baseline;
gap: 8px;
margin: 0 0 8px;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.18em;
text-transform: uppercase;
color: var(--faint);
}
/* A rule between the groups, but not above the first one. */
.ext-list + .ext-group-title {
margin-top: 6px;
padding-top: 14px;
border-top: 1px solid var(--line);
}
.ext-group-count {
font-size: 11px;
letter-spacing: 0.06em;
color: var(--muted);
}
.ext-group-count:empty {
display: none;
}
.lang-filter {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.lang-filter:not(:empty) {
margin-bottom: 10px;
}
.lang-chip {
padding: 4px 11px;
border: 1px solid var(--line);
border-radius: 999px;
background: var(--ctp-crust);
color: var(--muted);
font: inherit;
font-size: 11px;
letter-spacing: 0.04em;
cursor: pointer;
transition:
color 120ms ease,
border-color 120ms ease;
}
.lang-chip:hover {
color: var(--text);
border-color: var(--accent);
}
.lang-chip.is-active {
border-color: var(--accent);
color: var(--accent);
}
.lang-chip:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.ext-list {
display: flex;
flex-direction: column;
gap: 6px;
}
.ext-list:not(:empty) {
margin-bottom: 14px;
}
.ext-row {
display: flex;
align-items: center;
gap: 12px;
padding: 9px 12px;
border: 1px solid var(--line);
border-radius: 10px;
background: var(--ctp-crust);
}
.ext-row.is-error {
border-color: rgba(237, 135, 150, 0.45);
}
.ext-main {
flex: 1 1 auto;
min-width: 0;
}
.ext-name {
font-size: 13px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ext-sub {
font-family: var(--mono);
font-size: 11px;
color: var(--faint);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ext-row.is-error .ext-sub {
color: var(--danger);
white-space: normal;
}
.ext-tag {
flex: none;
padding: 2px 8px;
border-radius: 999px;
border: 1px solid var(--line);
font-size: 10px;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--faint);
}
.ext-tag.installed {
border-color: rgba(166, 218, 149, 0.4);
color: var(--ok);
}
.ext-tag.nsfw {
border-color: rgba(237, 135, 150, 0.4);
color: var(--danger);
}
.ext-row .ghost-button,
.ext-row .primary-button {
flex: none;
padding: 5px 12px;
font-size: 12px;
}
.ext-empty {
padding: 14px 2px;
font-size: 13px;
color: var(--muted);
}