mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-08-02 19:21:34 -07:00
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:
@@ -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');
|
||||
}
|
||||
})();
|
||||
@@ -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*/, '');
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
@@ -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(' · ');
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user