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
+1 -1
View File
@@ -10,7 +10,7 @@ area: anime
- Repository URLs only need to be an https URL to a `.json` index; the file name is not restricted to `index.min.json`.
- The source picker offers "All sources", which searches every installed source at once. Results stream in as each source answers, with per-source progress in the status bar. Results are tagged with their source, failures do not blank the grid, and **Load more** appends later pages without duplicating streamed entries.
- The Extensions tab opens with an Installed section listing every extension on disk with Remove, including ones added by hand or whose repository has since been removed. It compares APK and repository version codes, enables Update only for newer builds, marks current extensions as Up to date, and offers Update all when multiple updates are waiting.
- Added `anime.repos`, `anime.extensionsDir`, `anime.preferredQuality`, and `anime.defaultSource` config keys; a star beside the Source picker saves the current source (or All sources) as the one the browser opens on. SubMiner ships no extension repositories and performs no discovery.
- Added `anime.repos`, `anime.extensionsDir`, `anime.preferredQuality`, and `anime.defaultSource` config keys; **Set default** on an Installed row in the Extensions tab makes that source (or All sources) the one the browser opens on, and the current default carries a tag. SubMiner ships no extension repositories and performs no discovery.
- The settings app now groups Anime Browser config under an **Aniyomi** section in Integrations. A new `anime.autoOpenJimaku` option pauses newly loaded Anime Browser episodes, closes the in-player browser, opens Jimaku with the episode details filled in, and resumes after a subtitle loads or the modal closes without overriding playback that was already paused.
- Anime playback targets Japanese audio: dub-labelled entries are skipped when the source offers an alternative, `alang` prefers Japanese, and the source's own audio and subtitle tracks are loaded into mpv (Japanese selected) instead of being discarded, so all of them can be switched from mpv's track menu.
- The primary subtitle slot stays reserved for Japanese: a source that only has, say, English subtitles gets them added with a normalized language tag (`English``en`) but not selected, so the regular `secondarySub` auto-load can route them to the secondary slot instead.
+1 -1
View File
@@ -633,7 +633,7 @@
"extensionsDir": "", // Directory holding Aniyomi extension .apk files. Empty uses <userData>/anime-extensions.
"repos": [], // Extension repository index URLs (any https .json index, e.g. https://.../index.min.json). Empty by default; SubMiner ships no repositories.
"preferredQuality": "", // Preferred stream quality label, matched as a substring (for example: 1080). Empty uses the source order.
"defaultSource": "", // 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.
"defaultSource": "", // 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.
"bridgeDir": "" // Directory holding an M-Extension-Server bundle (java runtime plus server jar) to run instead of the copy SubMiner downloads. Empty checks the package-manager install (Arch: mangatan-extension-server), then <userData>/anime-bridge.
}, // Anime browser sources. SubMiner ships no extension repositories and bundles no sources;
+6 -4
View File
@@ -119,10 +119,12 @@ one bad APK will not hide the rest.
### Default source
The browser opens on the first installed source. To open on a different one,
pick it in the **Source** picker and press the star beside it; the choice is
written to `anime.defaultSource` and applies the next time a browser window or
the in-player modal opens. **All sources** can be the default too. A default
that is no longer installed falls back to the first source.
press **Set default** on its row in the Installed list; the row then carries a
**default** tag, and only one source can carry it. The choice is written to
`anime.defaultSource` and applies the next time a browser window or the
in-player modal opens. With more than one source installed, an **All sources**
row at the top of the list can be the default too. A default that is no longer
installed falls back to the first source.
## Searching every source at once
+1 -1
View File
@@ -1180,7 +1180,7 @@ Sources for the [anime browser](/anime-browser). SubMiner ships no extension rep
| `anime.extensionsDir` | `string` | `""` | Directory holding Aniyomi extension `.apk` files. Empty uses `<userData>/anime-extensions`. |
| `anime.repos` | `string[]` | `[]` | Extension repository index URLs. Any `https` URL ending in `.json` works; `index.min.json` is only the common name. |
| `anime.preferredQuality` | `string` | `""` | Preferred stream quality label, matched as a substring (for example `1080`). Empty keeps the source's own order. A Japanese-audio entry always outranks a higher-quality dub. |
| `anime.defaultSource` | `string` | `""` | 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. |
| `anime.defaultSource` | `string` | `""` | 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. |
| `anime.bridgeDir` | `string` | `""` | Directory holding an M-Extension-Server bundle (Java runtime plus server jar) to run instead of the downloaded copy. Empty checks the package-manager install first, then `<userData>/anime-bridge`. See [the bridge](anime-browser.md#the-bridge). |
Repositories added from the browser's Extensions tab are written back to `anime.repos`, so the list can also be kept in a dotfile. Changes apply the next time the anime browser opens.
+1 -1
View File
@@ -633,7 +633,7 @@
"extensionsDir": "", // Directory holding Aniyomi extension .apk files. Empty uses <userData>/anime-extensions.
"repos": [], // Extension repository index URLs (any https .json index, e.g. https://.../index.min.json). Empty by default; SubMiner ships no repositories.
"preferredQuality": "", // Preferred stream quality label, matched as a substring (for example: 1080). Empty uses the source order.
"defaultSource": "", // 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.
"defaultSource": "", // 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.
"bridgeDir": "" // Directory holding an M-Extension-Server bundle (java runtime plus server jar) to run instead of the copy SubMiner downloads. Empty checks the package-manager install (Arch: mangatan-extension-server), then <userData>/anime-bridge.
}, // Anime browser sources. SubMiner ships no extension repositories and bundles no sources;
+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;
+64 -5
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,9 +253,9 @@ export function createExtensionsPanel(options: ExtensionsPanelOptions) {
return;
}
installedList.replaceChildren(
...installed.map((view) => {
const actions: RowAction[] = [];
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({
@@ -262,13 +297,32 @@ export function createExtensionsPanel(options: ExtensionsPanelOptions) {
sub: view.error ?? describeInstalled(view),
iconUrl: iconsByPkg.get(view.pkg) ?? null,
isError: view.error !== null,
tags: view.error === null ? [] : [{ text: 'failed', className: 'nsfw' }],
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 {
repoList.replaceChildren(
...repos.map((repoUrl) =>
@@ -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. */