feat(anime): add language filter to extensions available list

- Add a language chip row above the Available list; pick one or more languages to narrow the repo index, or "All" to clear
- Show language display names (e.g. "Japanese") instead of raw tags, and an "N of M" count on the Available heading
- Add language-filter.ts with collectLanguages/toggleLanguage/pruneSelection/filterByLanguage/languageLabel, plus tests
This commit is contained in:
2026-07-31 23:41:14 -07:00
parent 2e34f01adf
commit 3c19228b35
6 changed files with 254 additions and 19 deletions
+4
View File
@@ -2,6 +2,10 @@
## Unreleased ## Unreleased
### Added
- Anime Browser Language Filter: The Extensions tab's available list now has a language chip row above it. Pick one or more languages to narrow a repository index that otherwise lists every language it knows, or "All" to clear the filter — selecting a language replaces "All" rather than sitting beside it. Extension rows name the language ("Japanese" instead of `ja`), and the Available heading shows how many of the offered extensions the filter leaves.
### Fixed ### Fixed
- Anime Browser Window Switching: Opening the anime browser now shows a tray icon on every platform and — on macOS — puts the app in the Cmd+Tab switcher (which requires the Dock icon; the two are inseparable on macOS), so you can switch between it and mpv. Previously the subtitle overlay's fullscreen support hid the whole app from the Dock and Cmd+Tab, leaving no way to reach the window. The Dock icon is released again when the window closes during playback. - Anime Browser Window Switching: Opening the anime browser now shows a tray icon on every platform and — on macOS — puts the app in the Cmd+Tab switcher (which requires the Dock icon; the two are inseparable on macOS), so you can switch between it and mpv. Previously the subtitle overlay's fullscreen support hid the whole app from the Dock and Cmd+Tab, leaving no way to reach the window. The Dock icon is released again when the window closes during playback.
+75 -16
View File
@@ -1,5 +1,12 @@
import { describe, el } from './dom'; import { describe, el } from './dom';
import { describeInstalled } from './format'; import { describeInstalled } from './format';
import {
collectLanguages,
filterByLanguage,
languageLabel,
pruneSelection,
toggleLanguage,
} from './language-filter';
import type { import type {
AnimeBrowserAPI, AnimeBrowserAPI,
AvailableExtension, AvailableExtension,
@@ -90,10 +97,20 @@ export function createExtensionsPanel(options: ExtensionsPanelOptions) {
const installedList = el<HTMLDivElement>('installed-list'); const installedList = el<HTMLDivElement>('installed-list');
const installedCount = el<HTMLSpanElement>('installed-count'); const installedCount = el<HTMLSpanElement>('installed-count');
const availableList = el<HTMLDivElement>('extensions-list'); const availableList = el<HTMLDivElement>('extensions-list');
const availableCount = el<HTMLSpanElement>('available-count');
const langFilter = el<HTMLDivElement>('lang-filter');
const repoInput = el<HTMLInputElement>('repo-input'); const repoInput = el<HTMLInputElement>('repo-input');
const repoAddButton = el<HTMLButtonElement>('repo-add'); const repoAddButton = el<HTMLButtonElement>('repo-add');
const repoList = el<HTMLDivElement>('repo-list'); const repoList = el<HTMLDivElement>('repo-list');
// What the last refresh found, kept so toggling a language chip re-renders
// the list without re-fetching every repository index.
let installable: AvailableExtension[] = [];
let repoFailures: Array<{ name: string; error: string }> = [];
let hasRepos = false;
/** Selected language codes; empty means "All". */
let selectedLangs = new Set<string>();
async function afterChange(extensionName: string, verb: string): Promise<void> { async function afterChange(extensionName: string, verb: string): Promise<void> {
await refresh(); await refresh();
await onSourcesChanged(); await onSourcesChanged();
@@ -179,20 +196,62 @@ export function createExtensionsPanel(options: ExtensionsPanelOptions) {
); );
} }
function renderAvailable( function languageChip(label: string, active: boolean, onClick: () => void): HTMLButtonElement {
available: AvailableExtension[], const chip = document.createElement('button');
repoFailures: Array<{ name: string; error: string }>, chip.type = 'button';
hasRepos: boolean, chip.className = active ? 'lang-chip is-active' : 'lang-chip';
): void { chip.textContent = label;
chip.setAttribute('aria-pressed', active ? 'true' : 'false');
chip.addEventListener('click', onClick);
return chip;
}
function renderLanguageFilter(languages: string[]): void {
// One language, or none: there is nothing to choose between.
if (languages.length < 2) {
langFilter.replaceChildren();
return;
}
langFilter.replaceChildren(
// "All" is the empty selection, so picking a language always replaces it
// rather than sitting alongside it.
languageChip('All', selectedLangs.size === 0, () => {
if (selectedLangs.size === 0) return;
selectedLangs = new Set();
renderAvailable();
}),
...languages.map((code) =>
languageChip(languageLabel(code), selectedLangs.has(code), () => {
selectedLangs = toggleLanguage(selectedLangs, code);
renderAvailable();
}),
),
);
}
function renderAvailable(): void {
const languages = collectLanguages(installable);
selectedLangs = pruneSelection(selectedLangs, languages);
renderLanguageFilter(languages);
const shown = filterByLanguage(installable, selectedLangs);
availableCount.textContent =
shown.length === installable.length
? installable.length === 0
? ''
: String(installable.length)
: `${shown.length} of ${installable.length}`;
const rows: HTMLElement[] = repoFailures.map((failure) => const rows: HTMLElement[] = repoFailures.map((failure) =>
extensionRow({ name: failure.name, sub: failure.error, isError: true }), extensionRow({ name: failure.name, sub: failure.error, isError: true }),
); );
for (const extension of available) { for (const extension of shown) {
rows.push( rows.push(
extensionRow({ extensionRow({
name: extension.name, name: extension.name,
sub: `${extension.lang} · v${extension.version}`, sub: `${languageLabel(extension.lang)} · v${extension.version}`,
tags: extension.nsfw ? [{ text: '18+', className: 'nsfw' }] : [], tags: extension.nsfw ? [{ text: '18+', className: 'nsfw' }] : [],
actions: [ actions: [
{ {
@@ -216,7 +275,9 @@ export function createExtensionsPanel(options: ExtensionsPanelOptions) {
if (rows.length === 0) { if (rows.length === 0) {
rows.push( rows.push(
emptyNote( emptyNote(
hasRepos selectedLangs.size > 0
? 'No available extension matches the selected languages.'
: hasRepos
? 'Every extension the configured repositories offer is already installed.' ? 'Every extension the configured repositories offer is already installed.'
: 'No repository configured, so there is nothing to install from.', : 'No repository configured, so there is nothing to install from.',
), ),
@@ -231,7 +292,7 @@ export function createExtensionsPanel(options: ExtensionsPanelOptions) {
extensionsDirLabel.textContent = snapshot.extensionsDir; extensionsDirLabel.textContent = snapshot.extensionsDir;
renderRepos(snapshot.repos); renderRepos(snapshot.repos);
const repoFailures: Array<{ name: string; error: string }> = []; repoFailures = [];
let available; let available;
try { try {
available = await api.listAvailableExtensions(); available = await api.listAvailableExtensions();
@@ -245,13 +306,11 @@ export function createExtensionsPanel(options: ExtensionsPanelOptions) {
const offeredPkgs = new Set(available.extensions.map((extension) => extension.pkg)); const offeredPkgs = new Set(available.extensions.map((extension) => extension.pkg));
renderInstalled(snapshot.installed, offeredPkgs, snapshot.extensionsDir); renderInstalled(snapshot.installed, offeredPkgs, snapshot.extensionsDir);
renderAvailable( // Installed extensions have their own section; leaving them here too would
// Installed extensions have their own section; leaving them here too // list every one of them twice.
// would list every one of them twice. installable = available.extensions.filter((extension) => !extension.installed);
available.extensions.filter((extension) => !extension.installed), hasRepos = snapshot.repos.length > 0;
repoFailures, renderAvailable();
snapshot.repos.length > 0,
);
} }
repoAddButton.addEventListener('click', () => { repoAddButton.addEventListener('click', () => {
+4 -1
View File
@@ -115,7 +115,10 @@
<div class="ext-list" id="repo-list" aria-label="Repositories"></div> <div class="ext-list" id="repo-list" aria-label="Repositories"></div>
<h3 class="ext-group-title">Available</h3> <h3 class="ext-group-title">
Available <span class="ext-group-count" id="available-count"></span>
</h3>
<div class="lang-filter" id="lang-filter" role="group" aria-label="Filter by language"></div>
<div class="ext-list" id="extensions-list" aria-label="Available extensions"></div> <div class="ext-list" id="extensions-list" aria-label="Available extensions"></div>
</section> </section>
+49
View File
@@ -0,0 +1,49 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
collectLanguages,
filterByLanguage,
languageLabel,
pruneSelection,
toggleLanguage,
} from './language-filter';
const ext = (lang: string, name = lang) => ({ lang, name });
test('languageLabel names known tags and falls back to the raw code', () => {
assert.equal(languageLabel('ja'), 'Japanese');
assert.equal(languageLabel('all'), 'Multi-language');
assert.equal(languageLabel('zzzz'), 'ZZZZ');
});
test('collectLanguages dedupes, puts multi-language first, then sorts by name', () => {
// English, German, Japanese — display names, not codes.
assert.deepEqual(collectLanguages([ext('ja'), ext('de'), ext('ja'), ext('all'), ext('en')]), [
'all',
'en',
'de',
'ja',
]);
});
test('toggleLanguage adds, removes, and empties back to All', () => {
const one = toggleLanguage(new Set(), 'ja');
assert.deepEqual([...one], ['ja']);
const two = toggleLanguage(one, 'en');
assert.deepEqual([...two].sort(), ['en', 'ja']);
assert.deepEqual([...toggleLanguage(two, 'ja')], ['en']);
assert.equal(toggleLanguage(one, 'ja').size, 0);
});
test('pruneSelection drops languages no repository offers any more', () => {
assert.deepEqual([...pruneSelection(new Set(['ja', 'de']), ['ja', 'en'])], ['ja']);
});
test('filterByLanguage keeps everything when nothing is selected', () => {
const extensions = [ext('ja'), ext('en'), ext('all')];
assert.deepEqual(filterByLanguage(extensions, new Set()), extensions);
assert.deepEqual(
filterByLanguage(extensions, new Set(['ja', 'all'])).map((extension) => extension.name),
['ja', 'all'],
);
});
+80
View File
@@ -0,0 +1,80 @@
/**
* The language filter for the available-extensions list.
*
* A repository index carries every language it knows about, which is far more
* than any one user reads. The selection is a set of language codes; the empty
* set means "All", so picking a language always replaces "All" rather than
* sitting alongside it.
*/
/** The code a repository uses for an extension whose sources span languages. */
export const MULTI_LANG = 'all';
let displayNames: Intl.DisplayNames | null | undefined;
function languageDisplayNames(): Intl.DisplayNames | null {
if (displayNames === undefined) {
try {
displayNames = new Intl.DisplayNames(['en'], { type: 'language' });
} catch {
displayNames = null;
}
}
return displayNames;
}
/** `ja` → `Japanese`, `pt-BR` → `Brazilian Portuguese`, `all` → `Multi-language`. */
export function languageLabel(code: string): string {
if (code === MULTI_LANG) return 'Multi-language';
try {
const name = languageDisplayNames()?.of(code);
// Intl echoes the input back when it knows no name for the tag.
if (name && name !== code) return name;
} catch {
// An invalid tag throws; fall through to the raw code.
}
return code.toUpperCase();
}
/**
* The languages offered, ordered for the chip row: multi-language first
* because it is the odd one out, then alphabetically by display name.
*/
export function collectLanguages(extensions: ReadonlyArray<{ lang: string }>): string[] {
const codes = [...new Set(extensions.map((extension) => extension.lang))];
return codes.sort((a, b) => {
if (a === MULTI_LANG || b === MULTI_LANG) return a === MULTI_LANG ? -1 : 1;
return languageLabel(a).localeCompare(languageLabel(b));
});
}
/**
* The selection after clicking a language chip. Toggling the last selected
* language off leaves the empty set, which is "All" again.
*/
export function toggleLanguage(selected: ReadonlySet<string>, code: string): Set<string> {
const next = new Set(selected);
if (!next.delete(code)) next.add(code);
return next;
}
/**
* Drop the codes no repository offers any more, so a language that disappears
* when a repository is removed does not keep filtering the list invisibly.
*/
export function pruneSelection(
selected: ReadonlySet<string>,
available: ReadonlyArray<string>,
): Set<string> {
const offered = new Set(available);
return new Set([...selected].filter((code) => offered.has(code)));
}
/** The extensions to show; an empty selection shows everything. */
export function filterByLanguage<T extends { lang: string }>(
extensions: ReadonlyArray<T>,
selected: ReadonlySet<string>,
): T[] {
if (selected.size === 0) return [...extensions];
return extensions.filter((extension) => selected.has(extension.lang));
}
+40
View File
@@ -823,6 +823,46 @@ body {
display: none; display: none;
} }
.lang-filter {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.lang-filter:not(:empty) {
margin-bottom: 10px;
}
.lang-chip {
padding: 4px 11px;
border: 1px solid var(--line);
border-radius: 999px;
background: var(--ctp-crust);
color: var(--muted);
font: inherit;
font-size: 11px;
letter-spacing: 0.04em;
cursor: pointer;
transition:
color 120ms ease,
border-color 120ms ease;
}
.lang-chip:hover {
color: var(--text);
border-color: var(--accent);
}
.lang-chip.is-active {
border-color: var(--accent);
color: var(--accent);
}
.lang-chip:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.ext-list { .ext-list {
display: flex; display: flex;
flex-direction: column; flex-direction: column;