diff --git a/CHANGELOG.md b/CHANGELOG.md index 29af305b..d77dc01d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## 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 - 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. diff --git a/src/animeui/extensions-panel.ts b/src/animeui/extensions-panel.ts index afb33bd3..a094434d 100644 --- a/src/animeui/extensions-panel.ts +++ b/src/animeui/extensions-panel.ts @@ -1,5 +1,12 @@ import { describe, el } from './dom'; import { describeInstalled } from './format'; +import { + collectLanguages, + filterByLanguage, + languageLabel, + pruneSelection, + toggleLanguage, +} from './language-filter'; import type { AnimeBrowserAPI, AvailableExtension, @@ -90,10 +97,20 @@ export function createExtensionsPanel(options: ExtensionsPanelOptions) { const installedList = el('installed-list'); const installedCount = el('installed-count'); const availableList = el('extensions-list'); + const availableCount = el('available-count'); + const langFilter = el('lang-filter'); const repoInput = el('repo-input'); const repoAddButton = el('repo-add'); const repoList = el('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(); + async function afterChange(extensionName: string, verb: string): Promise { await refresh(); await onSourcesChanged(); @@ -179,20 +196,62 @@ export function createExtensionsPanel(options: ExtensionsPanelOptions) { ); } - function renderAvailable( - available: AvailableExtension[], - repoFailures: Array<{ name: string; error: string }>, - hasRepos: boolean, - ): void { + function languageChip(label: string, active: boolean, onClick: () => void): HTMLButtonElement { + const chip = document.createElement('button'); + chip.type = 'button'; + chip.className = active ? 'lang-chip is-active' : 'lang-chip'; + 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) => extensionRow({ name: failure.name, sub: failure.error, isError: true }), ); - for (const extension of available) { + for (const extension of shown) { rows.push( extensionRow({ name: extension.name, - sub: `${extension.lang} · v${extension.version}`, + sub: `${languageLabel(extension.lang)} · v${extension.version}`, tags: extension.nsfw ? [{ text: '18+', className: 'nsfw' }] : [], actions: [ { @@ -216,9 +275,11 @@ export function createExtensionsPanel(options: ExtensionsPanelOptions) { if (rows.length === 0) { rows.push( emptyNote( - hasRepos - ? 'Every extension the configured repositories offer is already installed.' - : 'No repository configured, so there is nothing to install from.', + selectedLangs.size > 0 + ? 'No available extension matches the selected languages.' + : hasRepos + ? 'Every extension the configured repositories offer is already installed.' + : 'No repository configured, so there is nothing to install from.', ), ); } @@ -231,7 +292,7 @@ export function createExtensionsPanel(options: ExtensionsPanelOptions) { extensionsDirLabel.textContent = snapshot.extensionsDir; renderRepos(snapshot.repos); - const repoFailures: Array<{ name: string; error: string }> = []; + repoFailures = []; let available; try { available = await api.listAvailableExtensions(); @@ -245,13 +306,11 @@ export function createExtensionsPanel(options: ExtensionsPanelOptions) { const offeredPkgs = new Set(available.extensions.map((extension) => extension.pkg)); renderInstalled(snapshot.installed, offeredPkgs, snapshot.extensionsDir); - renderAvailable( - // Installed extensions have their own section; leaving them here too - // would list every one of them twice. - available.extensions.filter((extension) => !extension.installed), - repoFailures, - snapshot.repos.length > 0, - ); + // Installed extensions have their own section; leaving them here too would + // list every one of them twice. + installable = available.extensions.filter((extension) => !extension.installed); + hasRepos = snapshot.repos.length > 0; + renderAvailable(); } repoAddButton.addEventListener('click', () => { diff --git a/src/animeui/index.html b/src/animeui/index.html index 235651ab..e2488e16 100644 --- a/src/animeui/index.html +++ b/src/animeui/index.html @@ -115,7 +115,10 @@
-

Available

+

+ Available +

+
diff --git a/src/animeui/language-filter.test.ts b/src/animeui/language-filter.test.ts new file mode 100644 index 00000000..515040ea --- /dev/null +++ b/src/animeui/language-filter.test.ts @@ -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'], + ); +}); diff --git a/src/animeui/language-filter.ts b/src/animeui/language-filter.ts new file mode 100644 index 00000000..c8d3ae3a --- /dev/null +++ b/src/animeui/language-filter.ts @@ -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, code: string): Set { + 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, + available: ReadonlyArray, +): Set { + 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( + extensions: ReadonlyArray, + selected: ReadonlySet, +): T[] { + if (selected.size === 0) return [...extensions]; + return extensions.filter((extension) => selected.has(extension.lang)); +} diff --git a/src/animeui/style.css b/src/animeui/style.css index ae8f995a..6f59b553 100644 --- a/src/animeui/style.css +++ b/src/animeui/style.css @@ -823,6 +823,46 @@ body { 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 { display: flex; flex-direction: column;