feat(anime): set default source from Extensions tab

- Add default controls for installed sources and All sources
- Expose source metadata for extension rows
This commit is contained in:
2026-09-02 23:50:11 -07:00
parent 7bc9a07a52
commit 89479f7045
16 changed files with 137 additions and 118 deletions
+5
View File
@@ -152,6 +152,10 @@ test('toInstalledExtensionViews names an extension after the sources it provides
name: 'One, Two',
langs: ['en', 'ja'],
sourceCount: 2,
sources: [
{ id: 'multi:1', name: 'One' },
{ id: 'multi:2', name: 'Two' },
],
versionCode: 7,
error: null,
},
@@ -172,6 +176,7 @@ test('toInstalledExtensionViews lists an extension that loaded nothing, with its
name: 'broken',
langs: [],
sourceCount: 0,
sources: [],
versionCode: null,
error: 'dex2jar failed',
},
+1
View File
@@ -96,6 +96,7 @@ export function toInstalledExtensionViews(
name: names.length > 0 ? names.join(', ') : extension.fallbackName,
langs: [...new Set(provided.map((source) => source.lang))],
sourceCount: provided.length,
sources: provided.map((source) => ({ id: source.id, name: source.name })),
versionCode: extension.versionCode,
error: loadFailures.find((failure) => failure.pkg === extension.fallbackName)?.error ?? null,
};
+4 -44
View File
@@ -50,7 +50,6 @@ 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 sourceDefaultButton = el<HTMLButtonElement>('source-default');
const grid = el<HTMLDivElement>('grid');
const gridEmpty = el<HTMLParagraphElement>('grid-empty');
const loadMoreButton = el<HTMLButtonElement>('load-more');
@@ -71,8 +70,6 @@ const settingsTitle = el<HTMLHeadingElement>('settings-title');
/** Last source accepted by the main process, used to roll back a rejected change. */
let selectedSourceId: string | null = null;
/** Configured `anime.defaultSource`, so the star reflects the picker's current value. */
let defaultSourceId: string | null = null;
/* ---------- tabs ---------- */
@@ -171,11 +168,7 @@ function renderBridgeState(state: AnimeBrowserBridgeState): void {
* searches them together. That entry only earns its place with more than one
* source installed.
*/
function renderSources(
sources: AnimeBrowserSource[],
selectedId: string | null,
defaultId: string | null,
): void {
function renderSources(sources: AnimeBrowserSource[], selectedId: string | null): void {
const options: HTMLOptionElement[] = [];
if (sources.length > 1) {
@@ -197,23 +190,6 @@ function renderSources(
sourceSelect.replaceChildren(...options);
sourceSelect.disabled = options.length <= 1;
selectedSourceId = selectedId;
defaultSourceId = defaultId;
renderDefaultSourceButton();
}
/**
* The star is lit while the picker shows the configured default. With one
* source or none there is nothing to choose between, so it stays hidden.
*/
function renderDefaultSourceButton(): void {
const isDefault = sourceSelect.value !== '' && sourceSelect.value === defaultSourceId;
sourceDefaultButton.hidden = sourceSelect.options.length <= 1;
sourceDefaultButton.disabled = isDefault;
sourceDefaultButton.setAttribute('aria-pressed', String(isDefault));
sourceDefaultButton.textContent = isDefault ? '\u2605' : '\u2606';
sourceDefaultButton.title = isDefault
? 'The browser opens on this source'
: 'Open the browser on this source';
}
function searchingAllSources(): boolean {
@@ -426,7 +402,7 @@ async function openSettings(): Promise<void> {
async function refreshSources(): Promise<void> {
const snapshot = await api.getSnapshot();
renderSources(snapshot.sources, snapshot.selectedSourceId, snapshot.defaultSourceId);
renderSources(snapshot.sources, snapshot.selectedSourceId);
}
const extensions = createExtensionsPanel({ api, setStatus, onSourcesChanged: refreshSources });
@@ -453,7 +429,6 @@ sourceSelect.addEventListener('change', () => {
try {
await api.selectSource(requestedSourceId);
selectedSourceId = requestedSourceId;
renderDefaultSourceButton();
// Settings belong to the source, so reload them rather than showing stale fields.
if (currentView === 'settings') await openSettings();
await runSearch(searchInput.value.trim());
@@ -464,21 +439,6 @@ sourceSelect.addEventListener('change', () => {
})();
});
sourceDefaultButton.addEventListener('click', () => {
void (async () => {
const sourceId = sourceSelect.value;
try {
await api.setDefaultSource(sourceId);
defaultSourceId = sourceId;
renderDefaultSourceButton();
const label = sourceSelect.selectedOptions[0]?.textContent ?? sourceId;
setStatus(`${label} is now the default source.`, 'ok');
} catch (error) {
setStatus(describe(error), 'error');
}
})();
});
loadMoreButton.addEventListener('click', () => void loadNextPage());
bannerUpdate.addEventListener('click', () => {
@@ -497,7 +457,7 @@ bannerUpdate.addEventListener('click', () => {
}
// The bridge restarted, so the source list is fresh from disk.
const snapshot = await api.getSnapshot();
renderSources(snapshot.sources, snapshot.selectedSourceId, snapshot.defaultSourceId);
renderSources(snapshot.sources, snapshot.selectedSourceId);
if (currentView === 'extensions') await extensions.refresh();
} catch (error) {
setStatus(describe(error), 'error');
@@ -540,7 +500,7 @@ void (async () => {
renderBridgeState(state);
const snapshot = await api.getSnapshot();
renderSources(snapshot.sources, snapshot.selectedSourceId, snapshot.defaultSourceId);
renderSources(snapshot.sources, snapshot.selectedSourceId);
if (state.stage === 'ready' && snapshot.sources.length > 0) {
searchInput.focus();
+1
View File
@@ -8,6 +8,7 @@ const installed = {
name: 'Example',
langs: ['en'],
sourceCount: 1,
sources: [{ id: 'pkg.example:1', name: 'Example' }],
versionCode: 12,
error: null,
} satisfies InstalledExtensionView;
+100 -41
View File
@@ -8,6 +8,7 @@ import {
pruneSelection,
toggleLanguage,
} from './language-filter';
import { ALL_SOURCES_ID } from '../types/anime-browser';
import type {
AnimeBrowserAPI,
AvailableExtension,
@@ -189,10 +190,44 @@ export function createExtensionsPanel(options: ExtensionsPanelOptions) {
setStatus(`${extensionName} ${verb}`, 'ok');
}
/**
* The "default" tag and "Set default" buttons for a row's sources. The tag
* marks the one source (or All sources) the browser opens on; every other
* source gets a button, so exactly one can carry the tag at a time.
*/
function defaultSourceControls(
sources: Array<{ id: string; name: string }>,
defaultSourceId: string | null,
): { tags: RowOptions['tags']; actions: RowAction[] } {
const tags: NonNullable<RowOptions['tags']> = [];
const actions: RowAction[] = [];
const named = sources.length > 1;
for (const source of sources) {
if (source.id === defaultSourceId) {
tags.push({ text: named ? `default · ${source.name}` : 'default', className: 'default' });
continue;
}
actions.push({
label: named ? `Set default: ${source.name}` : 'Set default',
onClick: async () => {
try {
await api.setDefaultSource(source.id);
await refresh();
setStatus(`The browser now opens on ${source.name}.`, 'ok');
} catch (error) {
setStatus(describe(error), 'error');
}
},
});
}
return { tags, actions };
}
function renderInstalled(
installed: InstalledExtensionView[],
offeredByPkg: Map<string, AvailableExtension>,
extensionsDir: string,
defaultSourceId: string | null,
): void {
installedCount.textContent = installed.length === 0 ? '' : String(installed.length);
const updateStates = installed.map((view) =>
@@ -218,55 +253,74 @@ export function createExtensionsPanel(options: ExtensionsPanelOptions) {
return;
}
installedList.replaceChildren(
...installed.map((view) => {
const actions: RowAction[] = [];
const updateState = getExtensionUpdateState(view, offeredByPkg.get(view.pkg));
if (updateState === 'available') {
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');
}
},
});
} else if (updateState === 'current') {
actions.push({ label: 'Up to date', disabled: true });
} else if (updateState === 'unknown') {
actions.push({
label: 'Version unknown',
disabled: true,
title: 'SubMiner could not read a version code from this APK.',
});
}
const rows = installed.map((view) => {
const defaults = defaultSourceControls(view.sources, defaultSourceId);
const actions: RowAction[] = [...defaults.actions];
const updateState = getExtensionUpdateState(view, offeredByPkg.get(view.pkg));
if (updateState === 'available') {
actions.push({
label: 'Remove',
label: 'Update',
onClick: async () => {
setStatus(`Removing ${view.name}`);
setStatus(`Updating ${view.name}`);
try {
await api.removeExtension(view.pkg);
await afterChange(view.name, 'removed');
await api.installExtension(view.pkg);
await afterChange(view.name, 'updated');
} catch (error) {
setStatus(describe(error), 'error');
}
},
});
return extensionRow({
name: view.name,
sub: view.error ?? describeInstalled(view),
iconUrl: iconsByPkg.get(view.pkg) ?? null,
isError: view.error !== null,
tags: view.error === null ? [] : [{ text: 'failed', className: 'nsfw' }],
actions,
} else if (updateState === 'current') {
actions.push({ label: 'Up to date', disabled: true });
} else if (updateState === 'unknown') {
actions.push({
label: 'Version unknown',
disabled: true,
title: 'SubMiner could not read a version code from this APK.',
});
}),
);
}
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),
iconUrl: iconsByPkg.get(view.pkg) ?? null,
isError: view.error !== null,
tags: view.error === null ? defaults.tags : [{ text: 'failed', className: 'nsfw' }],
actions,
});
});
// "All sources" is a picker entry too, so it can be the default like any
// source. It only exists with more than one source installed.
const sourceTotal = installed.reduce((sum, view) => sum + view.sourceCount, 0);
if (sourceTotal > 1) {
const defaults = defaultSourceControls(
[{ id: ALL_SOURCES_ID, name: 'All sources' }],
defaultSourceId,
);
rows.unshift(
extensionRow({
name: 'All sources',
sub: `Search every installed source at once · ${sourceTotal} sources`,
tags: defaults.tags,
actions: defaults.actions,
}),
);
}
installedList.replaceChildren(...rows);
}
function renderRepos(repos: string[]): void {
@@ -412,7 +466,12 @@ export function createExtensionsPanel(options: ExtensionsPanelOptions) {
// The catalogue is the only source of icons, so an installed extension can
// only show one while a repository still carries its package.
iconsByPkg = buildIconIndex(available.extensions);
renderInstalled(snapshot.installed, offeredByPkg, snapshot.extensionsDir);
renderInstalled(
snapshot.installed,
offeredByPkg,
snapshot.extensionsDir,
snapshot.defaultSourceId,
);
// Installed extensions have their own section; leaving them here too would
// list every one of them twice.
installable = available.extensions.filter((extension) => !extension.installed);
+5
View File
@@ -51,6 +51,10 @@ test('describeInstalled reports sources and languages when the extension loaded'
name: 'One, Two',
langs: ['en', 'ja'],
sourceCount: 2,
sources: [
{ id: 'multi:1', name: 'One' },
{ id: 'multi:2', name: 'Two' },
],
versionCode: 1,
error: null,
}),
@@ -65,6 +69,7 @@ test('describeInstalled falls back to the package alone when nothing loaded', ()
name: 'broken',
langs: [],
sourceCount: 0,
sources: [],
versionCode: null,
error: 'boom',
}),
+3 -12
View File
@@ -33,19 +33,10 @@
<button class="primary-button" id="search-button" type="submit">Search</button>
</form>
<div class="source-picker">
<label class="source-label" for="source-select">Source</label>
<label class="source-picker">
<span class="source-label">Source</span>
<select class="text-input" id="source-select" aria-label="Extension source"></select>
<button
class="ghost-button default-source-button"
id="source-default"
type="button"
title="Open the browser on this source"
aria-label="Set as default source"
>
&#9734;
</button>
</div>
</label>
<nav class="tabs" role="tablist" aria-label="View">
<button
+5
View File
@@ -312,6 +312,11 @@
color: var(--ok);
}
.ext-tag.default {
border-color: var(--accent);
color: var(--accent);
}
.ext-tag.nsfw {
border-color: rgba(237, 135, 150, 0.4);
color: var(--danger);
-12
View File
@@ -125,7 +125,6 @@ body {
height: 52px;
flex: none;
object-fit: contain;
image-rendering: pixelated;
filter: drop-shadow(0 5px 9px rgba(0, 0, 0, 0.28));
}
@@ -276,17 +275,6 @@ body {
width: auto;
}
.default-source-button {
padding: 6px 9px;
font-size: 15px;
line-height: 1;
}
.default-source-button[aria-pressed='true'] {
color: var(--accent);
border-color: var(--accent);
}
/* ---------- bridge banner ---------- */
.bridge-banner {
@@ -621,7 +621,7 @@ export function buildIntegrationConfigOptionRegistry(
kind: 'string',
defaultValue: defaultConfig.anime.defaultSource,
description:
'Source the Anime Browser selects when it opens: a source id (<package>:<source>) or "all" for every installed source. Empty selects the first installed source. The star beside the Source picker writes this value.',
'Source the Anime Browser selects when it opens: a source id (<package>:<source>) or "all" for every installed source. Empty selects the first installed source. "Set default" in the Extensions tab writes this value.',
},
{
path: 'anime.bridgeDir',
+2
View File
@@ -208,6 +208,8 @@ export interface InstalledExtensionView {
langs: string[];
/** How many sources it provides; 0 when it failed to load. */
sourceCount: number;
/** Each source it provides, so the Extensions tab can pick a default. */
sources: Array<{ id: string; name: string }>;
/** Read from AndroidManifest.xml; null only for an invalid or unusual APK. */
versionCode: number | null;
/** Why it failed to load, or null when it loaded. */