mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-09-21 17:16:20 -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:
@@ -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',
|
||||
},
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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
@@ -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"
|
||||
>
|
||||
☆
|
||||
</button>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<nav class="tabs" role="tablist" aria-label="View">
|
||||
<button
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user