feat(anime): add anime browser powered by Aniyomi extensions

- Add `subminer anime` / `--anime` and a tray entry to open a browser that searches installed Aniyomi extension sources, shows cover art and episodes, and plays into mpv with overlay/mining attached
- Add an Extensions tab to add repos and install/update/remove sources, and per-source settings for sources needing config
- Support searching all sources at once with streaming, per-source results and status
- Prefer Japanese audio/subtitle tracks from the source and keep the primary subtitle slot reserved for Japanese
- Fix window/tray/Dock handling so the browser and mpv can be switched between without quitting the app or losing the Dock icon
- Add anime.repos, anime.extensionsDir, anime.preferredQuality config keys (no bundled repos or discovery)
This commit is contained in:
2026-07-31 17:16:49 -07:00
parent b204d4dd6e
commit e64ff1a0ee
117 changed files with 7565 additions and 529 deletions
+505
View File
@@ -0,0 +1,505 @@
import { describe, el } from './dom';
import { sourceOptionLabel, summarizeSearch } from './format';
import { applySearchUpdate, idleSearchProgress, summarizeProgress } from './search-progress';
import { createExtensionsPanel } from './extensions-panel';
import { renderPreferences, renderPreferencesUnavailable } from './preferences-fields';
import { ALL_SOURCES_ID } from '../types/anime-browser';
import type {
AnimeBrowserAPI,
AnimeBrowserBridgeState,
AnimeBrowserEntry,
AnimeBrowserEpisode,
AnimeBrowserSource,
} from '../types/anime-browser';
declare global {
interface Window {
animeBrowserAPI: AnimeBrowserAPI;
}
}
const api = window.animeBrowserAPI;
const searchForm = el<HTMLFormElement>('search-form');
const searchInput = el<HTMLInputElement>('search-input');
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 banner = el<HTMLDivElement>('bridge-banner');
const bannerMessage = el<HTMLSpanElement>('bridge-message');
const bannerMeter = el<HTMLSpanElement>('bridge-meter');
const bannerMeterFill = el<HTMLElement>('bridge-meter-fill');
const statusMessage = el<HTMLSpanElement>('status-message');
const browseTab = el<HTMLButtonElement>('tab-browse');
const extensionsTab = el<HTMLButtonElement>('tab-extensions');
const settingsTab = el<HTMLButtonElement>('tab-settings');
const layout = el<HTMLElement>('layout');
const extensionsPanel = el<HTMLElement>('extensions');
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;
/* ---------- tabs ---------- */
type View = 'browse' | 'extensions' | 'settings';
let currentView: View = 'browse';
/**
* Show one view at a time. The panels used to sit above the results, which left
* a long extension list scrolling inside a sliver of the window; as tabs each
* one gets the whole content region.
*/
function setView(view: View): void {
currentView = view;
layout.classList.toggle('hidden', view !== 'browse');
extensionsPanel.classList.toggle('hidden', view !== 'extensions');
settingsPanel.classList.toggle('hidden', view !== 'settings');
browseTab.setAttribute('aria-selected', String(view === 'browse'));
extensionsTab.setAttribute('aria-selected', String(view === 'extensions'));
settingsTab.setAttribute('aria-selected', String(view === 'settings'));
}
function setStatus(message: string, tone: 'info' | 'ok' | 'error' = 'info'): void {
statusMessage.textContent = message;
statusMessage.parentElement?.setAttribute('data-tone', tone);
}
const BRIDGE_LABELS: Record<AnimeBrowserBridgeState['stage'], string> = {
idle: 'Starting the extension bridge',
locating: 'Looking up the extension bridge release',
downloading: 'Downloading the extension bridge',
verifying: 'Verifying the download',
extracting: 'Unpacking the extension bridge',
starting: 'Starting the extension bridge',
ready: 'Bridge ready',
failed: 'Bridge failed to start',
};
const BUSY_STAGES = new Set([
'idle',
'locating',
'downloading',
'verifying',
'extracting',
'starting',
]);
function renderBridgeState(state: AnimeBrowserBridgeState): void {
const busy = BUSY_STAGES.has(state.stage);
banner.dataset.stage = state.stage;
banner.dataset.busy = String(busy);
// Once ready with nothing to report, the banner has nothing to say.
const hide = state.stage === 'ready' && state.message === null;
banner.classList.toggle('hidden', hide);
bannerMessage.textContent = state.message ?? BRIDGE_LABELS[state.stage];
const showMeter = state.progress !== null;
bannerMeter.classList.toggle('hidden', !showMeter);
if (state.progress !== null) {
bannerMeterFill.style.width = `${Math.round(state.progress * 100)}%`;
}
const ready = state.stage === 'ready';
searchInput.disabled = !ready;
searchButton.disabled = !ready;
}
/* ---------- source picker ---------- */
/**
* The picker lists every installed source, plus an "All sources" entry that
* searches them together. That entry only earns its place with more than one
* source installed.
*/
function renderSources(sources: AnimeBrowserSource[], selectedId: string | null): void {
const options: HTMLOptionElement[] = [];
if (sources.length > 1) {
const all = document.createElement('option');
all.value = ALL_SOURCES_ID;
all.textContent = `All sources (${sources.length})`;
all.selected = selectedId === ALL_SOURCES_ID;
options.push(all);
}
for (const source of sources) {
const option = document.createElement('option');
option.value = source.id;
option.textContent = sourceOptionLabel(source);
option.selected = source.id === selectedId;
options.push(option);
}
sourceSelect.replaceChildren(...options);
sourceSelect.disabled = options.length <= 1;
}
function searchingAllSources(): boolean {
return sourceSelect.value === ALL_SOURCES_ID;
}
/* ---------- results ---------- */
function createCard(entry: AnimeBrowserEntry, showSource: boolean): HTMLButtonElement {
const card = document.createElement('button');
card.type = 'button';
card.className = 'card';
const art = document.createElement('div');
art.className = 'card-art';
if (entry.thumbnailUrl) {
const img = document.createElement('img');
img.src = entry.thumbnailUrl;
img.alt = '';
img.loading = 'lazy';
// A dead cover should fall back to the initial, not a broken-image icon.
img.addEventListener('error', () => {
img.remove();
art.classList.add('is-empty');
});
art.append(img);
} else {
art.classList.add('is-empty');
}
art.dataset.initial = entry.title.trim().charAt(0).toUpperCase() || '?';
if (showSource) {
const badge = document.createElement('span');
badge.className = 'card-source';
badge.textContent = entry.sourceName;
art.append(badge);
}
const title = document.createElement('div');
title.className = 'card-title';
title.textContent = entry.title;
card.append(art, title);
card.title = showSource ? `${entry.title}${entry.sourceName}` : entry.title;
card.addEventListener('click', () => {
void openDetail(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)));
const empty = entries.length === 0;
gridEmpty.classList.toggle('hidden', !empty);
gridEmpty.textContent = emptyMessage;
}
/** 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,
});
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');
}
}
/* ---------- streamed search ---------- */
/**
* Results are pushed per source while the search call is still pending, so a
* fast source is on screen before a slow one answers. The grid is built from
* those pushes; the awaited result then settles the final status line (and
* backfills the grid if no push ever arrived).
*/
let progress = idleSearchProgress();
/** Orders runSearch calls so a slow search cannot finish over a newer one. */
let searchRequest = 0;
api.onSearchUpdate((update) => {
const applied = applySearchUpdate(progress, update);
if (!applied) return; // A superseded search; let it run out quietly.
progress = applied.progress;
if (applied.started) {
grid.replaceChildren();
gridEmpty.classList.add('hidden');
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');
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;
// 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}`);
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) {
renderEntries(
result.entries,
query ? `Nothing found for “${query}”.` : 'This source returned nothing.',
);
}
setStatus(summarizeSearch(result), result.failures.length > 0 ? 'error' : 'info');
} catch (error) {
if (request !== searchRequest) return;
renderEntries([], describe(error));
setStatus(describe(error), 'error');
}
}
/* ---------- source settings ---------- */
async function openSettings(): Promise<void> {
const option = sourceSelect.selectedOptions[0];
settingsTitle.textContent = option ? `${option.textContent} settings` : 'Source settings';
settingsFields.replaceChildren();
setView('settings');
// Settings belong to one extension, so there is nothing coherent to show for
// "All sources".
if (searchingAllSources()) {
renderPreferencesUnavailable(
settingsFields,
'Pick a single source in the Source picker to edit its settings.',
);
return;
}
const sourceId = sourceSelect.value;
try {
renderPreferences(settingsFields, await api.getPreferences(sourceId), (key, value) =>
api.setPreference(sourceId, key, value),
);
} catch (error) {
setStatus(describe(error), 'error');
setView('browse');
}
}
/* ---------- wiring ---------- */
async function refreshSources(): Promise<void> {
const snapshot = await api.getSnapshot();
renderSources(snapshot.sources, snapshot.selectedSourceId);
}
const extensions = createExtensionsPanel({ api, setStatus, onSourcesChanged: refreshSources });
browseTab.addEventListener('click', () => setView('browse'));
settingsTab.addEventListener('click', () => void openSettings());
extensionsTab.addEventListener('click', () => {
setView('extensions');
void extensions.refresh();
});
searchForm.addEventListener('submit', (event) => {
event.preventDefault();
// Searching is a browse action, whichever tab it was typed from.
setView('browse');
void runSearch(searchInput.value.trim());
});
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());
})();
});
detailBack.addEventListener('click', closeDetail);
document.addEventListener('keydown', (event) => {
if (event.key === 'Escape' && !detail.classList.contains('hidden')) {
closeDetail();
}
});
api.onBridgeState(renderBridgeState);
void (async () => {
renderBridgeState({ stage: 'idle', progress: null, message: null });
const state = await api.ensureBridge();
renderBridgeState(state);
const snapshot = await api.getSnapshot();
renderSources(snapshot.sources, snapshot.selectedSourceId);
if (state.stage === 'ready' && snapshot.sources.length > 0) {
searchInput.focus();
await runSearch('');
} else if (state.stage === 'ready') {
setStatus(state.message ?? 'No extensions installed.', 'error');
} else {
setStatus(state.message ?? 'The extension bridge is not available.', 'error');
}
})();
+13
View File
@@ -0,0 +1,13 @@
/** Small helpers shared by the anime browser's panels. */
export function el<T extends HTMLElement>(id: string): T {
const node = document.getElementById(id);
if (!node) throw new Error(`Missing element #${id}`);
return node as T;
}
export function describe(error: unknown): string {
const message = error instanceof Error ? error.message : String(error);
// Electron wraps handler errors; keep only the useful tail.
return message.replace(/^Error invoking remote method '[^']+':\s*/, '').replace(/^Error:\s*/, '');
}
+280
View File
@@ -0,0 +1,280 @@
import { describe, el } from './dom';
import { describeInstalled } from './format';
import type {
AnimeBrowserAPI,
AvailableExtension,
InstalledExtensionView,
} from '../types/anime-browser';
/**
* The Extensions tab: what is installed, which repositories feed it, and what
* those repositories still offer.
*
* Installed extensions get their own section at the top, listed from the
* extensions directory rather than from a repository catalogue — an APK dropped
* in by hand, or one whose repository has since been removed, is still
* installed and has to stay removable.
*/
export interface ExtensionsPanelOptions {
api: AnimeBrowserAPI;
setStatus: (message: string, tone?: 'info' | 'ok' | 'error') => void;
/** Called after an install or removal, so the source picker keeps up. */
onSourcesChanged: () => Promise<void>;
}
interface RowAction {
label: string;
primary?: boolean;
onClick: () => void | Promise<void>;
}
interface RowOptions {
name: string;
sub: string;
tags?: Array<{ text: string; className: string }>;
actions?: RowAction[];
isError?: boolean;
}
function extensionRow(options: RowOptions): HTMLDivElement {
const row = document.createElement('div');
row.className = options.isError ? 'ext-row is-error' : 'ext-row';
const main = document.createElement('div');
main.className = 'ext-main';
const name = document.createElement('div');
name.className = 'ext-name';
name.textContent = options.name;
const sub = document.createElement('div');
sub.className = 'ext-sub';
sub.textContent = options.sub;
main.append(name, sub);
row.append(main);
for (const tag of options.tags ?? []) {
const chip = document.createElement('span');
chip.className = `ext-tag ${tag.className}`;
chip.textContent = tag.text;
row.append(chip);
}
for (const action of options.actions ?? []) {
const button = document.createElement('button');
button.type = 'button';
button.className = action.primary ? 'primary-button' : 'ghost-button';
button.textContent = action.label;
button.addEventListener('click', () => {
button.disabled = true;
void Promise.resolve(action.onClick()).finally(() => {
button.disabled = false;
});
});
row.append(button);
}
return row;
}
function emptyNote(text: string): HTMLParagraphElement {
const empty = document.createElement('p');
empty.className = 'ext-empty';
empty.textContent = text;
return empty;
}
export function createExtensionsPanel(options: ExtensionsPanelOptions) {
const { api, setStatus, onSourcesChanged } = options;
const extensionsDirLabel = el<HTMLSpanElement>('extensions-dir');
const installedList = el<HTMLDivElement>('installed-list');
const installedCount = el<HTMLSpanElement>('installed-count');
const availableList = el<HTMLDivElement>('extensions-list');
const repoInput = el<HTMLInputElement>('repo-input');
const repoAddButton = el<HTMLButtonElement>('repo-add');
const repoList = el<HTMLDivElement>('repo-list');
async function afterChange(extensionName: string, verb: string): Promise<void> {
await refresh();
await onSourcesChanged();
setStatus(`${extensionName} ${verb}`, 'ok');
}
function renderInstalled(
installed: InstalledExtensionView[],
offeredPkgs: Set<string>,
extensionsDir: string,
): void {
installedCount.textContent = installed.length === 0 ? '' : String(installed.length);
if (installed.length === 0) {
installedList.replaceChildren(
emptyNote(
`Nothing installed yet. Add a repository below, or drop .apk files in ${extensionsDir}.`,
),
);
return;
}
installedList.replaceChildren(
...installed.map((view) => {
const actions: RowAction[] = [];
// Only offer an update for an extension a configured repository still
// carries; reinstalling overwrites the APK in place.
if (offeredPkgs.has(view.pkg)) {
actions.push({
label: 'Update',
onClick: async () => {
setStatus(`Updating ${view.name}`);
try {
await api.installExtension(view.pkg);
await afterChange(view.name, 'updated');
} catch (error) {
setStatus(describe(error), 'error');
}
},
});
}
actions.push({
label: 'Remove',
onClick: async () => {
setStatus(`Removing ${view.name}`);
try {
await api.removeExtension(view.pkg);
await afterChange(view.name, 'removed');
} catch (error) {
setStatus(describe(error), 'error');
}
},
});
return extensionRow({
name: view.name,
sub: view.error ?? describeInstalled(view),
isError: view.error !== null,
tags: view.error === null ? [] : [{ text: 'failed', className: 'nsfw' }],
actions,
});
}),
);
}
function renderRepos(repos: string[]): void {
repoList.replaceChildren(
...repos.map((repoUrl) =>
extensionRow({
name: repoUrl.replace(/^https:\/\//, '').replace(/\/[^/]*\.json$/, ''),
sub: repoUrl,
actions: [
{
label: 'Remove',
onClick: async () => {
await api.removeRepo(repoUrl);
await refresh();
},
},
],
}),
),
);
}
function renderAvailable(
available: AvailableExtension[],
repoFailures: Array<{ name: string; error: string }>,
hasRepos: boolean,
): void {
const rows: HTMLElement[] = repoFailures.map((failure) =>
extensionRow({ name: failure.name, sub: failure.error, isError: true }),
);
for (const extension of available) {
rows.push(
extensionRow({
name: extension.name,
sub: `${extension.lang} · v${extension.version}`,
tags: extension.nsfw ? [{ text: '18+', className: 'nsfw' }] : [],
actions: [
{
label: 'Install',
primary: true,
onClick: async () => {
setStatus(`Installing ${extension.name}`);
try {
await api.installExtension(extension.pkg);
await afterChange(extension.name, 'installed');
} catch (error) {
setStatus(describe(error), 'error');
}
},
},
],
}),
);
}
if (rows.length === 0) {
rows.push(
emptyNote(
hasRepos
? 'Every extension the configured repositories offer is already installed.'
: 'No repository configured, so there is nothing to install from.',
),
);
}
availableList.replaceChildren(...rows);
}
async function refresh(): Promise<void> {
const snapshot = await api.getSnapshot();
extensionsDirLabel.textContent = snapshot.extensionsDir;
renderRepos(snapshot.repos);
const repoFailures: Array<{ name: string; error: string }> = [];
let available;
try {
available = await api.listAvailableExtensions();
} catch (error) {
repoFailures.push({ name: 'Repository error', error: describe(error) });
available = { extensions: [], failures: [] };
}
for (const failure of available.failures) {
repoFailures.push({ name: failure.repoUrl, error: failure.error });
}
const offeredPkgs = new Set(available.extensions.map((extension) => extension.pkg));
renderInstalled(snapshot.installed, offeredPkgs, snapshot.extensionsDir);
renderAvailable(
// Installed extensions have their own section; leaving them here too
// would list every one of them twice.
available.extensions.filter((extension) => !extension.installed),
repoFailures,
snapshot.repos.length > 0,
);
}
repoAddButton.addEventListener('click', () => {
void (async () => {
const url = repoInput.value.trim();
if (url.length === 0) return;
try {
await api.addRepo(url);
repoInput.value = '';
setStatus('Repository added');
await refresh();
} catch (error) {
setStatus(describe(error), 'error');
}
})();
});
repoInput.addEventListener('keydown', (event) => {
if (event.key === 'Enter') {
event.preventDefault();
repoAddButton.click();
}
});
return { refresh };
}
+60
View File
@@ -0,0 +1,60 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { describeInstalled, sourceOptionLabel, summarizeSearch } from './format';
import type { AnimeBrowserSearchResult } from '../types/anime-browser';
const result = (
entryCount: number,
failures: AnimeBrowserSearchResult['failures'] = [],
): AnimeBrowserSearchResult => ({
entries: Array.from({ length: entryCount }, (_unused, index) => ({
url: `/a/${index}`,
title: `Anime ${index}`,
thumbnailUrl: null,
sourceId: 's',
sourceName: 'Source',
})),
hasNextPage: false,
failures,
});
test('sourceOptionLabel omits the language for an all-language source', () => {
assert.equal(sourceOptionLabel({ id: '1', name: 'Nyaa', lang: 'ja', pkg: 'p' }), 'Nyaa (ja)');
assert.equal(sourceOptionLabel({ id: '2', name: 'Jellyfin', lang: 'all', pkg: 'p' }), 'Jellyfin');
});
test('summarizeSearch counts results and singularizes one', () => {
assert.equal(summarizeSearch(result(4)), '4 results');
assert.equal(summarizeSearch(result(1)), '1 result');
assert.equal(summarizeSearch(result(0)), '0 results');
});
test('summarizeSearch names the sources that failed alongside the ones that answered', () => {
const summary = summarizeSearch(
result(6, [
{ sourceId: 'a', sourceName: 'Alpha', error: 'login required' },
{ sourceId: 'b', sourceName: 'Beta', error: 'timed out' },
]),
);
assert.equal(summary, '6 results · 2 unavailable: Alpha, Beta');
});
test('describeInstalled reports sources and languages when the extension loaded', () => {
assert.equal(
describeInstalled({
pkg: 'multi',
name: 'One, Two',
langs: ['en', 'ja'],
sourceCount: 2,
error: null,
}),
'multi · 2 sources · en, ja',
);
});
test('describeInstalled falls back to the package alone when nothing loaded', () => {
assert.equal(
describeInstalled({ pkg: 'broken', name: 'broken', langs: [], sourceCount: 0, error: 'boom' }),
'broken',
);
});
+35
View File
@@ -0,0 +1,35 @@
import type {
AnimeBrowserSearchResult,
AnimeBrowserSource,
InstalledExtensionView,
} from '../types/anime-browser';
/** Display strings for the anime browser, kept separate from the DOM. */
/** `Nyaa (ja)`, or just the name for a source that serves every language. */
export function sourceOptionLabel(source: AnimeBrowserSource): string {
return source.lang === 'all' ? source.name : `${source.name} (${source.lang})`;
}
/**
* The status line after a search.
*
* An all-sources search can half-succeed, so the sources that failed are named
* rather than folded into a count the user cannot act on.
*/
export function summarizeSearch(result: AnimeBrowserSearchResult): string {
const count = `${result.entries.length} result${result.entries.length === 1 ? '' : 's'}`;
if (result.failures.length === 0) return count;
const names = result.failures.map((failure) => failure.sourceName).join(', ');
return `${count} · ${result.failures.length} unavailable: ${names}`;
}
/** `pkg · 3 sources · en, ja`, trimmed to what the extension actually reported. */
export function describeInstalled(view: InstalledExtensionView): string {
const parts = [view.pkg];
if (view.sourceCount > 0) {
parts.push(`${view.sourceCount} source${view.sourceCount === 1 ? '' : 's'}`);
}
if (view.langs.length > 0) parts.push(view.langs.join(', '));
return parts.join(' · ');
}
+158
View File
@@ -0,0 +1,158 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta
http-equiv="Content-Security-Policy"
content="default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data: blob: http://127.0.0.1:* http://localhost:* https:; object-src 'none'; base-uri 'self';"
/>
<title>SubMiner Anime</title>
<link rel="stylesheet" href="./style.css" />
</head>
<body>
<header class="topbar">
<div class="brand-block">
<div class="brand-title">SubMiner</div>
<div class="brand-subtitle">Anime</div>
</div>
<form class="search-form" id="search-form" role="search">
<input
class="text-input search-input"
id="search-input"
type="search"
placeholder="Search anime"
autocomplete="off"
aria-label="Search anime"
/>
<button class="primary-button" id="search-button" type="submit">Search</button>
</form>
<label class="source-picker">
<span class="source-label">Source</span>
<select class="text-input" id="source-select" aria-label="Extension source"></select>
</label>
<nav class="tabs" role="tablist" aria-label="View">
<button
class="tab"
id="tab-browse"
type="button"
role="tab"
aria-controls="layout"
aria-selected="true"
>
Browse
</button>
<button
class="tab"
id="tab-extensions"
type="button"
role="tab"
aria-controls="extensions"
aria-selected="false"
>
Extensions
</button>
<button
class="tab"
id="tab-settings"
type="button"
role="tab"
aria-controls="settings"
aria-selected="false"
>
Source settings
</button>
</nav>
</header>
<div class="bridge-banner hidden" id="bridge-banner" role="status" aria-live="polite">
<span class="bridge-dot" id="bridge-dot"></span>
<span class="bridge-message" id="bridge-message"></span>
<span class="bridge-meter hidden" id="bridge-meter"><i id="bridge-meter-fill"></i></span>
</div>
<section class="settings hidden" id="settings" role="tabpanel" aria-labelledby="tab-settings">
<div class="settings-head">
<h2 class="settings-title" id="settings-title">Source settings</h2>
<span class="settings-note">Saved as you change them.</span>
</div>
<div class="settings-fields" id="settings-fields"></div>
</section>
<section
class="settings hidden"
id="extensions"
role="tabpanel"
aria-labelledby="tab-extensions"
>
<div class="settings-head">
<h2 class="settings-title">Extensions</h2>
<span class="settings-note" id="extensions-dir"></span>
</div>
<h3 class="ext-group-title">
Installed <span class="ext-group-count" id="installed-count"></span>
</h3>
<div class="ext-list" id="installed-list" aria-label="Installed extensions"></div>
<h3 class="ext-group-title">Repositories</h3>
<div class="repo-add">
<input
class="text-input"
id="repo-input"
type="url"
placeholder="https://example.org/repo/index.min.json"
aria-label="Repository index URL"
/>
<button class="primary-button" id="repo-add" type="button">Add repository</button>
</div>
<p class="repo-hint" id="repo-hint">
SubMiner ships no repositories. Add any https URL to a repository's <code>.json</code> index
to install extensions from it, or drop <code>.apk</code> files in the extensions directory.
</p>
<div class="ext-list" id="repo-list" aria-label="Repositories"></div>
<h3 class="ext-group-title">Available</h3>
<div class="ext-list" id="extensions-list" aria-label="Available extensions"></div>
</section>
<main class="layout" id="layout" role="tabpanel" aria-labelledby="tab-browse">
<section class="results" id="results" aria-label="Results">
<div class="grid" id="grid"></div>
<p class="empty hidden" id="grid-empty"></p>
</section>
<section class="detail hidden" id="detail" aria-label="Details">
<div class="detail-body">
<button class="ghost-button detail-back" id="detail-back" type="button">
← Back to results
</button>
<div class="detail-head">
<img class="detail-cover" id="detail-cover" alt="" />
<div class="detail-meta">
<h2 class="detail-title" id="detail-title"></h2>
<div class="detail-chips" id="detail-chips"></div>
<p class="detail-description" id="detail-description"></p>
</div>
</div>
<div class="episodes-head">
<h3 class="episodes-title">Episodes</h3>
<span class="episodes-count" id="episodes-count"></span>
</div>
<ol class="cue-rail" id="episodes"></ol>
</div>
</section>
</main>
<footer class="statusbar">
<span id="status-message"></span>
</footer>
<script type="module" src="./animeui.js"></script>
</body>
</html>
+143
View File
@@ -0,0 +1,143 @@
import { describe } from './dom';
import type { SourcePreferenceView } from '../types/anime-browser';
/**
* Renders one extension's settings schema as form fields.
*
* The extension owns the schema, so a commit hands back a refreshed one and the
* whole panel re-renders from it — the Jellyfin source fills in its library
* picker only after a successful login, and that has to show up.
*/
export type PreferenceCommit = (
key: string,
value: string | string[] | boolean,
) => Promise<SourcePreferenceView[]>;
/** Masked in the UI so a shoulder-surfer cannot read a stored password. */
function isSecretKey(view: SourcePreferenceView): boolean {
return /password|token|api[-_ ]?key|secret/i.test(`${view.key} ${view.title}`);
}
function renderPreferenceField(
container: HTMLElement,
view: SourcePreferenceView,
commit: PreferenceCommit,
): HTMLElement {
const field = document.createElement('div');
field.className = 'field';
const label = document.createElement('label');
label.className = 'field-label';
label.textContent = view.title;
field.append(label);
if (view.summary) {
const summary = document.createElement('span');
summary.className = 'field-summary';
summary.textContent = view.summary;
field.append(summary);
}
const state = document.createElement('span');
state.className = 'field-state';
const save = async (value: string | string[] | boolean): Promise<void> => {
state.removeAttribute('data-tone');
state.textContent = 'Saving…';
try {
const refreshed = await commit(view.key, value);
state.dataset.tone = 'ok';
state.textContent = 'Saved';
renderPreferences(container, refreshed, commit);
} catch (error) {
state.dataset.tone = 'error';
state.textContent = describe(error);
}
};
const row = document.createElement('div');
row.className = 'field-row';
if (view.kind === 'switch') {
const wrapper = document.createElement('label');
wrapper.className = 'field-check';
const box = document.createElement('input');
box.type = 'checkbox';
box.checked = view.value === true;
box.addEventListener('change', () => void save(box.checked));
wrapper.append(box, document.createTextNode('Enabled'));
row.append(wrapper);
} else if (view.kind === 'list') {
const select = document.createElement('select');
select.className = 'text-input';
for (const [index, entryValue] of view.entryValues.entries()) {
const option = document.createElement('option');
option.value = entryValue;
option.textContent = view.entries[index] ?? entryValue;
option.selected = entryValue === view.value;
select.append(option);
}
if (view.entryValues.length === 0) {
select.disabled = true;
const option = document.createElement('option');
option.textContent = 'Nothing to choose yet';
select.append(option);
}
select.addEventListener('change', () => void save(select.value));
row.append(select);
} else if (view.kind === 'multi') {
const group = document.createElement('div');
group.className = 'field-multi';
const selected = new Set(Array.isArray(view.value) ? view.value : []);
for (const [index, entryValue] of view.entryValues.entries()) {
const wrapper = document.createElement('label');
wrapper.className = 'field-check';
const box = document.createElement('input');
box.type = 'checkbox';
box.checked = selected.has(entryValue);
box.addEventListener('change', () => {
if (box.checked) selected.add(entryValue);
else selected.delete(entryValue);
void save([...selected]);
});
wrapper.append(box, document.createTextNode(view.entries[index] ?? entryValue));
group.append(wrapper);
}
row.append(group);
} else {
const input = document.createElement('input');
input.className = 'text-input';
input.type = isSecretKey(view) ? 'password' : 'text';
input.value = typeof view.value === 'string' ? view.value : '';
// Commit on blur/Enter rather than per keystroke; each save round-trips
// to the extension and may trigger a login.
input.addEventListener('change', () => void save(input.value));
row.append(input);
}
field.append(row, state);
return field;
}
export function renderPreferences(
container: HTMLElement,
views: SourcePreferenceView[],
commit: PreferenceCommit,
): void {
container.replaceChildren(...views.map((view) => renderPreferenceField(container, view, commit)));
if (views.length === 0) {
const empty = document.createElement('p');
empty.className = 'field-summary';
empty.textContent = 'This source has no settings.';
container.append(empty);
}
}
/** Shown in place of the fields when the source picker is on "All sources". */
export function renderPreferencesUnavailable(container: HTMLElement, message: string): void {
const note = document.createElement('p');
note.className = 'field-summary';
note.textContent = message;
container.replaceChildren(note);
}
+103
View File
@@ -0,0 +1,103 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { applySearchUpdate, idleSearchProgress, summarizeProgress } from './search-progress';
import type { AnimeBrowserEntry, SourceSearchFailure } from '../types/anime-browser';
const entry = (title: string): AnimeBrowserEntry => ({
url: `/a/${title}`,
title,
thumbnailUrl: null,
sourceId: 's1',
sourceName: 'Source One',
});
const failure: SourceSearchFailure = {
sourceId: 's2',
sourceName: 'Source Two',
error: 'login required',
};
test('a search accumulates results and failures update by update', () => {
let progress = idleSearchProgress();
let applied = applySearchUpdate(progress, { kind: 'start', token: 1, sourceCount: 3 });
assert.ok(applied);
assert.equal(applied.started, true);
progress = applied.progress;
applied = applySearchUpdate(progress, {
kind: 'result',
token: 1,
sourceId: 's1',
sourceName: 'Source One',
entries: [entry('A'), entry('B')],
});
assert.ok(applied);
assert.deepEqual(
applied.entries.map((item) => item.title),
['A', 'B'],
);
progress = applied.progress;
applied = applySearchUpdate(progress, { kind: 'failure', token: 1, failure });
assert.ok(applied);
progress = applied.progress;
assert.equal(progress.entryCount, 2);
assert.equal(progress.sourcesDone, 2);
assert.deepEqual(progress.failures, [failure]);
assert.equal(progress.done, false);
applied = applySearchUpdate(progress, { kind: 'done', token: 1 });
assert.ok(applied);
assert.equal(applied.progress.done, true);
});
test('updates from a superseded search are dropped entirely', () => {
let progress = idleSearchProgress();
progress = applySearchUpdate(progress, { kind: 'start', token: 1, sourceCount: 2 })!.progress;
progress = applySearchUpdate(progress, { kind: 'start', token: 2, sourceCount: 2 })!.progress;
// The first search's straggler results and completion must not touch token 2.
assert.equal(
applySearchUpdate(progress, {
kind: 'result',
token: 1,
sourceId: 's1',
sourceName: 'Source One',
entries: [entry('stale')],
}),
null,
);
assert.equal(applySearchUpdate(progress, { kind: 'done', token: 1 }), null);
assert.equal(progress.entryCount, 0);
});
test('an older start cannot reset a newer search', () => {
let progress = idleSearchProgress();
progress = applySearchUpdate(progress, { kind: 'start', token: 5, sourceCount: 1 })!.progress;
assert.equal(applySearchUpdate(progress, { kind: 'start', token: 4, sourceCount: 9 }), null);
assert.equal(applySearchUpdate(progress, { kind: 'start', token: 5, sourceCount: 9 }), null);
assert.equal(progress.sourceCount, 1);
});
test('summarizeProgress counts sources and names failures', () => {
let progress = idleSearchProgress();
progress = applySearchUpdate(progress, { kind: 'start', token: 1, sourceCount: 5 })!.progress;
progress = applySearchUpdate(progress, {
kind: 'result',
token: 1,
sourceId: 's1',
sourceName: 'Source One',
entries: [entry('A')],
})!.progress;
assert.equal(summarizeProgress(progress), 'Searching… 1/5 sources · 1 result');
progress = applySearchUpdate(progress, { kind: 'failure', token: 1, failure })!.progress;
assert.equal(
summarizeProgress(progress),
'Searching… 2/5 sources · 1 result · unavailable: Source Two',
);
});
+106
View File
@@ -0,0 +1,106 @@
import type { AnimeBrowserEntry, AnimeBrowserSearchUpdate } from '../types/anime-browser';
import type { SourceSearchFailure } from '../types/anime-browser';
/**
* Streamed-search bookkeeping, kept apart from the DOM so the staleness rules
* are testable.
*
* Updates stream in while earlier searches may still be resolving; a search is
* identified by its token and only the newest one may touch the grid. Tokens
* are emitted in start order over an ordered channel, so "newest" is simply
* the highest token seen.
*/
export interface SearchProgress {
token: number;
sourceCount: number;
sourcesDone: number;
entryCount: number;
failures: SourceSearchFailure[];
done: boolean;
}
const IDLE: SearchProgress = {
token: -1,
sourceCount: 0,
sourcesDone: 0,
entryCount: 0,
failures: [],
done: false,
};
export interface AppliedUpdate {
progress: SearchProgress;
/** Entries this update contributed; append them to the grid. */
entries: AnimeBrowserEntry[];
/** True when this update began a new search; clear the grid first. */
started: boolean;
}
export function idleSearchProgress(): SearchProgress {
return { ...IDLE, failures: [] };
}
/**
* Fold one update into the current progress. Returns null for an update from
* a superseded search, which the caller must ignore entirely.
*/
export function applySearchUpdate(
current: SearchProgress,
update: AnimeBrowserSearchUpdate,
): AppliedUpdate | null {
if (update.kind === 'start') {
// An older search's start (or a replay) must not reset the newer one.
if (update.token <= current.token) return null;
return {
progress: {
token: update.token,
sourceCount: update.sourceCount,
sourcesDone: 0,
entryCount: 0,
failures: [],
done: false,
},
entries: [],
started: true,
};
}
if (update.token !== current.token) return null;
if (update.kind === 'result') {
return {
progress: {
...current,
sourcesDone: current.sourcesDone + 1,
entryCount: current.entryCount + update.entries.length,
},
entries: update.entries,
started: false,
};
}
if (update.kind === 'failure') {
return {
progress: {
...current,
sourcesDone: current.sourcesDone + 1,
failures: [...current.failures, update.failure],
},
entries: [],
started: false,
};
}
return { progress: { ...current, done: true }, entries: [], started: false };
}
/** `Searching… 3/5 sources · 42 results`, with failures named once they exist. */
export function summarizeProgress(progress: SearchProgress): string {
const counts = `${progress.sourcesDone}/${progress.sourceCount} sources · ${progress.entryCount} result${progress.entryCount === 1 ? '' : 's'}`;
const failed =
progress.failures.length === 0
? ''
: ` · unavailable: ${progress.failures.map((failure) => failure.sourceName).join(', ')}`;
return `Searching… ${counts}${failed}`;
}
+908
View File
@@ -0,0 +1,908 @@
@font-face {
font-family: 'M PLUS 1';
src: url('./fonts/MPLUS1[wght].ttf') format('truetype');
font-weight: 100 900;
font-display: swap;
}
:root {
/* Catppuccin Macchiato */
--ctp-mauve: #c6a0f6;
--ctp-red: #ed8796;
--ctp-peach: #f5a97f;
--ctp-yellow: #eed49f;
--ctp-green: #a6da95;
--ctp-sky: #91d7e3;
--ctp-blue: #8aadf4;
--ctp-lavender: #b7bdf8;
--ctp-text: #cad3f5;
--ctp-subtext0: #a5adcb;
--ctp-overlay1: #8087a2;
--ctp-surface0: #363a4f;
--ctp-base: #24273a;
--ctp-mantle: #1e2030;
--ctp-crust: #181926;
--bg: var(--ctp-base);
--panel: rgba(36, 39, 58, 0.85);
--panel-elevated: rgba(54, 58, 79, 0.55);
--line: rgba(110, 115, 141, 0.28);
--text: var(--ctp-text);
--muted: var(--ctp-subtext0);
--faint: var(--ctp-overlay1);
--accent: var(--ctp-blue);
--accent-strong: var(--ctp-lavender);
/* Amber marks the mining action: the thing you highlight to look up. */
--mine: var(--ctp-yellow);
--danger: var(--ctp-red);
--ok: var(--ctp-green);
--shadow: rgba(0, 0, 0, 0.42);
--mono: 'SF Mono', 'Cascadia Code', 'JetBrains Mono', 'Fira Code', monospace;
}
* {
box-sizing: border-box;
}
html,
body {
height: 100%;
margin: 0;
overflow: hidden;
}
body {
display: flex;
flex-direction: column;
background: var(--bg);
color: var(--text);
font-family:
'M PLUS 1', 'Avenir Next', 'Hiragino Sans', 'Hiragino Kaku Gothic ProN', 'Yu Gothic', sans-serif;
font-size: 14px;
line-height: 1.55;
}
.hidden {
display: none !important;
}
/* ---------- top bar ---------- */
.topbar {
display: flex;
align-items: center;
gap: 20px;
padding: 14px 20px;
border-bottom: 1px solid var(--line);
background: linear-gradient(180deg, var(--ctp-mantle), var(--ctp-base));
flex: none;
}
.brand-title {
font-size: 11px;
font-weight: 700;
letter-spacing: 0.22em;
text-transform: uppercase;
color: var(--faint);
}
.brand-subtitle {
font-size: 20px;
font-weight: 600;
letter-spacing: -0.01em;
line-height: 1.1;
}
.search-form {
display: flex;
gap: 8px;
flex: 1 1 auto;
max-width: 560px;
}
.text-input {
width: 100%;
padding: 9px 12px;
border: 1px solid var(--line);
border-radius: 10px;
background: var(--ctp-crust);
color: var(--text);
font: inherit;
transition:
border-color 0.15s ease,
box-shadow 0.15s ease;
}
.text-input:focus-visible {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 3px rgba(138, 173, 244, 0.22);
}
.primary-button,
.ghost-button {
padding: 9px 16px;
border-radius: 10px;
border: 1px solid transparent;
font: inherit;
font-weight: 600;
cursor: pointer;
white-space: nowrap;
transition:
background 0.15s ease,
border-color 0.15s ease,
transform 0.1s ease;
}
.primary-button {
background: var(--accent);
color: var(--ctp-crust);
}
.primary-button:hover:not(:disabled) {
background: var(--accent-strong);
}
.ghost-button {
background: transparent;
border-color: var(--line);
color: var(--muted);
}
.ghost-button:hover:not(:disabled) {
color: var(--text);
border-color: var(--faint);
}
.primary-button:disabled,
.ghost-button:disabled {
opacity: 0.5;
cursor: default;
}
.primary-button:active:not(:disabled) {
transform: translateY(1px);
}
:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.tabs {
display: flex;
gap: 4px;
flex: none;
padding: 3px;
border: 1px solid var(--line);
border-radius: 12px;
background: var(--ctp-crust);
}
.tab {
padding: 8px 14px;
border: 1px solid transparent;
border-radius: 9px;
background: transparent;
color: var(--muted);
font: inherit;
font-weight: 600;
white-space: nowrap;
cursor: pointer;
transition:
background 0.15s ease,
color 0.15s ease;
}
.tab:hover[aria-selected='false'] {
color: var(--text);
}
.tab[aria-selected='true'] {
background: var(--panel-elevated);
border-color: var(--line);
color: var(--text);
}
.source-picker {
display: flex;
flex-direction: column;
gap: 3px;
flex: none;
min-width: 190px;
}
.source-label {
font-size: 10px;
letter-spacing: 0.16em;
text-transform: uppercase;
color: var(--faint);
}
/* ---------- bridge banner ---------- */
.bridge-banner {
display: flex;
align-items: center;
gap: 10px;
padding: 9px 20px;
border-bottom: 1px solid var(--line);
background: var(--panel-elevated);
font-size: 13px;
color: var(--muted);
flex: none;
}
.bridge-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--faint);
flex: none;
}
.bridge-banner[data-stage='ready'] .bridge-dot {
background: var(--ok);
}
.bridge-banner[data-stage='failed'] .bridge-dot {
background: var(--danger);
}
.bridge-banner[data-busy='true'] .bridge-dot {
background: var(--accent);
animation: pulse 1.1s ease-in-out infinite;
}
@keyframes pulse {
0%,
100% {
opacity: 0.35;
}
50% {
opacity: 1;
}
}
.bridge-meter {
width: 160px;
height: 4px;
border-radius: 2px;
background: var(--ctp-crust);
overflow: hidden;
}
.bridge-meter i {
display: block;
height: 100%;
width: 0;
background: var(--accent);
transition: width 0.2s ease;
}
/* ---------- layout ---------- */
.layout {
display: flex;
flex: 1 1 0;
min-height: 0;
}
.results {
flex: 1 1 auto;
min-width: 0;
overflow-y: auto;
padding: 20px;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(158px, 1fr));
gap: 18px;
}
.empty {
margin: 48px auto;
max-width: 44ch;
text-align: center;
color: var(--muted);
}
/* ---------- cover cards ---------- */
.card {
display: flex;
flex-direction: column;
gap: 8px;
padding: 0;
border: none;
background: none;
color: inherit;
font: inherit;
text-align: left;
cursor: pointer;
animation: card-in 0.32s ease both;
}
@keyframes card-in {
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.card-art {
position: relative;
aspect-ratio: 2 / 3;
border-radius: 12px;
overflow: hidden;
background: var(--ctp-crust);
border: 1px solid var(--line);
box-shadow: 0 10px 28px -18px var(--shadow);
transition:
border-color 0.18s ease,
transform 0.18s ease;
}
.card:hover .card-art,
.card:focus-visible .card-art {
border-color: var(--accent);
transform: translateY(-3px);
}
.card-art img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.card-art.is-empty::after {
content: attr(data-initial);
position: absolute;
inset: 0;
display: grid;
place-items: center;
font-size: 30px;
font-weight: 600;
color: var(--faint);
}
/* Which source a cover came from, shown only in an all-sources result. */
.card-source {
position: absolute;
left: 6px;
bottom: 6px;
right: 6px;
padding: 3px 7px;
border-radius: 7px;
background: rgba(24, 25, 38, 0.82);
backdrop-filter: blur(6px);
color: var(--muted);
font-size: 10px;
letter-spacing: 0.04em;
text-transform: uppercase;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.card-title {
font-size: 13px;
line-height: 1.35;
display: -webkit-box;
-webkit-line-clamp: 2;
-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;
width: clamp(140px, 18vw, 232px);
aspect-ratio: 2 / 3;
object-fit: cover;
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: 41px;
top: 6px;
bottom: 6px;
width: 1px;
background: var(--line);
}
.cue {
position: relative;
display: grid;
grid-template-columns: 40px 1fr;
gap: 14px;
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: 37px;
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;
}
.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);
}