mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-08-01 19:21:34 -07:00
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:
@@ -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.
|
||||||
|
|||||||
@@ -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', () => {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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'],
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user