mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-09-02 23:54:29 -07:00
feat(anime): remember the default Anime Browser source
- Add `anime.defaultSource` with installed-source fallback - Let the source picker save a source or All sources as default
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`.
|
||||
- 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`, and `anime.preferredQuality` config keys. SubMiner ships no extension repositories and performs no discovery.
|
||||
- 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.
|
||||
- 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.
|
||||
|
||||
@@ -633,6 +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.
|
||||
"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;
|
||||
|
||||
|
||||
@@ -116,6 +116,14 @@ missing.
|
||||
An extension that fails to load is skipped rather than blocking the others, so
|
||||
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.
|
||||
|
||||
## Searching every source at once
|
||||
|
||||
With more than one source installed, the **Source** picker gains an
|
||||
@@ -201,11 +209,12 @@ there is nowhere to write them and the status bar says so.
|
||||
## Settings
|
||||
|
||||
| Key | Purpose |
|
||||
| ------------------------ | ----------------------------------------------------------------------- |
|
||||
| ------------------------ | ------------------------------------------------------------------------ |
|
||||
| `anime.autoOpenJimaku` | Pause new episodes and open Jimaku for Japanese subtitles. |
|
||||
| `anime.repos` | Repository index URLs. Empty by default. |
|
||||
| `anime.extensionsDir` | Where APKs are read from. Empty uses `<userData>/anime-extensions`. |
|
||||
| `anime.preferredQuality` | Preferred stream label, matched as a substring (for example `1080`). |
|
||||
| `anime.defaultSource` | Source the browser opens on: a source id or `all`. Empty uses the first. |
|
||||
| `anime.bridgeDir` | A bridge bundle to run instead of the downloaded one. Empty by default. |
|
||||
|
||||
Enable `anime.autoOpenJimaku` to hand each newly loaded Anime Browser episode
|
||||
|
||||
@@ -1168,7 +1168,8 @@ Sources for the [anime browser](/anime-browser). SubMiner ships no extension rep
|
||||
"autoOpenJimaku": false,
|
||||
"extensionsDir": "",
|
||||
"repos": [],
|
||||
"preferredQuality": ""
|
||||
"preferredQuality": "",
|
||||
"defaultSource": ""
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -1179,6 +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.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.
|
||||
|
||||
@@ -633,6 +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.
|
||||
"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;
|
||||
|
||||
|
||||
+44
-4
@@ -50,6 +50,7 @@ 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');
|
||||
@@ -70,6 +71,8 @@ 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 ---------- */
|
||||
|
||||
@@ -168,7 +171,11 @@ 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): void {
|
||||
function renderSources(
|
||||
sources: AnimeBrowserSource[],
|
||||
selectedId: string | null,
|
||||
defaultId: string | null,
|
||||
): void {
|
||||
const options: HTMLOptionElement[] = [];
|
||||
|
||||
if (sources.length > 1) {
|
||||
@@ -190,6 +197,23 @@ function renderSources(sources: AnimeBrowserSource[], selectedId: string | null)
|
||||
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 {
|
||||
@@ -402,7 +426,7 @@ async function openSettings(): Promise<void> {
|
||||
|
||||
async function refreshSources(): Promise<void> {
|
||||
const snapshot = await api.getSnapshot();
|
||||
renderSources(snapshot.sources, snapshot.selectedSourceId);
|
||||
renderSources(snapshot.sources, snapshot.selectedSourceId, snapshot.defaultSourceId);
|
||||
}
|
||||
|
||||
const extensions = createExtensionsPanel({ api, setStatus, onSourcesChanged: refreshSources });
|
||||
@@ -429,6 +453,7 @@ 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());
|
||||
@@ -439,6 +464,21 @@ 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', () => {
|
||||
@@ -457,7 +497,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);
|
||||
renderSources(snapshot.sources, snapshot.selectedSourceId, snapshot.defaultSourceId);
|
||||
if (currentView === 'extensions') await extensions.refresh();
|
||||
} catch (error) {
|
||||
setStatus(describe(error), 'error');
|
||||
@@ -500,7 +540,7 @@ void (async () => {
|
||||
renderBridgeState(state);
|
||||
|
||||
const snapshot = await api.getSnapshot();
|
||||
renderSources(snapshot.sources, snapshot.selectedSourceId);
|
||||
renderSources(snapshot.sources, snapshot.selectedSourceId, snapshot.defaultSourceId);
|
||||
|
||||
if (state.stage === 'ready' && snapshot.sources.length > 0) {
|
||||
searchInput.focus();
|
||||
|
||||
+12
-3
@@ -33,10 +33,19 @@
|
||||
<button class="primary-button" id="search-button" type="submit">Search</button>
|
||||
</form>
|
||||
|
||||
<label class="source-picker">
|
||||
<span class="source-label">Source</span>
|
||||
<div class="source-picker">
|
||||
<label class="source-label" for="source-select">Source</label>
|
||||
<select class="text-input" id="source-select" aria-label="Extension source"></select>
|
||||
</label>
|
||||
<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>
|
||||
|
||||
<nav class="tabs" role="tablist" aria-label="View">
|
||||
<button
|
||||
|
||||
@@ -276,6 +276,17 @@ 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 {
|
||||
|
||||
@@ -108,6 +108,7 @@ export const INTEGRATIONS_DEFAULT_CONFIG: Pick<
|
||||
extensionsDir: '',
|
||||
repos: [],
|
||||
preferredQuality: '',
|
||||
defaultSource: '',
|
||||
bridgeDir: '',
|
||||
},
|
||||
jimaku: {
|
||||
|
||||
@@ -616,6 +616,13 @@ export function buildIntegrationConfigOptionRegistry(
|
||||
description:
|
||||
'Preferred stream quality label, matched as a substring (for example: 1080). Empty uses the source order.',
|
||||
},
|
||||
{
|
||||
path: 'anime.defaultSource',
|
||||
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.',
|
||||
},
|
||||
{
|
||||
path: 'anime.bridgeDir',
|
||||
kind: 'string',
|
||||
|
||||
@@ -86,3 +86,15 @@ test('resolveConfig warns for an invalid Anime Browser Jimaku handoff option', (
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('resolveConfig trims the Anime Browser default source and warns on a non-string', () => {
|
||||
const trimmed = resolveConfig({ anime: { defaultSource: ' all ' } });
|
||||
assert.equal(trimmed.resolved.anime.defaultSource, 'all');
|
||||
assert.deepEqual(trimmed.warnings, []);
|
||||
|
||||
const invalid = resolveConfig({ anime: { defaultSource: 3 as never } });
|
||||
assert.equal(invalid.resolved.anime.defaultSource, '');
|
||||
assert.deepEqual(invalid.warnings, [
|
||||
{ path: 'anime.defaultSource', value: 3, fallback: '', message: 'Expected string.' },
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -388,6 +388,18 @@ export function applyIntegrationConfig(context: ResolveContext): void {
|
||||
);
|
||||
}
|
||||
|
||||
const defaultSource = asString(src.anime.defaultSource);
|
||||
if (defaultSource !== undefined) {
|
||||
resolved.anime.defaultSource = defaultSource.trim();
|
||||
} else if (src.anime.defaultSource !== undefined) {
|
||||
warn(
|
||||
'anime.defaultSource',
|
||||
src.anime.defaultSource,
|
||||
resolved.anime.defaultSource,
|
||||
'Expected string.',
|
||||
);
|
||||
}
|
||||
|
||||
if (Array.isArray(src.anime.repos)) {
|
||||
const repos: string[] = [];
|
||||
for (const entry of src.anime.repos) {
|
||||
|
||||
@@ -3426,6 +3426,9 @@ const animeBrowserApplicationRuntime = createAnimeBrowserApplicationRuntime({
|
||||
repos: () => configService.getConfig().anime?.repos ?? [],
|
||||
setRepos: (repos) => configService.patchRawConfig({ anime: { repos } }),
|
||||
preferredQuality: () => configService.getConfig().anime?.preferredQuality || undefined,
|
||||
defaultSourceId: () => configService.getConfig().anime?.defaultSource || undefined,
|
||||
setDefaultSourceId: (defaultSource) =>
|
||||
configService.patchRawConfig({ anime: { defaultSource } }),
|
||||
preferencesFile: path.join(USER_DATA_PATH, 'anime-source-preferences.json'),
|
||||
ensureBinaries: (onProgress) =>
|
||||
ensureBridgeBinaries({
|
||||
|
||||
@@ -52,6 +52,9 @@ export function registerAnimeBrowserIpcHandlers(deps: AnimeBrowserIpcDeps): void
|
||||
handle(channels.animeBrowserSelectSource, (event, sessionId, sourceId) =>
|
||||
runtime.selectSource(String(sourceId), registerSession(deps, event, sessionId)),
|
||||
);
|
||||
handle(channels.animeBrowserSetDefaultSource, (_event, sourceId) =>
|
||||
runtime.setDefaultSource(String(sourceId)),
|
||||
);
|
||||
handle(channels.animeBrowserSearch, (event, sessionId, query, page) =>
|
||||
runtime.search(String(query ?? ''), toPage(page), registerSession(deps, event, sessionId)),
|
||||
);
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtemp, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import type { AnimeBridgeClient } from '../../anime-bridge/bridge-client';
|
||||
import { ALL_SOURCES_ID } from '../../types/anime-browser';
|
||||
import { createAnimeBrowserRuntime } from './anime-browser-runtime';
|
||||
|
||||
const client = {
|
||||
listAnimeSources: async () => [{ id: 'shared', name: 'Source', lang: 'en' }],
|
||||
} as unknown as AnimeBridgeClient;
|
||||
|
||||
async function setupRuntime(defaultSource: string) {
|
||||
const dir = await mkdtemp(path.join(tmpdir(), 'subminer-anime-default-source-'));
|
||||
await writeFile(path.join(dir, 'pkg.one.apk'), 'one');
|
||||
await writeFile(path.join(dir, 'pkg.two.apk'), 'two');
|
||||
let saved = defaultSource;
|
||||
const runtime = createAnimeBrowserRuntime({
|
||||
extensionsDir: () => dir,
|
||||
repos: () => [],
|
||||
setRepos: () => undefined,
|
||||
defaultSourceId: () => saved,
|
||||
setDefaultSourceId: (sourceId) => {
|
||||
saved = sourceId;
|
||||
},
|
||||
preferencesFile: path.join(dir, 'preferences.json'),
|
||||
ensureBinaries: async () => ({}) as never,
|
||||
checkBridgeUpdate: async () => null,
|
||||
stageBridgeUpdate: async () => {
|
||||
throw new Error('not under test');
|
||||
},
|
||||
sendMpvCommand: () => undefined,
|
||||
ensureMpvConnected: async () => true,
|
||||
onBridgeState: () => undefined,
|
||||
log: () => undefined,
|
||||
startSidecar: async () => ({
|
||||
client,
|
||||
baseUrl: 'http://127.0.0.1:12345',
|
||||
port: 12345,
|
||||
stop: async () => undefined,
|
||||
onExit: () => undefined,
|
||||
}),
|
||||
startStreamStripProxy: async () => ({
|
||||
origin: 'http://127.0.0.1:12346',
|
||||
port: 12346,
|
||||
close: async () => undefined,
|
||||
}),
|
||||
});
|
||||
await runtime.ensureBridge();
|
||||
return { runtime, saved: () => saved };
|
||||
}
|
||||
|
||||
test('a new browser session opens on the configured default source', async () => {
|
||||
const { runtime } = await setupRuntime('pkg.two:shared');
|
||||
const snapshot = runtime.getSnapshot('fresh');
|
||||
assert.equal(snapshot.selectedSourceId, 'pkg.two:shared');
|
||||
assert.equal(snapshot.defaultSourceId, 'pkg.two:shared');
|
||||
await runtime.dispose();
|
||||
});
|
||||
|
||||
test('"all" as the default selects every source, and an unknown default falls back to the first', async () => {
|
||||
const all = await setupRuntime(ALL_SOURCES_ID);
|
||||
assert.equal(all.runtime.getSnapshot('fresh').selectedSourceId, ALL_SOURCES_ID);
|
||||
await all.runtime.dispose();
|
||||
|
||||
const missing = await setupRuntime('pkg.gone:shared');
|
||||
assert.equal(missing.runtime.getSnapshot('fresh').selectedSourceId, 'pkg.one:shared');
|
||||
await missing.runtime.dispose();
|
||||
});
|
||||
|
||||
test('setDefaultSource persists an installed source and rejects an unknown one', async () => {
|
||||
const { runtime, saved } = await setupRuntime('');
|
||||
runtime.setDefaultSource('pkg.two:shared');
|
||||
assert.equal(saved(), 'pkg.two:shared');
|
||||
assert.throws(() => runtime.setDefaultSource('pkg.gone:shared'), /no longer installed/);
|
||||
assert.equal(saved(), 'pkg.two:shared');
|
||||
// Sessions that already exist keep their own selection; only new ones follow the default.
|
||||
assert.equal(runtime.getSnapshot('existing').selectedSourceId, 'pkg.two:shared');
|
||||
await runtime.dispose();
|
||||
});
|
||||
@@ -79,6 +79,13 @@ export interface AnimeBrowserRuntimeDeps {
|
||||
/** Streams per-source progress while a search invoke is pending. */
|
||||
onSearchUpdate?: (update: AnimeBrowserSearchUpdate, sessionId: string) => void;
|
||||
preferredQuality?: () => string | undefined;
|
||||
/**
|
||||
* Source id (or `all`) a new browser session starts on. Falls back to the
|
||||
* first installed source when unset or no longer installed.
|
||||
*/
|
||||
defaultSourceId?: () => string | undefined;
|
||||
/** Persists the default; absent when the host has no config to write to. */
|
||||
setDefaultSourceId?: (sourceId: string) => void;
|
||||
log: (message: string) => void;
|
||||
/** Overrides process startup in focused runtime tests. */
|
||||
startSidecar?: typeof startSidecar;
|
||||
|
||||
@@ -92,10 +92,24 @@ export function createAnimeBrowserRuntime(deps: AnimeBrowserRuntimeDeps) {
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* The configured default when it is installed (or `all` with anything
|
||||
* installed), otherwise the first source the scan found. Null with nothing
|
||||
* installed.
|
||||
*/
|
||||
function initialSourceId(): string | null {
|
||||
const configured = deps.defaultSourceId?.()?.trim();
|
||||
if (configured) {
|
||||
if (configured === ALL_SOURCES_ID && sources.length > 0) return ALL_SOURCES_ID;
|
||||
if (sources.some((source) => source.id === configured)) return configured;
|
||||
}
|
||||
return sources[0]?.id ?? null;
|
||||
}
|
||||
|
||||
function getBrowserSession(sessionId = 'default') {
|
||||
const existing = browserSessions.get(sessionId);
|
||||
if (existing) return existing;
|
||||
const created = { selectedSourceId: sources[0]?.id ?? null, searchToken: 0 };
|
||||
const created = { selectedSourceId: initialSourceId(), searchToken: 0 };
|
||||
browserSessions.set(sessionId, created);
|
||||
return created;
|
||||
}
|
||||
@@ -245,7 +259,7 @@ export function createAnimeBrowserRuntime(deps: AnimeBrowserRuntimeDeps) {
|
||||
for (const session of browserSessions.values()) {
|
||||
const keptAll = session.selectedSourceId === ALL_SOURCES_ID && sources.length > 0;
|
||||
if (!keptAll && !sources.some((source) => source.id === session.selectedSourceId)) {
|
||||
session.selectedSourceId = sources[0]?.id ?? null;
|
||||
session.selectedSourceId = initialSourceId();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -526,6 +540,7 @@ export function createAnimeBrowserRuntime(deps: AnimeBrowserRuntimeDeps) {
|
||||
pkg: source.pkg,
|
||||
})),
|
||||
selectedSourceId: session.selectedSourceId,
|
||||
defaultSourceId: deps.defaultSourceId?.()?.trim() || null,
|
||||
loadFailures,
|
||||
installed: toInstalledExtensionViews(extensions, sources, loadFailures),
|
||||
extensionsDir: deps.extensionsDir(),
|
||||
@@ -656,6 +671,20 @@ export function createAnimeBrowserRuntime(deps: AnimeBrowserRuntimeDeps) {
|
||||
if (sources.some((source) => source.id === sourceId)) session.selectedSourceId = sourceId;
|
||||
},
|
||||
|
||||
/**
|
||||
* Remember `sourceId` as the source every future browser session opens on.
|
||||
* Only an installed source (or `all`) is accepted, so a typo cannot be
|
||||
* persisted from the UI; the config file itself is not validated this way.
|
||||
*/
|
||||
setDefaultSource(sourceId: string): void {
|
||||
const valid =
|
||||
(sourceId === ALL_SOURCES_ID && sources.length > 0) ||
|
||||
sources.some((source) => source.id === sourceId);
|
||||
if (!valid) throw new Error('That source is no longer installed. Rescan and try again.');
|
||||
if (!deps.setDefaultSourceId) throw new Error('Default source cannot be saved here.');
|
||||
deps.setDefaultSourceId(sourceId);
|
||||
},
|
||||
|
||||
/**
|
||||
* The extension's settings schema, merged with anything saved locally. The
|
||||
* extension is the source of truth for structure; saved values only supply
|
||||
|
||||
@@ -44,6 +44,8 @@ export function createAnimeBrowserAPI(ipcRenderer: AnimeBrowserIpcRenderer): Ani
|
||||
ipcRenderer.invoke(request.animeBrowserUpdateBridge),
|
||||
selectSource: (sourceId: string): Promise<void> =>
|
||||
ipcRenderer.invoke(request.animeBrowserSelectSource, sessionId, sourceId),
|
||||
setDefaultSource: (sourceId: string): Promise<void> =>
|
||||
ipcRenderer.invoke(request.animeBrowserSetDefaultSource, sourceId),
|
||||
search: (query: string, page?: number): Promise<AnimeBrowserSearchResult> =>
|
||||
ipcRenderer.invoke(request.animeBrowserSearch, sessionId, query, page),
|
||||
getPopular: (page?: number): Promise<AnimeBrowserSearchResult> =>
|
||||
|
||||
@@ -130,6 +130,7 @@ export const IPC_CHANNELS = {
|
||||
animeBrowserEnsureBridge: 'anime-browser:ensure-bridge',
|
||||
animeBrowserUpdateBridge: 'anime-browser:update-bridge',
|
||||
animeBrowserSelectSource: 'anime-browser:select-source',
|
||||
animeBrowserSetDefaultSource: 'anime-browser:set-default-source',
|
||||
animeBrowserSearch: 'anime-browser:search',
|
||||
animeBrowserGetPopular: 'anime-browser:get-popular',
|
||||
animeBrowserGetDetails: 'anime-browser:get-details',
|
||||
|
||||
@@ -218,6 +218,8 @@ export interface AnimeBrowserSnapshot {
|
||||
bridge: AnimeBrowserBridgeState;
|
||||
sources: AnimeBrowserSource[];
|
||||
selectedSourceId: string | null;
|
||||
/** Configured `anime.defaultSource`, verbatim; null when unset. */
|
||||
defaultSourceId: string | null;
|
||||
loadFailures: ExtensionLoadFailure[];
|
||||
/** Every extension on disk, whether or not it loaded. */
|
||||
installed: InstalledExtensionView[];
|
||||
@@ -287,6 +289,8 @@ export interface AnimeBrowserAPI {
|
||||
*/
|
||||
updateBridge: () => Promise<AnimeBrowserBridgeState>;
|
||||
selectSource: (sourceId: string) => Promise<void>;
|
||||
/** Persist the source (or `all`) new browser sessions open on. */
|
||||
setDefaultSource: (sourceId: string) => Promise<void>;
|
||||
search: (query: string, page?: number) => Promise<AnimeBrowserSearchResult>;
|
||||
getPopular: (page?: number) => Promise<AnimeBrowserSearchResult>;
|
||||
/** `sourceId` is required after an all-sources search; pass the entry's own. */
|
||||
|
||||
@@ -325,6 +325,7 @@ export interface ResolvedConfig {
|
||||
extensionsDir: string;
|
||||
repos: string[];
|
||||
preferredQuality: string;
|
||||
defaultSource: string;
|
||||
bridgeDir: string;
|
||||
};
|
||||
jimaku: JimakuConfig & {
|
||||
|
||||
@@ -63,6 +63,11 @@ export interface AnimeConfig {
|
||||
repos?: string[];
|
||||
/** Preferred stream label, matched as a substring, e.g. "1080". */
|
||||
preferredQuality?: string;
|
||||
/**
|
||||
* Source id (`<package>:<source>`) selected when the browser opens, or
|
||||
* "all" for every installed source. Empty selects the first installed one.
|
||||
*/
|
||||
defaultSource?: string;
|
||||
/**
|
||||
* Directory holding an M-Extension-Server bundle (java runtime plus server
|
||||
* jar) to run instead of the copy SubMiner downloads. Empty checks the
|
||||
|
||||
Reference in New Issue
Block a user