mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-22 14:35:41 +00:00
fix(settings): improve in-page search matching and coverage (#968)
* fix(settings): improve in-page search matching and coverage Index AudioMuse and individual shortcut rows, tighten fuzzy matching so junk queries return no hits, and scroll to the parent subsection when a shortcut result is selected. * docs: note PR #968 in changelog and settings credits
This commit is contained in:
@@ -482,6 +482,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
* Fractional custom minutes (e.g. `0.1`, `0.01`) now share the same delay math between the modal preview, armed timer, and play-button countdown so the displayed remaining time matches when playback starts or pauses.
|
* Fractional custom minutes (e.g. `0.1`, `0.01`) now share the same delay math between the modal preview, armed timer, and play-button countdown so the displayed remaining time matches when playback starts or pauses.
|
||||||
|
|
||||||
|
|
||||||
|
### Settings — in-page search coverage and junk-query filtering
|
||||||
|
|
||||||
|
**By [@cucadmuh](https://github.com/cucadmuh), reported by zunoz on the Psysonic Discord, PR [#968](https://github.com/Psychotoxical/psysonic/pull/968)**
|
||||||
|
|
||||||
|
* AudioMuse-AI (Navidrome) is indexed and selectable from settings search; choosing it opens Servers and scrolls to the plugin toggle when shown.
|
||||||
|
* In-app and global shortcut labels (e.g. Volume up / Volume down) appear as search hits and focus the parent shortcuts subsection.
|
||||||
|
* Nonsense queries no longer return unrelated fuzzy matches (e.g. long repeated letters).
|
||||||
|
|
||||||
|
|
||||||
### In-page browse — virtual scroll and cover-art priority
|
### In-page browse — virtual scroll and cover-art priority
|
||||||
|
|
||||||
**By [@cucadmuh](https://github.com/cucadmuh), PR [#783](https://github.com/Psychotoxical/psysonic/pull/783)**
|
**By [@cucadmuh](https://github.com/cucadmuh), PR [#783](https://github.com/Psychotoxical/psysonic/pull/783)**
|
||||||
|
|||||||
@@ -481,6 +481,7 @@ export function ServersTab({
|
|||||||
) && (
|
) && (
|
||||||
<div
|
<div
|
||||||
className="settings-toggle-row"
|
className="settings-toggle-row"
|
||||||
|
data-settings-search={t('settings.audiomuseTitle')}
|
||||||
style={{ marginTop: '0.75rem', paddingTop: '0.75rem', borderTop: '1px solid color-mix(in srgb, var(--text-muted) 18%, transparent)' }}
|
style={{ marginTop: '0.75rem', paddingTop: '0.75rem', borderTop: '1px solid color-mix(in srgb, var(--text-muted) 18%, transparent)' }}
|
||||||
>
|
>
|
||||||
<div style={{ display: 'flex', alignItems: 'flex-start', gap: '0.5rem', minWidth: 0 }}>
|
<div style={{ display: 'flex', alignItems: 'flex-start', gap: '0.5rem', minWidth: 0 }}>
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import { matchScore } from './settingsTabs';
|
||||||
|
import { searchSettings } from './settingsSearch';
|
||||||
|
|
||||||
|
const t = vi.fn((key: string) => {
|
||||||
|
const labels: Record<string, string> = {
|
||||||
|
'settings.audiomuseTitle': 'AudioMuse-AI (Navidrome)',
|
||||||
|
'settings.servers': 'Servers',
|
||||||
|
'settings.inputKeybindingsTitle': 'In-app shortcuts',
|
||||||
|
'settings.globalShortcutsTitle': 'Global shortcuts',
|
||||||
|
'settings.shortcutVolumeUp': 'Volume up',
|
||||||
|
'settings.shortcutVolumeDown': 'Volume down',
|
||||||
|
'settings.playbackTitle': 'Playback',
|
||||||
|
};
|
||||||
|
return labels[key] ?? key;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('matchScore', () => {
|
||||||
|
it('prefers earlier substring matches', () => {
|
||||||
|
expect(matchScore('volume up global shortcuts', 'volume')).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects repeated-character junk queries via fuzzy matching', () => {
|
||||||
|
expect(matchScore('database backup analytics strategy', 'aaaaaaa')).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still fuzzy-matches compact typos', () => {
|
||||||
|
expect(matchScore('equalizer eq bass treble', 'equl')).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('searchSettings', () => {
|
||||||
|
it('finds AudioMuse by product name', () => {
|
||||||
|
const hits = searchSettings('AudioMuse', 'library', t as any);
|
||||||
|
expect(hits.some(h => h.title.includes('AudioMuse'))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('finds global volume shortcuts', () => {
|
||||||
|
const hits = searchSettings('Volume', 'library', t as any);
|
||||||
|
expect(hits.some(h => h.title === 'Volume up')).toBe(true);
|
||||||
|
expect(hits.some(h => h.title === 'Volume down')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns nothing for nonsense queries', () => {
|
||||||
|
expect(searchSettings('aaaaaaa', 'library', t as any)).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import type { TFunction } from 'i18next';
|
||||||
|
import { GLOBAL_SHORTCUT_ACTIONS, IN_APP_SHORTCUT_ACTIONS } from '../../config/shortcutActions';
|
||||||
|
import { SETTINGS_INDEX, matchScore, type Tab } from './settingsTabs';
|
||||||
|
|
||||||
|
export type SettingsSearchHit = {
|
||||||
|
tab: Tab;
|
||||||
|
title: string;
|
||||||
|
focusTitle: string;
|
||||||
|
score: number;
|
||||||
|
key: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function searchSettings(query: string, activeTab: Tab, t: TFunction): SettingsSearchHit[] {
|
||||||
|
const q = query.trim();
|
||||||
|
if (!q) return [];
|
||||||
|
|
||||||
|
const hits: SettingsSearchHit[] = [];
|
||||||
|
|
||||||
|
for (const entry of SETTINGS_INDEX) {
|
||||||
|
const title = t(entry.titleKey as any);
|
||||||
|
const hay = entry.keywords ? `${title} ${entry.keywords}` : title;
|
||||||
|
const score = matchScore(hay, q);
|
||||||
|
if (score <= 0) continue;
|
||||||
|
const focusTitle = entry.focusTitleKey ? t(entry.focusTitleKey as any) : title;
|
||||||
|
hits.push({ tab: entry.tab, title, focusTitle, score, key: entry.titleKey });
|
||||||
|
}
|
||||||
|
|
||||||
|
const inAppSection = t('settings.inputKeybindingsTitle');
|
||||||
|
for (const { id, getLabel } of IN_APP_SHORTCUT_ACTIONS) {
|
||||||
|
const title = getLabel(t);
|
||||||
|
const hay = `${title} ${inAppSection} shortcut hotkey keyboard in-app ${id.replace(/-/g, ' ')}`;
|
||||||
|
const score = matchScore(hay, q);
|
||||||
|
if (score <= 0) continue;
|
||||||
|
hits.push({ tab: 'input', title, focusTitle: inAppSection, score, key: `in-app:${id}` });
|
||||||
|
}
|
||||||
|
|
||||||
|
const globalSection = t('settings.globalShortcutsTitle');
|
||||||
|
for (const { id, getLabel } of GLOBAL_SHORTCUT_ACTIONS) {
|
||||||
|
const title = getLabel(t);
|
||||||
|
const hay = `${title} ${globalSection} shortcut hotkey global media ${id.replace(/-/g, ' ')}`;
|
||||||
|
const score = matchScore(hay, q);
|
||||||
|
if (score <= 0) continue;
|
||||||
|
hits.push({ tab: 'input', title, focusTitle: globalSection, score, key: `global:${id}` });
|
||||||
|
}
|
||||||
|
|
||||||
|
hits.sort((a, b) => {
|
||||||
|
const aCurrent = a.tab === activeTab ? 1 : 0;
|
||||||
|
const bCurrent = b.tab === activeTab ? 1 : 0;
|
||||||
|
if (aCurrent !== bCurrent) return bCurrent - aCurrent;
|
||||||
|
return b.score - a.score;
|
||||||
|
});
|
||||||
|
|
||||||
|
return hits;
|
||||||
|
}
|
||||||
@@ -29,7 +29,7 @@ export function resolveTab(input: string | undefined | null): Tab {
|
|||||||
|
|
||||||
// Statischer Suchindex ueber alle Sub-Sections aller Tabs. Mitpflegen, wenn eine
|
// Statischer Suchindex ueber alle Sub-Sections aller Tabs. Mitpflegen, wenn eine
|
||||||
// neue SettingsSubSection hinzukommt — sonst taucht sie nicht in der Suche auf.
|
// neue SettingsSubSection hinzukommt — sonst taucht sie nicht in der Suche auf.
|
||||||
export type SearchIndexEntry = { tab: Tab; titleKey: string; keywords?: string };
|
export type SearchIndexEntry = { tab: Tab; titleKey: string; keywords?: string; focusTitleKey?: string };
|
||||||
|
|
||||||
export const SETTINGS_INDEX: SearchIndexEntry[] = [
|
export const SETTINGS_INDEX: SearchIndexEntry[] = [
|
||||||
{ tab: 'audio', titleKey: 'settings.audioOutputDevice', keywords: 'output device speakers headphones alsa wasapi coreaudio' },
|
{ tab: 'audio', titleKey: 'settings.audioOutputDevice', keywords: 'output device speakers headphones alsa wasapi coreaudio' },
|
||||||
@@ -51,6 +51,7 @@ export const SETTINGS_INDEX: SearchIndexEntry[] = [
|
|||||||
{ tab: 'personalisation',titleKey: 'settings.playerBarTitle', keywords: 'player bar playback favorites stars rating lastfm love equalizer mini player controls hide show' },
|
{ tab: 'personalisation',titleKey: 'settings.playerBarTitle', keywords: 'player bar playback favorites stars rating lastfm love equalizer mini player controls hide show' },
|
||||||
{ tab: 'appearance', titleKey: 'settings.libraryGridMaxColumnsTitle', keywords: 'grid columns album artist playlist cards layout appearance performance scroll paint' },
|
{ tab: 'appearance', titleKey: 'settings.libraryGridMaxColumnsTitle', keywords: 'grid columns album artist playlist cards layout appearance performance scroll paint' },
|
||||||
{ tab: 'servers', titleKey: 'settings.servers', keywords: 'local library index sync resync verify integrity offline delta background sqlite search' },
|
{ tab: 'servers', titleKey: 'settings.servers', keywords: 'local library index sync resync verify integrity offline delta background sqlite search' },
|
||||||
|
{ tab: 'servers', titleKey: 'settings.audiomuseTitle', keywords: 'audiomuse audio muse navidrome plugin instant mix similar songs lucky mix' },
|
||||||
{ tab: 'library', titleKey: 'settings.analyticsStrategyTitle', keywords: 'analytics strategy analysis bpm enrichment waveform lazy advanced library backfill' },
|
{ tab: 'library', titleKey: 'settings.analyticsStrategyTitle', keywords: 'analytics strategy analysis bpm enrichment waveform lazy advanced library backfill' },
|
||||||
{ tab: 'library', titleKey: 'settings.randomMixTitle', keywords: 'random mix blacklist genre keywords filter audiobook' },
|
{ tab: 'library', titleKey: 'settings.randomMixTitle', keywords: 'random mix blacklist genre keywords filter audiobook' },
|
||||||
{ tab: 'library', titleKey: 'settings.ratingsSectionTitle', keywords: 'ratings stars skip threshold manual' },
|
{ tab: 'library', titleKey: 'settings.ratingsSectionTitle', keywords: 'ratings stars skip threshold manual' },
|
||||||
@@ -76,19 +77,26 @@ export const SETTINGS_INDEX: SearchIndexEntry[] = [
|
|||||||
{ tab: 'system', titleKey: 'licenses.title', keywords: 'licenses license open source attribution copyright third party dependencies oss' },
|
{ tab: 'system', titleKey: 'licenses.title', keywords: 'licenses license open source attribution copyright third party dependencies oss' },
|
||||||
];
|
];
|
||||||
|
|
||||||
// Substring-first, Fuzzy-Fallback (alle Query-Zeichen in Reihenfolge im
|
// Substring-first, compact fuzzy fallback (query chars in order within a
|
||||||
// Haystack). Rueckgabe 0 = kein Match. Hoeher = besser.
|
// short span). Returns 0 = no match. Higher = better.
|
||||||
export function matchScore(haystack: string, needle: string): number {
|
export function matchScore(haystack: string, needle: string): number {
|
||||||
if (!needle) return 0;
|
if (!needle) return 0;
|
||||||
const h = haystack.toLowerCase();
|
const h = haystack.toLowerCase();
|
||||||
const n = needle.toLowerCase();
|
const n = needle.toLowerCase();
|
||||||
const idx = h.indexOf(n);
|
const idx = h.indexOf(n);
|
||||||
if (idx >= 0) return 1000 - Math.min(999, idx);
|
if (idx >= 0) return 1000 - Math.min(999, idx);
|
||||||
|
// Repeated single-char queries ("aaaaaaa") must not match via sparse fuzzy hits.
|
||||||
|
if (n.length >= 4 && /^(.)\1+$/.test(n)) return 0;
|
||||||
let hi = 0;
|
let hi = 0;
|
||||||
|
let start = -1;
|
||||||
for (const ch of n) {
|
for (const ch of n) {
|
||||||
const j = h.indexOf(ch, hi);
|
const j = h.indexOf(ch, hi);
|
||||||
if (j < 0) return 0;
|
if (j < 0) return 0;
|
||||||
|
if (start < 0) start = j;
|
||||||
hi = j + 1;
|
hi = j + 1;
|
||||||
}
|
}
|
||||||
return Math.max(1, 100 - Math.min(99, hi - n.length));
|
const span = hi - start;
|
||||||
|
if (span > n.length * 2) return 0;
|
||||||
|
if (n.length >= 4 && span > n.length + 3) return 0;
|
||||||
|
return Math.max(1, 100 - Math.min(99, span - n.length));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -153,6 +153,7 @@ const CONTRIBUTOR_ENTRIES = [
|
|||||||
'Cover backfill: follow the smart local/public endpoint switch so off-LAN clients stop fetching covers from the unreachable local address (PR #952)',
|
'Cover backfill: follow the smart local/public endpoint switch so off-LAN clients stop fetching covers from the unreachable local address (PR #952)',
|
||||||
'Player: persist volume/repeat/queue visibility/Last.fm cache outside quota-bound queue blob (report: norp on Psysonic Discord) (PR #958)',
|
'Player: persist volume/repeat/queue visibility/Last.fm cache outside quota-bound queue blob (report: norp on Psysonic Discord) (PR #958)',
|
||||||
'Player transport: custom delay input capped to browser timer limit; preview/countdown aligned for fractional minutes (report: zunoz on Psysonic Discord) (PR #967)',
|
'Player transport: custom delay input capped to browser timer limit; preview/countdown aligned for fractional minutes (report: zunoz on Psysonic Discord) (PR #967)',
|
||||||
|
'Settings: in-page search indexes AudioMuse + shortcut rows; rejects junk fuzzy matches (report: zunoz on Psysonic Discord) (PR #968)',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
+7
-22
@@ -16,7 +16,8 @@ import { PersonalisationTab } from '../components/settings/PersonalisationTab';
|
|||||||
import { ServersTab } from '../components/settings/ServersTab';
|
import { ServersTab } from '../components/settings/ServersTab';
|
||||||
import { StorageTab } from '../components/settings/StorageTab';
|
import { StorageTab } from '../components/settings/StorageTab';
|
||||||
import { SystemTab } from '../components/settings/SystemTab';
|
import { SystemTab } from '../components/settings/SystemTab';
|
||||||
import { SETTINGS_INDEX, type Tab, matchScore, resolveTab } from '../components/settings/settingsTabs';
|
import { searchSettings, type SettingsSearchHit } from '../components/settings/settingsSearch';
|
||||||
|
import { type Tab, resolveTab } from '../components/settings/settingsTabs';
|
||||||
import { UserManagementSection } from '../components/settings/UserManagementSection';
|
import { UserManagementSection } from '../components/settings/UserManagementSection';
|
||||||
import { ndLogin } from '../api/navidromeAdmin';
|
import { ndLogin } from '../api/navidromeAdmin';
|
||||||
import { type ServerMagicPayload } from '../utils/server/serverMagicString';
|
import { type ServerMagicPayload } from '../utils/server/serverMagicString';
|
||||||
@@ -32,7 +33,7 @@ export default function Settings() {
|
|||||||
const [activeTab, setActiveTab] = useState<Tab>(resolveTab((routeState as { tab?: string } | null)?.tab));
|
const [activeTab, setActiveTab] = useState<Tab>(resolveTab((routeState as { tab?: string } | null)?.tab));
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
const [searchOpen, setSearchOpen] = useState(false);
|
const [searchOpen, setSearchOpen] = useState(false);
|
||||||
const [searchResults, setSearchResults] = useState<Array<{ tab: Tab; titleKey: string; title: string; score: number }>>([]);
|
const [searchResults, setSearchResults] = useState<SettingsSearchHit[]>([]);
|
||||||
const [selectedResultIdx, setSelectedResultIdx] = useState(0);
|
const [selectedResultIdx, setSelectedResultIdx] = useState(0);
|
||||||
const [pendingFocusTitle, setPendingFocusTitle] = useState<string | null>(null);
|
const [pendingFocusTitle, setPendingFocusTitle] = useState<string | null>(null);
|
||||||
const [pendingServerInvite, setPendingServerInvite] = useState<ServerMagicPayload | null>(null);
|
const [pendingServerInvite, setPendingServerInvite] = useState<ServerMagicPayload | null>(null);
|
||||||
@@ -61,23 +62,7 @@ export default function Settings() {
|
|||||||
// eine Query aktiv ist, wird der Tab-Content gerendert-nicht und stattdessen
|
// eine Query aktiv ist, wird der Tab-Content gerendert-nicht und stattdessen
|
||||||
// die Ergebnisliste angezeigt.
|
// die Ergebnisliste angezeigt.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const q = searchQuery.trim();
|
setSearchResults(searchSettings(searchQuery, activeTab, t));
|
||||||
if (!q) {
|
|
||||||
setSearchResults([]);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const scored = SETTINGS_INDEX.map(entry => {
|
|
||||||
const title = t(entry.titleKey as any);
|
|
||||||
const hay = entry.keywords ? `${title} ${entry.keywords}` : title;
|
|
||||||
return { ...entry, title, score: matchScore(hay, q) };
|
|
||||||
}).filter(e => e.score > 0);
|
|
||||||
scored.sort((a, b) => {
|
|
||||||
const aCurrent = a.tab === activeTab ? 1 : 0;
|
|
||||||
const bCurrent = b.tab === activeTab ? 1 : 0;
|
|
||||||
if (aCurrent !== bCurrent) return bCurrent - aCurrent;
|
|
||||||
return b.score - a.score;
|
|
||||||
});
|
|
||||||
setSearchResults(scored);
|
|
||||||
setSelectedResultIdx(0);
|
setSelectedResultIdx(0);
|
||||||
}, [searchQuery, activeTab, t]);
|
}, [searchQuery, activeTab, t]);
|
||||||
|
|
||||||
@@ -213,7 +198,7 @@ export default function Settings() {
|
|||||||
if (!hit) return;
|
if (!hit) return;
|
||||||
setSearchQuery('');
|
setSearchQuery('');
|
||||||
setSearchOpen(false);
|
setSearchOpen(false);
|
||||||
setPendingFocusTitle(hit.title);
|
setPendingFocusTitle(hit.focusTitle);
|
||||||
setActiveTab(hit.tab);
|
setActiveTab(hit.tab);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
@@ -275,7 +260,7 @@ export default function Settings() {
|
|||||||
const tabLabelKey = TAB_LABEL_KEY[hit.tab];
|
const tabLabelKey = TAB_LABEL_KEY[hit.tab];
|
||||||
const selected = idx === selectedResultIdx;
|
const selected = idx === selectedResultIdx;
|
||||||
return (
|
return (
|
||||||
<li key={`${hit.tab}:${hit.titleKey}`}>
|
<li key={hit.key}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="settings-search-result-item"
|
className="settings-search-result-item"
|
||||||
@@ -284,7 +269,7 @@ export default function Settings() {
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
setSearchQuery('');
|
setSearchQuery('');
|
||||||
setSearchOpen(false);
|
setSearchOpen(false);
|
||||||
setPendingFocusTitle(hit.title);
|
setPendingFocusTitle(hit.focusTitle);
|
||||||
setActiveTab(hit.tab);
|
setActiveTab(hit.tab);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
Reference in New Issue
Block a user