mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-09-10 17:16:20 -07:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
01fcacb6cf
|
||
|
|
cd7a65ec80
|
||
|
|
89479f7045
|
@@ -10,8 +10,8 @@ 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.
|
||||
- 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.
|
||||
- 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, waits for mpv's video window to appear, closes the in-player browser, hides the standalone browser window so it is not pulled in front of mpv when the modals close, 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.
|
||||
- HLS streams pass through a local strip proxy that removes fake image headers some hosts glue onto their video segments and gives disguised segment URLs (`.image`, `.jpg`, `.css`, and other rotating fake extensions) a media-safe local alias, so those streams play in mpv and support Anki audio and image extraction with current ffmpeg releases. Upstream responses use identity encoding so playlists remain readable for URL rewriting.
|
||||
|
||||
@@ -633,7 +633,7 @@
|
||||
"extensionsDir": "", // Directory holding Aniyomi extension .apk files. Empty uses <userData>/anime-extensions.
|
||||
"repos": [], // Extension repository index URLs (any https .json index, e.g. https://.../index.min.json). Empty by default; SubMiner ships no repositories.
|
||||
"preferredQuality": "", // Preferred stream quality label, matched as a substring (for example: 1080). Empty uses the source order.
|
||||
"defaultSource": "", // Source the Anime Browser selects when it opens: a source id (<package>:<source>) or "all" for every installed source. Empty selects the first installed source. The star beside the Source picker writes this value.
|
||||
"defaultSource": "", // Source the Anime Browser selects when it opens: a source id (<package>:<source>) or "all" for every installed source. Empty selects the first installed source. "Set default" in the Extensions tab writes this value.
|
||||
"bridgeDir": "" // Directory holding an M-Extension-Server bundle (java runtime plus server jar) to run instead of the copy SubMiner downloads. Empty checks the package-manager install (Arch: mangatan-extension-server), then <userData>/anime-bridge.
|
||||
}, // Anime browser sources. SubMiner ships no extension repositories and bundles no sources;
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -218,10 +220,14 @@ there is nowhere to write them and the status bar says so.
|
||||
| `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
|
||||
to Jimaku. SubMiner pauses playback, closes the in-player browser if it is open,
|
||||
and opens Jimaku with the source title, season, and episode already filled in.
|
||||
Playback resumes after the selected subtitle loads. Closing Jimaku also releases
|
||||
the automatic pause, while playback that was already paused stays paused.
|
||||
to Jimaku. SubMiner pauses playback on its first frame, waits for mpv's video
|
||||
window to appear, closes the in-player browser if it is open, hides the
|
||||
standalone browser window, and opens Jimaku with the source title, season, and
|
||||
episode already filled in. Playback resumes after the selected subtitle loads.
|
||||
Closing Jimaku also releases the automatic pause, while playback that was
|
||||
already paused stays paused. A stream that never shows a window resumes without
|
||||
opening Jimaku. Reopen the browser window with `subminer anime`, the tray
|
||||
entry, or the shortcut to keep browsing.
|
||||
|
||||
## Source settings
|
||||
|
||||
|
||||
@@ -1180,7 +1180,7 @@ Sources for the [anime browser](/anime-browser). SubMiner ships no extension rep
|
||||
| `anime.extensionsDir` | `string` | `""` | Directory holding Aniyomi extension `.apk` files. Empty uses `<userData>/anime-extensions`. |
|
||||
| `anime.repos` | `string[]` | `[]` | Extension repository index URLs. Any `https` URL ending in `.json` works; `index.min.json` is only the common name. |
|
||||
| `anime.preferredQuality` | `string` | `""` | Preferred stream quality label, matched as a substring (for example `1080`). Empty keeps the source's own order. A Japanese-audio entry always outranks a higher-quality dub. |
|
||||
| `anime.defaultSource` | `string` | `""` | Source the Anime Browser selects when it opens: a source id (`<package>:<source>`) or `all` for every installed source. Empty selects the first installed source. The star beside the Source picker writes this value. |
|
||||
| `anime.defaultSource` | `string` | `""` | Source the Anime Browser selects when it opens: a source id (`<package>:<source>`) or `all` for every installed source. Empty selects the first installed source. **Set default** in the Extensions tab writes this value. |
|
||||
| `anime.bridgeDir` | `string` | `""` | Directory holding an M-Extension-Server bundle (Java runtime plus server jar) to run instead of the downloaded copy. Empty checks the package-manager install first, then `<userData>/anime-bridge`. See [the bridge](anime-browser.md#the-bridge). |
|
||||
|
||||
Repositories added from the browser's Extensions tab are written back to `anime.repos`, so the list can also be kept in a dotfile. Changes apply the next time the anime browser opens.
|
||||
|
||||
@@ -633,7 +633,7 @@
|
||||
"extensionsDir": "", // Directory holding Aniyomi extension .apk files. Empty uses <userData>/anime-extensions.
|
||||
"repos": [], // Extension repository index URLs (any https .json index, e.g. https://.../index.min.json). Empty by default; SubMiner ships no repositories.
|
||||
"preferredQuality": "", // Preferred stream quality label, matched as a substring (for example: 1080). Empty uses the source order.
|
||||
"defaultSource": "", // Source the Anime Browser selects when it opens: a source id (<package>:<source>) or "all" for every installed source. Empty selects the first installed source. The star beside the Source picker writes this value.
|
||||
"defaultSource": "", // Source the Anime Browser selects when it opens: a source id (<package>:<source>) or "all" for every installed source. Empty selects the first installed source. "Set default" in the Extensions tab writes this value.
|
||||
"bridgeDir": "" // Directory holding an M-Extension-Server bundle (java runtime plus server jar) to run instead of the copy SubMiner downloads. Empty checks the package-manager install (Arch: mangatan-extension-server), then <userData>/anime-bridge.
|
||||
}, // Anime browser sources. SubMiner ships no extension repositories and bundles no sources;
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
+17
@@ -545,6 +545,7 @@ import {
|
||||
} from './main/runtime/setup-window-factory';
|
||||
import { createAnimeBrowserApplicationRuntime } from './main/runtime/anime-browser-application-runtime';
|
||||
import { createAnimeBrowserJimakuAutoOpen } from './main/runtime/anime-browser-jimaku-auto-open';
|
||||
import { waitForPlaybackWindow } from './main/runtime/playback-window-ready';
|
||||
import { openAnimeBrowserModal as openAnimeBrowserModalRuntime } from './main/runtime/anime-browser-open';
|
||||
import {
|
||||
ensureBridgeBinaries,
|
||||
@@ -2526,6 +2527,16 @@ const animeBrowserJimakuAutoOpen = createAnimeBrowserJimakuAutoOpen({
|
||||
setPlaybackPaused: (paused) => {
|
||||
sendMpvCommandRuntime(appState.mpvClient, ['set_property', 'pause', paused ? 'yes' : 'no']);
|
||||
},
|
||||
waitForPlaybackWindow: () =>
|
||||
waitForPlaybackWindow({
|
||||
isWindowTracked: () => appState.windowTracker?.isTracking() ?? null,
|
||||
readProperty: (name) => {
|
||||
const client = appState.mpvClient;
|
||||
if (!client) return Promise.reject(new Error('mpv is not connected.'));
|
||||
return client.requestProperty(name);
|
||||
},
|
||||
wait: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
||||
}),
|
||||
closeAnimeBrowserModal: () => {
|
||||
if (!overlayModalRuntime.isModalOpen('anime-browser')) return;
|
||||
overlayModalRuntime.sendToActiveOverlayWindow(IPC_CHANNELS.event.animeBrowserClose, undefined, {
|
||||
@@ -2533,6 +2544,12 @@ const animeBrowserJimakuAutoOpen = createAnimeBrowserJimakuAutoOpen({
|
||||
preferModalWindow: true,
|
||||
});
|
||||
},
|
||||
hideAnimeBrowserWindow: () => {
|
||||
const window = appState.animeBrowserWindow;
|
||||
if (window && !window.isDestroyed() && window.isVisible()) {
|
||||
window.hide();
|
||||
}
|
||||
},
|
||||
openJimakuModal: () => openJimakuOverlay(),
|
||||
logWarn: (message, error) => logger.warn(message, error),
|
||||
});
|
||||
|
||||
@@ -7,6 +7,7 @@ function createHarness(options?: {
|
||||
animeMedia?: boolean | ((mediaPath: string) => boolean);
|
||||
paused?: boolean | null;
|
||||
opened?: boolean;
|
||||
windowReady?: () => Promise<boolean>;
|
||||
}) {
|
||||
const calls: string[] = [];
|
||||
let currentMediaPath: string | null = null;
|
||||
@@ -19,7 +20,12 @@ function createHarness(options?: {
|
||||
getCurrentMediaPath: () => currentMediaPath,
|
||||
getPlaybackPaused: async () => (options?.paused === undefined ? false : options.paused),
|
||||
setPlaybackPaused: (paused) => calls.push(`pause:${paused}`),
|
||||
waitForPlaybackWindow: () => {
|
||||
calls.push('wait-window');
|
||||
return options?.windowReady?.() ?? Promise.resolve(true);
|
||||
},
|
||||
closeAnimeBrowserModal: () => calls.push('close-anime-browser'),
|
||||
hideAnimeBrowserWindow: () => calls.push('hide-anime-browser-window'),
|
||||
openJimakuModal: async () => {
|
||||
calls.push('open-jimaku');
|
||||
return options?.opened ?? true;
|
||||
@@ -36,20 +42,23 @@ function createHarness(options?: {
|
||||
};
|
||||
}
|
||||
|
||||
test('anime browser playback pauses, opens Jimaku, and resumes after subtitle load', async () => {
|
||||
const OPEN_SEQUENCE = [
|
||||
'pause:true',
|
||||
'wait-window',
|
||||
'close-anime-browser',
|
||||
'hide-anime-browser-window',
|
||||
'open-jimaku',
|
||||
];
|
||||
|
||||
test('anime browser playback pauses, waits for the mpv window, opens Jimaku, and resumes after subtitle load', async () => {
|
||||
const harness = createHarness();
|
||||
harness.setMediaPath('https://127.0.0.1/stream.m3u8');
|
||||
|
||||
await harness.runtime.handleMediaPathChange('https://127.0.0.1/stream.m3u8');
|
||||
assert.deepEqual(harness.calls, ['pause:true', 'close-anime-browser', 'open-jimaku']);
|
||||
assert.deepEqual(harness.calls, OPEN_SEQUENCE);
|
||||
|
||||
harness.runtime.handleJimakuSubtitleLoaded();
|
||||
assert.deepEqual(harness.calls, [
|
||||
'pause:true',
|
||||
'close-anime-browser',
|
||||
'open-jimaku',
|
||||
'pause:false',
|
||||
]);
|
||||
assert.deepEqual(harness.calls, [...OPEN_SEQUENCE, 'pause:false']);
|
||||
});
|
||||
|
||||
test('anime browser playback that was already paused stays paused after subtitle load', async () => {
|
||||
@@ -59,7 +68,12 @@ test('anime browser playback that was already paused stays paused after subtitle
|
||||
await harness.runtime.handleMediaPathChange('https://127.0.0.1/stream.m3u8');
|
||||
harness.runtime.handleJimakuSubtitleLoaded();
|
||||
|
||||
assert.deepEqual(harness.calls, ['close-anime-browser', 'open-jimaku']);
|
||||
assert.deepEqual(harness.calls, [
|
||||
'wait-window',
|
||||
'close-anime-browser',
|
||||
'hide-anime-browser-window',
|
||||
'open-jimaku',
|
||||
]);
|
||||
});
|
||||
|
||||
test('disabled and non-Anime Browser media do not open Jimaku', async () => {
|
||||
@@ -74,6 +88,49 @@ test('disabled and non-Anime Browser media do not open Jimaku', async () => {
|
||||
assert.deepEqual(unrelated.calls, []);
|
||||
});
|
||||
|
||||
test('a stream that never shows a window releases the pause without opening Jimaku', async () => {
|
||||
const harness = createHarness({ windowReady: async () => false });
|
||||
harness.setMediaPath('https://127.0.0.1/stream.m3u8');
|
||||
|
||||
await harness.runtime.handleMediaPathChange('https://127.0.0.1/stream.m3u8');
|
||||
|
||||
assert.ok(!harness.calls.includes('open-jimaku'));
|
||||
assert.equal(harness.calls.at(-1), 'pause:false');
|
||||
});
|
||||
|
||||
test('a newer episode during the window wait cancels the stale Jimaku open', async () => {
|
||||
const firstWait: { resolve: (ready: boolean) => void } = { resolve: () => {} };
|
||||
const firstWaitPromise = new Promise<boolean>((resolve) => {
|
||||
firstWait.resolve = resolve;
|
||||
});
|
||||
let notifyFirstWaitStarted: () => void = () => {};
|
||||
const firstWaitStarted = new Promise<void>((resolve) => {
|
||||
notifyFirstWaitStarted = resolve;
|
||||
});
|
||||
let waits = 0;
|
||||
const harness = createHarness({
|
||||
windowReady: () => {
|
||||
waits += 1;
|
||||
if (waits === 1) {
|
||||
notifyFirstWaitStarted();
|
||||
return firstWaitPromise;
|
||||
}
|
||||
return Promise.resolve(true);
|
||||
},
|
||||
});
|
||||
|
||||
harness.setMediaPath('https://127.0.0.1/one.m3u8');
|
||||
const first = harness.runtime.handleMediaPathChange('https://127.0.0.1/one.m3u8');
|
||||
await firstWaitStarted;
|
||||
|
||||
harness.setMediaPath('https://127.0.0.1/two.m3u8');
|
||||
await harness.runtime.handleMediaPathChange('https://127.0.0.1/two.m3u8');
|
||||
firstWait.resolve(true);
|
||||
await first;
|
||||
|
||||
assert.equal(harness.calls.filter((call) => call === 'open-jimaku').length, 1);
|
||||
});
|
||||
|
||||
test('closing Jimaku or failing to open it releases an owned pause', async () => {
|
||||
const closed = createHarness();
|
||||
closed.setMediaPath('https://127.0.0.1/one.m3u8');
|
||||
|
||||
@@ -4,7 +4,10 @@ export interface AnimeBrowserJimakuAutoOpenDeps {
|
||||
getCurrentMediaPath: () => string | null;
|
||||
getPlaybackPaused: () => Promise<boolean | null>;
|
||||
setPlaybackPaused: (paused: boolean) => void;
|
||||
/** Resolves true once mpv shows a video window; false when it never appears. */
|
||||
waitForPlaybackWindow: () => Promise<boolean>;
|
||||
closeAnimeBrowserModal: () => void;
|
||||
hideAnimeBrowserWindow: () => void;
|
||||
openJimakuModal: () => Promise<boolean>;
|
||||
logWarn: (message: string, error?: unknown) => void;
|
||||
}
|
||||
@@ -20,7 +23,16 @@ interface ActiveFlow {
|
||||
ownsPause: boolean;
|
||||
}
|
||||
|
||||
/** Coordinates the pause owned by the Anime Browser to Jimaku handoff. */
|
||||
/**
|
||||
* Coordinates the pause owned by the Anime Browser to Jimaku handoff.
|
||||
*
|
||||
* The pause goes out on the path change so the stream freezes on its first
|
||||
* frame, but Jimaku waits for mpv's window: the modal takes its geometry from
|
||||
* that window, and on macOS closing the last modal yields focus back to it.
|
||||
* Both Anime Browser surfaces get out of the way before Jimaku opens; a
|
||||
* visible standalone window would otherwise be pulled in front of mpv the
|
||||
* next time the app activates after that focus handoff.
|
||||
*/
|
||||
export function createAnimeBrowserJimakuAutoOpen(
|
||||
deps: AnimeBrowserJimakuAutoOpenDeps,
|
||||
): AnimeBrowserJimakuAutoOpen {
|
||||
@@ -68,7 +80,22 @@ export function createAnimeBrowserJimakuAutoOpen(
|
||||
}
|
||||
|
||||
if (!isCurrent(flow)) return;
|
||||
try {
|
||||
const windowReady = await deps.waitForPlaybackWindow();
|
||||
if (!isCurrent(flow)) return;
|
||||
if (!windowReady) {
|
||||
deps.logWarn('mpv showed no video window for Anime Browser playback; skipping Jimaku.');
|
||||
releaseFlow(flow);
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
deps.logWarn('Could not wait for the mpv window before opening Jimaku.', error);
|
||||
releaseFlow(flow);
|
||||
return;
|
||||
}
|
||||
|
||||
deps.closeAnimeBrowserModal();
|
||||
deps.hideAnimeBrowserWindow();
|
||||
|
||||
try {
|
||||
const opened = await deps.openJimakuModal();
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { waitForPlaybackWindow } from './playback-window-ready';
|
||||
|
||||
function createClock() {
|
||||
let time = 0;
|
||||
return {
|
||||
now: () => time,
|
||||
wait: async (ms: number) => {
|
||||
time += ms;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('resolves once the video output is configured and the tracker has the window', async () => {
|
||||
const clock = createClock();
|
||||
let probes = 0;
|
||||
let tracked = false;
|
||||
const ready = await waitForPlaybackWindow({
|
||||
...clock,
|
||||
isWindowTracked: () => tracked,
|
||||
readProperty: async () => {
|
||||
probes += 1;
|
||||
if (probes === 1) throw new Error('property unavailable');
|
||||
if (probes === 3) tracked = true;
|
||||
return probes >= 2;
|
||||
},
|
||||
probeIntervalMs: 100,
|
||||
});
|
||||
|
||||
assert.equal(ready, true);
|
||||
assert.equal(probes, 3);
|
||||
assert.equal(clock.now(), 200);
|
||||
});
|
||||
|
||||
test('a configured video output is enough when no window tracker is running', async () => {
|
||||
const ready = await waitForPlaybackWindow({
|
||||
...createClock(),
|
||||
isWindowTracked: () => null,
|
||||
readProperty: async () => true,
|
||||
});
|
||||
|
||||
assert.equal(ready, true);
|
||||
});
|
||||
|
||||
test('gives up after the wall-clock budget when no window appears', async () => {
|
||||
const clock = createClock();
|
||||
const ready = await waitForPlaybackWindow({
|
||||
...clock,
|
||||
isWindowTracked: () => false,
|
||||
readProperty: async () => true,
|
||||
timeoutMs: 1000,
|
||||
probeIntervalMs: 300,
|
||||
});
|
||||
|
||||
assert.equal(ready, false);
|
||||
assert.equal(clock.now(), 1000);
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* Waits until mpv has a video window the overlay can attach to.
|
||||
*
|
||||
* mpv reports a new `path` as soon as loading starts, which for a network
|
||||
* stream is seconds before any window exists (idle mpv shows none). A modal
|
||||
* opened on the path change lands on a blank desktop with fallback geometry.
|
||||
* Ready means the video output is configured (`vo-configured`, a window with a
|
||||
* frame in it) and, when a window tracker is running, that it has located the
|
||||
* window so overlay geometry follows it.
|
||||
*/
|
||||
export interface WaitForPlaybackWindowDeps {
|
||||
/** Tracker state, or null when no window tracker is running. */
|
||||
isWindowTracked: () => boolean | null;
|
||||
/** One-shot mpv property read; may reject while the file is still loading. */
|
||||
readProperty: (name: string) => Promise<unknown>;
|
||||
wait: (ms: number) => Promise<void>;
|
||||
now?: () => number;
|
||||
timeoutMs?: number;
|
||||
probeIntervalMs?: number;
|
||||
}
|
||||
|
||||
export const DEFAULT_PLAYBACK_WINDOW_TIMEOUT_MS = 20_000;
|
||||
const DEFAULT_PROBE_INTERVAL_MS = 200;
|
||||
|
||||
/** Resolves true once the window is ready, false when the wall-clock budget runs out. */
|
||||
export async function waitForPlaybackWindow(deps: WaitForPlaybackWindowDeps): Promise<boolean> {
|
||||
const now = deps.now ?? Date.now;
|
||||
const timeoutMs = deps.timeoutMs ?? DEFAULT_PLAYBACK_WINDOW_TIMEOUT_MS;
|
||||
const probeIntervalMs = deps.probeIntervalMs ?? DEFAULT_PROBE_INTERVAL_MS;
|
||||
const deadline = now() + timeoutMs;
|
||||
|
||||
for (;;) {
|
||||
let voConfigured = false;
|
||||
try {
|
||||
voConfigured = (await deps.readProperty('vo-configured')) === true;
|
||||
} catch {
|
||||
// Unreadable between files or before mpv connects; keep polling.
|
||||
}
|
||||
if (voConfigured && deps.isWindowTracked() !== false) return true;
|
||||
|
||||
const remaining = deadline - now();
|
||||
if (remaining <= 0) return false;
|
||||
await deps.wait(Math.min(probeIntervalMs, remaining));
|
||||
}
|
||||
}
|
||||
@@ -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