diff --git a/changes/anime-browser.md b/changes/anime-browser.md index 871f3a2d..852b7740 100644 --- a/changes/anime-browser.md +++ b/changes/anime-browser.md @@ -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. diff --git a/config.example.jsonc b/config.example.jsonc index c709b9e0..0daf73b0 100644 --- a/config.example.jsonc +++ b/config.example.jsonc @@ -633,7 +633,7 @@ "extensionsDir": "", // Directory holding Aniyomi extension .apk files. Empty uses /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 (:) 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 (:) 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 /anime-bridge. }, // Anime browser sources. SubMiner ships no extension repositories and bundles no sources; diff --git a/docs-site/anime-browser.md b/docs-site/anime-browser.md index f5cd2d15..3e767cda 100644 --- a/docs-site/anime-browser.md +++ b/docs-site/anime-browser.md @@ -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 diff --git a/docs-site/configuration.md b/docs-site/configuration.md index 9c01abac..5555f9f1 100644 --- a/docs-site/configuration.md +++ b/docs-site/configuration.md @@ -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 `/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 (`:`) 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 (`:`) 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 `/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. diff --git a/docs-site/public/config.example.jsonc b/docs-site/public/config.example.jsonc index c709b9e0..0daf73b0 100644 --- a/docs-site/public/config.example.jsonc +++ b/docs-site/public/config.example.jsonc @@ -633,7 +633,7 @@ "extensionsDir": "", // Directory holding Aniyomi extension .apk files. Empty uses /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 (:) 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 (:) 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 /anime-bridge. }, // Anime browser sources. SubMiner ships no extension repositories and bundles no sources; diff --git a/src/anime-bridge/extension-store.test.ts b/src/anime-bridge/extension-store.test.ts index 3c415251..e103b141 100644 --- a/src/anime-bridge/extension-store.test.ts +++ b/src/anime-bridge/extension-store.test.ts @@ -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', }, diff --git a/src/anime-bridge/extension-store.ts b/src/anime-bridge/extension-store.ts index baf268bc..38106964 100644 --- a/src/anime-bridge/extension-store.ts +++ b/src/anime-bridge/extension-store.ts @@ -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, }; diff --git a/src/animeui/animeui.ts b/src/animeui/animeui.ts index 3e0a6433..d1e8336a 100644 --- a/src/animeui/animeui.ts +++ b/src/animeui/animeui.ts @@ -50,7 +50,6 @@ const searchForm = el('search-form'); const searchInput = el('search-input'); const searchButton = el('search-button'); const sourceSelect = el('source-select'); -const sourceDefaultButton = el('source-default'); const grid = el('grid'); const gridEmpty = el('grid-empty'); const loadMoreButton = el('load-more'); @@ -71,8 +70,6 @@ const settingsTitle = el('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 { async function refreshSources(): Promise { 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(); diff --git a/src/animeui/extensions-panel.test.ts b/src/animeui/extensions-panel.test.ts index 36ce0e79..21d85175 100644 --- a/src/animeui/extensions-panel.test.ts +++ b/src/animeui/extensions-panel.test.ts @@ -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; diff --git a/src/animeui/extensions-panel.ts b/src/animeui/extensions-panel.ts index b27fb2ec..5e22a343 100644 --- a/src/animeui/extensions-panel.ts +++ b/src/animeui/extensions-panel.ts @@ -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 = []; + 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, 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); diff --git a/src/animeui/format.test.ts b/src/animeui/format.test.ts index f8435374..1ee24fd9 100644 --- a/src/animeui/format.test.ts +++ b/src/animeui/format.test.ts @@ -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', }), diff --git a/src/animeui/index.html b/src/animeui/index.html index 488db2b8..1b564cd4 100644 --- a/src/animeui/index.html +++ b/src/animeui/index.html @@ -33,19 +33,10 @@ -
- +
+