mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-09-03 11:54:30 -07:00
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:
@@ -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`.
|
- 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 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.
|
- 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.
|
- 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.
|
- 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.
|
- 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.
|
||||||
|
|||||||
@@ -633,7 +633,7 @@
|
|||||||
"extensionsDir": "", // Directory holding Aniyomi extension .apk files. Empty uses <userData>/anime-extensions.
|
"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.
|
"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.
|
"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.
|
"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;
|
}, // Anime browser sources. SubMiner ships no extension repositories and bundles no sources;
|
||||||
|
|
||||||
|
|||||||
@@ -119,10 +119,12 @@ one bad APK will not hide the rest.
|
|||||||
### Default source
|
### Default source
|
||||||
|
|
||||||
The browser opens on the first installed source. To open on a different one,
|
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
|
press **Set default** on its row in the Installed list; the row then carries a
|
||||||
written to `anime.defaultSource` and applies the next time a browser window or
|
**default** tag, and only one source can carry it. The choice is written to
|
||||||
the in-player modal opens. **All sources** can be the default too. A default
|
`anime.defaultSource` and applies the next time a browser window or the
|
||||||
that is no longer installed falls back to the first source.
|
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
|
## Searching every source at once
|
||||||
|
|
||||||
|
|||||||
@@ -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.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.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.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). |
|
| `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.
|
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.
|
||||||
|
|||||||
@@ -633,7 +633,7 @@
|
|||||||
"extensionsDir": "", // Directory holding Aniyomi extension .apk files. Empty uses <userData>/anime-extensions.
|
"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.
|
"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.
|
"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.
|
"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;
|
}, // Anime browser sources. SubMiner ships no extension repositories and bundles no sources;
|
||||||
|
|
||||||
|
|||||||
@@ -152,6 +152,10 @@ test('toInstalledExtensionViews names an extension after the sources it provides
|
|||||||
name: 'One, Two',
|
name: 'One, Two',
|
||||||
langs: ['en', 'ja'],
|
langs: ['en', 'ja'],
|
||||||
sourceCount: 2,
|
sourceCount: 2,
|
||||||
|
sources: [
|
||||||
|
{ id: 'multi:1', name: 'One' },
|
||||||
|
{ id: 'multi:2', name: 'Two' },
|
||||||
|
],
|
||||||
versionCode: 7,
|
versionCode: 7,
|
||||||
error: null,
|
error: null,
|
||||||
},
|
},
|
||||||
@@ -172,6 +176,7 @@ test('toInstalledExtensionViews lists an extension that loaded nothing, with its
|
|||||||
name: 'broken',
|
name: 'broken',
|
||||||
langs: [],
|
langs: [],
|
||||||
sourceCount: 0,
|
sourceCount: 0,
|
||||||
|
sources: [],
|
||||||
versionCode: null,
|
versionCode: null,
|
||||||
error: 'dex2jar failed',
|
error: 'dex2jar failed',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -96,6 +96,7 @@ export function toInstalledExtensionViews(
|
|||||||
name: names.length > 0 ? names.join(', ') : extension.fallbackName,
|
name: names.length > 0 ? names.join(', ') : extension.fallbackName,
|
||||||
langs: [...new Set(provided.map((source) => source.lang))],
|
langs: [...new Set(provided.map((source) => source.lang))],
|
||||||
sourceCount: provided.length,
|
sourceCount: provided.length,
|
||||||
|
sources: provided.map((source) => ({ id: source.id, name: source.name })),
|
||||||
versionCode: extension.versionCode,
|
versionCode: extension.versionCode,
|
||||||
error: loadFailures.find((failure) => failure.pkg === extension.fallbackName)?.error ?? null,
|
error: loadFailures.find((failure) => failure.pkg === extension.fallbackName)?.error ?? null,
|
||||||
};
|
};
|
||||||
|
|||||||
+4
-44
@@ -50,7 +50,6 @@ const searchForm = el<HTMLFormElement>('search-form');
|
|||||||
const searchInput = el<HTMLInputElement>('search-input');
|
const searchInput = el<HTMLInputElement>('search-input');
|
||||||
const searchButton = el<HTMLButtonElement>('search-button');
|
const searchButton = el<HTMLButtonElement>('search-button');
|
||||||
const sourceSelect = el<HTMLSelectElement>('source-select');
|
const sourceSelect = el<HTMLSelectElement>('source-select');
|
||||||
const sourceDefaultButton = el<HTMLButtonElement>('source-default');
|
|
||||||
const grid = el<HTMLDivElement>('grid');
|
const grid = el<HTMLDivElement>('grid');
|
||||||
const gridEmpty = el<HTMLParagraphElement>('grid-empty');
|
const gridEmpty = el<HTMLParagraphElement>('grid-empty');
|
||||||
const loadMoreButton = el<HTMLButtonElement>('load-more');
|
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. */
|
/** Last source accepted by the main process, used to roll back a rejected change. */
|
||||||
let selectedSourceId: string | null = null;
|
let selectedSourceId: string | null = null;
|
||||||
/** Configured `anime.defaultSource`, so the star reflects the picker's current value. */
|
|
||||||
let defaultSourceId: string | null = null;
|
|
||||||
|
|
||||||
/* ---------- tabs ---------- */
|
/* ---------- tabs ---------- */
|
||||||
|
|
||||||
@@ -171,11 +168,7 @@ function renderBridgeState(state: AnimeBrowserBridgeState): void {
|
|||||||
* searches them together. That entry only earns its place with more than one
|
* searches them together. That entry only earns its place with more than one
|
||||||
* source installed.
|
* source installed.
|
||||||
*/
|
*/
|
||||||
function renderSources(
|
function renderSources(sources: AnimeBrowserSource[], selectedId: string | null): void {
|
||||||
sources: AnimeBrowserSource[],
|
|
||||||
selectedId: string | null,
|
|
||||||
defaultId: string | null,
|
|
||||||
): void {
|
|
||||||
const options: HTMLOptionElement[] = [];
|
const options: HTMLOptionElement[] = [];
|
||||||
|
|
||||||
if (sources.length > 1) {
|
if (sources.length > 1) {
|
||||||
@@ -197,23 +190,6 @@ function renderSources(
|
|||||||
sourceSelect.replaceChildren(...options);
|
sourceSelect.replaceChildren(...options);
|
||||||
sourceSelect.disabled = options.length <= 1;
|
sourceSelect.disabled = options.length <= 1;
|
||||||
selectedSourceId = selectedId;
|
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 {
|
function searchingAllSources(): boolean {
|
||||||
@@ -426,7 +402,7 @@ async function openSettings(): Promise<void> {
|
|||||||
|
|
||||||
async function refreshSources(): Promise<void> {
|
async function refreshSources(): Promise<void> {
|
||||||
const snapshot = await api.getSnapshot();
|
const snapshot = await api.getSnapshot();
|
||||||
renderSources(snapshot.sources, snapshot.selectedSourceId, snapshot.defaultSourceId);
|
renderSources(snapshot.sources, snapshot.selectedSourceId);
|
||||||
}
|
}
|
||||||
|
|
||||||
const extensions = createExtensionsPanel({ api, setStatus, onSourcesChanged: refreshSources });
|
const extensions = createExtensionsPanel({ api, setStatus, onSourcesChanged: refreshSources });
|
||||||
@@ -453,7 +429,6 @@ sourceSelect.addEventListener('change', () => {
|
|||||||
try {
|
try {
|
||||||
await api.selectSource(requestedSourceId);
|
await api.selectSource(requestedSourceId);
|
||||||
selectedSourceId = requestedSourceId;
|
selectedSourceId = requestedSourceId;
|
||||||
renderDefaultSourceButton();
|
|
||||||
// Settings belong to the source, so reload them rather than showing stale fields.
|
// Settings belong to the source, so reload them rather than showing stale fields.
|
||||||
if (currentView === 'settings') await openSettings();
|
if (currentView === 'settings') await openSettings();
|
||||||
await runSearch(searchInput.value.trim());
|
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());
|
loadMoreButton.addEventListener('click', () => void loadNextPage());
|
||||||
|
|
||||||
bannerUpdate.addEventListener('click', () => {
|
bannerUpdate.addEventListener('click', () => {
|
||||||
@@ -497,7 +457,7 @@ bannerUpdate.addEventListener('click', () => {
|
|||||||
}
|
}
|
||||||
// The bridge restarted, so the source list is fresh from disk.
|
// The bridge restarted, so the source list is fresh from disk.
|
||||||
const snapshot = await api.getSnapshot();
|
const snapshot = await api.getSnapshot();
|
||||||
renderSources(snapshot.sources, snapshot.selectedSourceId, snapshot.defaultSourceId);
|
renderSources(snapshot.sources, snapshot.selectedSourceId);
|
||||||
if (currentView === 'extensions') await extensions.refresh();
|
if (currentView === 'extensions') await extensions.refresh();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setStatus(describe(error), 'error');
|
setStatus(describe(error), 'error');
|
||||||
@@ -540,7 +500,7 @@ void (async () => {
|
|||||||
renderBridgeState(state);
|
renderBridgeState(state);
|
||||||
|
|
||||||
const snapshot = await api.getSnapshot();
|
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) {
|
if (state.stage === 'ready' && snapshot.sources.length > 0) {
|
||||||
searchInput.focus();
|
searchInput.focus();
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ const installed = {
|
|||||||
name: 'Example',
|
name: 'Example',
|
||||||
langs: ['en'],
|
langs: ['en'],
|
||||||
sourceCount: 1,
|
sourceCount: 1,
|
||||||
|
sources: [{ id: 'pkg.example:1', name: 'Example' }],
|
||||||
versionCode: 12,
|
versionCode: 12,
|
||||||
error: null,
|
error: null,
|
||||||
} satisfies InstalledExtensionView;
|
} satisfies InstalledExtensionView;
|
||||||
|
|||||||
+100
-41
@@ -8,6 +8,7 @@ import {
|
|||||||
pruneSelection,
|
pruneSelection,
|
||||||
toggleLanguage,
|
toggleLanguage,
|
||||||
} from './language-filter';
|
} from './language-filter';
|
||||||
|
import { ALL_SOURCES_ID } from '../types/anime-browser';
|
||||||
import type {
|
import type {
|
||||||
AnimeBrowserAPI,
|
AnimeBrowserAPI,
|
||||||
AvailableExtension,
|
AvailableExtension,
|
||||||
@@ -189,10 +190,44 @@ export function createExtensionsPanel(options: ExtensionsPanelOptions) {
|
|||||||
setStatus(`${extensionName} ${verb}`, 'ok');
|
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(
|
function renderInstalled(
|
||||||
installed: InstalledExtensionView[],
|
installed: InstalledExtensionView[],
|
||||||
offeredByPkg: Map<string, AvailableExtension>,
|
offeredByPkg: Map<string, AvailableExtension>,
|
||||||
extensionsDir: string,
|
extensionsDir: string,
|
||||||
|
defaultSourceId: string | null,
|
||||||
): void {
|
): void {
|
||||||
installedCount.textContent = installed.length === 0 ? '' : String(installed.length);
|
installedCount.textContent = installed.length === 0 ? '' : String(installed.length);
|
||||||
const updateStates = installed.map((view) =>
|
const updateStates = installed.map((view) =>
|
||||||
@@ -218,55 +253,74 @@ export function createExtensionsPanel(options: ExtensionsPanelOptions) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
installedList.replaceChildren(
|
const rows = installed.map((view) => {
|
||||||
...installed.map((view) => {
|
const defaults = defaultSourceControls(view.sources, defaultSourceId);
|
||||||
const actions: RowAction[] = [];
|
const actions: RowAction[] = [...defaults.actions];
|
||||||
const updateState = getExtensionUpdateState(view, offeredByPkg.get(view.pkg));
|
const updateState = getExtensionUpdateState(view, offeredByPkg.get(view.pkg));
|
||||||
if (updateState === 'available') {
|
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.',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
actions.push({
|
actions.push({
|
||||||
label: 'Remove',
|
label: 'Update',
|
||||||
onClick: async () => {
|
onClick: async () => {
|
||||||
setStatus(`Removing ${view.name}…`);
|
setStatus(`Updating ${view.name}…`);
|
||||||
try {
|
try {
|
||||||
await api.removeExtension(view.pkg);
|
await api.installExtension(view.pkg);
|
||||||
await afterChange(view.name, 'removed');
|
await afterChange(view.name, 'updated');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setStatus(describe(error), 'error');
|
setStatus(describe(error), 'error');
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
} else if (updateState === 'current') {
|
||||||
return extensionRow({
|
actions.push({ label: 'Up to date', disabled: true });
|
||||||
name: view.name,
|
} else if (updateState === 'unknown') {
|
||||||
sub: view.error ?? describeInstalled(view),
|
actions.push({
|
||||||
iconUrl: iconsByPkg.get(view.pkg) ?? null,
|
label: 'Version unknown',
|
||||||
isError: view.error !== null,
|
disabled: true,
|
||||||
tags: view.error === null ? [] : [{ text: 'failed', className: 'nsfw' }],
|
title: 'SubMiner could not read a version code from this APK.',
|
||||||
actions,
|
|
||||||
});
|
});
|
||||||
}),
|
}
|
||||||
);
|
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 {
|
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
|
// The catalogue is the only source of icons, so an installed extension can
|
||||||
// only show one while a repository still carries its package.
|
// only show one while a repository still carries its package.
|
||||||
iconsByPkg = buildIconIndex(available.extensions);
|
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
|
// Installed extensions have their own section; leaving them here too would
|
||||||
// list every one of them twice.
|
// list every one of them twice.
|
||||||
installable = available.extensions.filter((extension) => !extension.installed);
|
installable = available.extensions.filter((extension) => !extension.installed);
|
||||||
|
|||||||
@@ -51,6 +51,10 @@ test('describeInstalled reports sources and languages when the extension loaded'
|
|||||||
name: 'One, Two',
|
name: 'One, Two',
|
||||||
langs: ['en', 'ja'],
|
langs: ['en', 'ja'],
|
||||||
sourceCount: 2,
|
sourceCount: 2,
|
||||||
|
sources: [
|
||||||
|
{ id: 'multi:1', name: 'One' },
|
||||||
|
{ id: 'multi:2', name: 'Two' },
|
||||||
|
],
|
||||||
versionCode: 1,
|
versionCode: 1,
|
||||||
error: null,
|
error: null,
|
||||||
}),
|
}),
|
||||||
@@ -65,6 +69,7 @@ test('describeInstalled falls back to the package alone when nothing loaded', ()
|
|||||||
name: 'broken',
|
name: 'broken',
|
||||||
langs: [],
|
langs: [],
|
||||||
sourceCount: 0,
|
sourceCount: 0,
|
||||||
|
sources: [],
|
||||||
versionCode: null,
|
versionCode: null,
|
||||||
error: 'boom',
|
error: 'boom',
|
||||||
}),
|
}),
|
||||||
|
|||||||
+3
-12
@@ -33,19 +33,10 @@
|
|||||||
<button class="primary-button" id="search-button" type="submit">Search</button>
|
<button class="primary-button" id="search-button" type="submit">Search</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div class="source-picker">
|
<label class="source-picker">
|
||||||
<label class="source-label" for="source-select">Source</label>
|
<span class="source-label">Source</span>
|
||||||
<select class="text-input" id="source-select" aria-label="Extension source"></select>
|
<select class="text-input" id="source-select" aria-label="Extension source"></select>
|
||||||
<button
|
</label>
|
||||||
class="ghost-button default-source-button"
|
|
||||||
id="source-default"
|
|
||||||
type="button"
|
|
||||||
title="Open the browser on this source"
|
|
||||||
aria-label="Set as default source"
|
|
||||||
>
|
|
||||||
☆
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<nav class="tabs" role="tablist" aria-label="View">
|
<nav class="tabs" role="tablist" aria-label="View">
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -312,6 +312,11 @@
|
|||||||
color: var(--ok);
|
color: var(--ok);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ext-tag.default {
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
.ext-tag.nsfw {
|
.ext-tag.nsfw {
|
||||||
border-color: rgba(237, 135, 150, 0.4);
|
border-color: rgba(237, 135, 150, 0.4);
|
||||||
color: var(--danger);
|
color: var(--danger);
|
||||||
|
|||||||
@@ -125,7 +125,6 @@ body {
|
|||||||
height: 52px;
|
height: 52px;
|
||||||
flex: none;
|
flex: none;
|
||||||
object-fit: contain;
|
object-fit: contain;
|
||||||
image-rendering: pixelated;
|
|
||||||
filter: drop-shadow(0 5px 9px rgba(0, 0, 0, 0.28));
|
filter: drop-shadow(0 5px 9px rgba(0, 0, 0, 0.28));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -276,17 +275,6 @@ body {
|
|||||||
width: auto;
|
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 ---------- */
|
||||||
|
|
||||||
.bridge-banner {
|
.bridge-banner {
|
||||||
|
|||||||
@@ -621,7 +621,7 @@ export function buildIntegrationConfigOptionRegistry(
|
|||||||
kind: 'string',
|
kind: 'string',
|
||||||
defaultValue: defaultConfig.anime.defaultSource,
|
defaultValue: defaultConfig.anime.defaultSource,
|
||||||
description:
|
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',
|
path: 'anime.bridgeDir',
|
||||||
|
|||||||
@@ -208,6 +208,8 @@ export interface InstalledExtensionView {
|
|||||||
langs: string[];
|
langs: string[];
|
||||||
/** How many sources it provides; 0 when it failed to load. */
|
/** How many sources it provides; 0 when it failed to load. */
|
||||||
sourceCount: number;
|
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. */
|
/** Read from AndroidManifest.xml; null only for an invalid or unusual APK. */
|
||||||
versionCode: number | null;
|
versionCode: number | null;
|
||||||
/** Why it failed to load, or null when it loaded. */
|
/** Why it failed to load, or null when it loaded. */
|
||||||
|
|||||||
Reference in New Issue
Block a user