import { describe, el } from './dom'; import { sourceOptionLabel, summarizeSearch } from './format'; import { applySearchUpdate, idleSearchProgress, summarizeProgress } from './search-progress'; import { createExtensionsPanel } from './extensions-panel'; import { createDetailPanel } from './detail-panel'; import { renderPreferences, renderPreferencesUnavailable } from './preferences-fields'; import { beginBrowse, beginNextPage, createBrowseState, failBrowse, finishBrowse, soleBrowseRequest, takeUnseenEntries, } from './browse-state'; import type { BrowseRequest } from './browse-state'; import { ALL_SOURCES_ID } from '../types/anime-browser'; import type { AnimeBrowserAPI, AnimeBrowserBridgeState, AnimeBrowserEntry, AnimeBrowserSource, } from '../types/anime-browser'; declare global { interface Window { animeBrowserAPI: AnimeBrowserAPI; } } const api = window.animeBrowserAPI; const searchForm = el('search-form'); const searchInput = el('search-input'); const searchButton = el('search-button'); const sourceSelect = el('source-select'); const grid = el('grid'); const gridEmpty = el('grid-empty'); const loadMoreButton = el('load-more'); const banner = el('bridge-banner'); const bannerMessage = el('bridge-message'); const bannerMeter = el('bridge-meter'); const bannerMeterFill = el('bridge-meter-fill'); const statusMessage = el('status-message'); const browseTab = el('tab-browse'); const extensionsTab = el('tab-extensions'); const settingsTab = el('tab-settings'); const layout = el('layout'); const extensionsPanel = el('extensions'); const settingsPanel = el('settings'); const settingsFields = el('settings-fields'); const settingsTitle = el('settings-title'); /** Last source accepted by the main process, used to roll back a rejected change. */ let selectedSourceId: string | null = null; /* ---------- 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 detailPanel = createDetailPanel({ api, setStatus }); const BRIDGE_LABELS: Record = { 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; selectedSourceId = selectedId; } 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 detailPanel.open(entry); }); return card; } /** Drops every card and the empty-state message so a fresh page can build up. */ function resetGrid(): void { seenEntries.clear(); grid.replaceChildren(); gridEmpty.classList.add('hidden'); } function renderEntries(entries: AnimeBrowserEntry[], emptyMessage: string): void { // Which source a cover came from only matters when they are mixed together. resetGrid(); appendEntries(entries); const empty = grid.childElementCount === 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(); const unseen = takeUnseenEntries(entries, seenEntries); grid.append(...unseen.map((entry) => createCard(entry, showSource))); if (unseen.length > 0) gridEmpty.classList.add('hidden'); } /* ---------- 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(); let browseState = createBrowseState(); const inFlightBrowses = new Map(); let activeStreamRequestId = 0; const seenEntries = new Set(); function renderLoadMore(): void { const loadingNextPage = browseState.loading && browseState.page > 1; loadMoreButton.classList.toggle('hidden', !browseState.hasNextPage && !loadingNextPage); loadMoreButton.disabled = browseState.loading; loadMoreButton.textContent = loadingNextPage ? 'Loading…' : 'Load more'; } api.onSearchUpdate((update) => { const applied = applySearchUpdate(progress, update); if (!applied) return; // A superseded search; let it run out quietly. progress = applied.progress; if (applied.started) { // The runtime token does not carry the renderer request id. When calls // overlap their starts may arrive in either order, so stream only when the // association is unambiguous; the final response still backfills the grid. const request = soleBrowseRequest(inFlightBrowses); activeStreamRequestId = request?.id ?? 0; const append = request?.append === true; if (!append) resetGrid(); return; } if (activeStreamRequestId !== browseState.requestId) return; if (applied.entries.length > 0) appendEntries(applied.entries); if (!progress.done) { setStatus(summarizeProgress(progress), progress.failures.length > 0 ? 'error' : 'info'); } }); async function runBrowse(request: BrowseRequest): Promise { inFlightBrowses.set(request.id, request); renderLoadMore(); try { const result = request.query ? await api.search(request.query, request.page) : await api.getPopular(request.page); if (request.id !== browseState.requestId) return; browseState = finishBrowse(browseState, request.id, result.hasNextPage); renderLoadMore(); // Every source failing is an error, not an empty result set. if (result.entries.length === 0 && result.failures.length > 0) { const first = result.failures[0]; if (!request.append) renderEntries([], `${first?.sourceName}: ${first?.error}`); setStatus(summarizeSearch(result), 'error'); return; } // The final response backfills a host without streaming. Entries already // pushed by the stream are filtered out, including sources that repeat a // final page while another source still has more. appendEntries(result.entries); if (!request.append && grid.childElementCount === 0) { renderEntries( [], request.query ? `Nothing found for “${request.query}”.` : 'This source returned nothing.', ); } setStatus(summarizeSearch(result), result.failures.length > 0 ? 'error' : 'info'); } catch (error) { if (request.id !== browseState.requestId) return; browseState = failBrowse(browseState, request); renderLoadMore(); if (!request.append) renderEntries([], describe(error)); setStatus(describe(error), 'error'); } finally { inFlightBrowses.delete(request.id); } } async function runSearch(query: string): Promise { const started = beginBrowse(browseState, query); browseState = started.state; // A new search means new results; leave the detail page for them. if (detailPanel.isOpen()) detailPanel.close(); setStatus(query ? `Searching for “${query}”…` : 'Loading popular…'); resetGrid(); await runBrowse(started.request); } async function loadNextPage(): Promise { const started = beginNextPage(browseState); if (!started) return; browseState = started.state; setStatus(`Loading page ${started.request.page}…`); await runBrowse(started.request); } /* ---------- source settings ---------- */ async function openSettings(): Promise { 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 { 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 () => { const requestedSourceId = sourceSelect.value; try { await api.selectSource(requestedSourceId); selectedSourceId = requestedSourceId; // Settings belong to the source, so reload them rather than showing stale fields. if (currentView === 'settings') await openSettings(); await runSearch(searchInput.value.trim()); } catch (error) { if (selectedSourceId !== null) sourceSelect.value = selectedSourceId; setStatus(describe(error), 'error'); } })(); }); loadMoreButton.addEventListener('click', () => void loadNextPage()); api.onBridgeState(renderBridgeState); void (async () => { renderBridgeState({ stage: 'idle', progress: null, message: null }); try { 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'); } } catch (error) { // Without this the window keeps the "starting" banner up forever, with the // search box disabled and nothing saying why. renderBridgeState({ stage: 'failed', progress: null, message: describe(error) }); setStatus(describe(error), 'error'); } })();