diff --git a/src/locales/de.ts b/src/locales/de.ts
index cd19dd75..51e89aa4 100644
--- a/src/locales/de.ts
+++ b/src/locales/de.ts
@@ -708,7 +708,6 @@ export const deTranslation = {
aboutContributorsCount_other: '{{count}} Beiträge',
searchPlaceholder: 'Einstellungen durchsuchen…',
searchNoResults: 'Keine Treffer für deine Suche.',
- searchOtherTabs: 'In anderen Tabs',
integrationsPrivacyTitle: 'Hinweis zum Datenschutz',
integrationsPrivacyBody: 'Alle Integrationen auf diesem Tab sind Opt-in und senden, sobald aktiviert, Daten an externe Dienste oder an deinen Navidrome-Server. Last.fm erhält deinen Wiedergabeverlauf, Discord zeigt den aktuell laufenden Track in deinem Profil an, Bandsintown wird pro Künstler für Tour-Daten abgefragt, und der „Jetzt läuft"-Hinweis veröffentlicht deinen aktuellen Titel für andere Benutzer deines Navidrome-Servers. Wer das nicht möchte, lässt den jeweiligen Abschnitt einfach deaktiviert.',
homeCustomizerTitle: 'Startseite',
@@ -795,7 +794,7 @@ export const deTranslation = {
gaplessDesc: 'Nächsten Track vorpuffern um Lücken zwischen Songs zu vermeiden',
preloadMode: 'Nächsten Track vorpuffern',
preloadModeDesc: 'Wann mit dem Puffern des nächsten Tracks begonnen werden soll',
- nextTrackBufferingTitle: 'Nächster Track – Pufferung',
+ nextTrackBufferingTitle: 'Pufferung',
preloadHotCacheMutualExclusive: 'Preload und Hot Cache erfüllen denselben Zweck – nur eine Methode kann gleichzeitig aktiv sein. Das Aktivieren einer deaktiviert die andere automatisch.',
preloadOff: 'Aus',
preloadBalanced: 'Ausgewogen (30 s vor Ende)',
diff --git a/src/locales/en.ts b/src/locales/en.ts
index 53966c0f..739356c3 100644
--- a/src/locales/en.ts
+++ b/src/locales/en.ts
@@ -710,7 +710,6 @@ export const enTranslation = {
aboutContributorsCount_other: '{{count}} contributions',
searchPlaceholder: 'Search settings…',
searchNoResults: 'No settings match your search.',
- searchOtherTabs: 'In other tabs',
integrationsPrivacyTitle: 'Privacy notice',
integrationsPrivacyBody: 'All integrations on this tab are opt-in and send data to external services or to your Navidrome server when enabled. Last.fm receives your listening history, Discord shows the currently playing track in your profile, Bandsintown is queried per artist to fetch tour dates, and the Now-Playing share publishes your current track to other users of your Navidrome server. If you don\'t want any of this, simply leave the corresponding section disabled.',
homeCustomizerTitle: 'Home Page',
@@ -797,7 +796,7 @@ export const enTranslation = {
gaplessDesc: 'Pre-buffer next track to eliminate gaps between songs',
preloadMode: 'Preload Next Track',
preloadModeDesc: 'When to start buffering the next track in the queue',
- nextTrackBufferingTitle: 'Next Track Buffering',
+ nextTrackBufferingTitle: 'Buffering',
preloadHotCacheMutualExclusive: 'Preload and Hot Cache serve the same purpose — only one can be active at a time. Enabling one will automatically disable the other.',
preloadOff: 'Off',
preloadBalanced: 'Balanced (30 s before end)',
diff --git a/src/locales/es.ts b/src/locales/es.ts
index a5d34165..5312bb44 100644
--- a/src/locales/es.ts
+++ b/src/locales/es.ts
@@ -700,7 +700,6 @@ export const esTranslation = {
aboutContributorsCount_other: '{{count}} contribuciones',
searchPlaceholder: 'Buscar en ajustes…',
searchNoResults: 'Ningún ajuste coincide con tu búsqueda.',
- searchOtherTabs: 'En otras pestañas',
aboutMaintainersLabel: 'Mantenedores',
integrationsPrivacyTitle: 'Aviso de privacidad',
integrationsPrivacyBody: 'Todas las integraciones de esta pestaña son opt-in y, una vez activadas, envían datos a servicios externos o a tu servidor Navidrome. Last.fm recibe tu historial de escucha, Discord muestra la pista actual en tu perfil, Bandsintown se consulta por artista para obtener fechas de gira, y la compartición «En reproducción» publica tu pista actual para otros usuarios de tu servidor Navidrome. Si no quieres nada de esto, simplemente deja desactivada la sección correspondiente.',
@@ -788,7 +787,7 @@ export const esTranslation = {
gaplessDesc: 'Precarga siguiente pista para eliminar silencios entre canciones',
preloadMode: 'Precargar Siguiente Pista',
preloadModeDesc: 'Cuándo comenzar a cargar la siguiente pista en cola',
- nextTrackBufferingTitle: 'Buffer de Siguiente Pista',
+ nextTrackBufferingTitle: 'Buffer',
preloadHotCacheMutualExclusive: 'Precarga y Caché Activa sirven para lo mismo — solo uno puede estar activo a la vez. Habilitar uno desactivará automáticamente el otro.',
preloadOff: 'Apagado',
preloadBalanced: 'Balanceado (30 s antes del final)',
diff --git a/src/locales/fr.ts b/src/locales/fr.ts
index ff98514a..971ac814 100644
--- a/src/locales/fr.ts
+++ b/src/locales/fr.ts
@@ -695,7 +695,6 @@ export const frTranslation = {
aboutContributorsCount_other: '{{count}} contributions',
searchPlaceholder: 'Rechercher dans les paramètres…',
searchNoResults: 'Aucun réglage ne correspond à votre recherche.',
- searchOtherTabs: 'Dans d\'autres onglets',
aboutMaintainersLabel: 'Mainteneurs',
integrationsPrivacyTitle: 'Avis de confidentialité',
integrationsPrivacyBody: 'Toutes les intégrations de cet onglet sont facultatives et, une fois activées, envoient des données à des services externes ou à votre serveur Navidrome. Last.fm reçoit votre historique d\'écoute, Discord affiche le morceau en cours dans votre profil, Bandsintown est interrogé par artiste pour récupérer les dates de tournée, et le partage « En cours de lecture » publie votre morceau actuel auprès des autres utilisateurs de votre serveur Navidrome. Si vous ne voulez rien de tout cela, laissez simplement la section correspondante désactivée.',
@@ -783,7 +782,7 @@ export const frTranslation = {
gaplessDesc: 'Préparer la piste suivante pour éliminer les silences entre les morceaux',
preloadMode: 'Précharger la piste suivante',
preloadModeDesc: 'Quand commencer à mettre en mémoire tampon la piste suivante',
- nextTrackBufferingTitle: 'Piste suivante – mise en mémoire tampon',
+ nextTrackBufferingTitle: 'Mise en mémoire tampon',
preloadHotCacheMutualExclusive: "Preload et Hot Cache remplissent le même rôle — un seul peut être actif à la fois. Activer l'un désactive automatiquement l'autre.",
preloadOff: 'Désactivé',
preloadBalanced: 'Équilibré (30 s avant la fin)',
diff --git a/src/locales/nb.ts b/src/locales/nb.ts
index dfad151d..2e9d7042 100644
--- a/src/locales/nb.ts
+++ b/src/locales/nb.ts
@@ -694,7 +694,6 @@ export const nbTranslation = {
aboutContributorsCount_other: '{{count}} bidrag',
searchPlaceholder: 'Søk i innstillinger…',
searchNoResults: 'Ingen innstillinger samsvarer med søket ditt.',
- searchOtherTabs: 'I andre faner',
aboutMaintainersLabel: 'Ansvarlige',
integrationsPrivacyTitle: 'Personvern-merknad',
integrationsPrivacyBody: 'Alle integrasjoner på denne fanen er frivillige og sender, når aktivert, data til eksterne tjenester eller til Navidrome-serveren din. Last.fm mottar lyttehistorikken din, Discord viser gjeldende spor i profilen din, Bandsintown spørres per artist for turnédatoer, og "Spilles nå"-delingen publiserer gjeldende spor til andre brukere av Navidrome-serveren din. Hvis du ikke ønsker noe av dette, la den aktuelle seksjonen stå deaktivert.',
@@ -785,7 +784,7 @@ export const nbTranslation = {
experimental: 'Eksperimentell',
preloadMode: 'Forhåndslast neste spor',
preloadModeDesc: 'Når buffering av neste spor i køen skal starte',
- nextTrackBufferingTitle: 'Neste spor – bufring',
+ nextTrackBufferingTitle: 'Bufring',
preloadHotCacheMutualExclusive: 'Forhåndslasting og Hot Cache tjener samme formål – bare én kan være aktiv om gangen. Å aktivere den ene deaktiverer automatisk den andre.',
preloadOff: 'Av',
preloadBalanced: 'Balansert (30 s før slutt)',
diff --git a/src/locales/nl.ts b/src/locales/nl.ts
index a94b195d..4a004d84 100644
--- a/src/locales/nl.ts
+++ b/src/locales/nl.ts
@@ -694,7 +694,6 @@ export const nlTranslation = {
aboutContributorsCount_other: '{{count}} bijdragen',
searchPlaceholder: 'Instellingen zoeken…',
searchNoResults: 'Geen instellingen komen overeen met je zoekopdracht.',
- searchOtherTabs: 'In andere tabbladen',
aboutMaintainersLabel: 'Beheerders',
integrationsPrivacyTitle: 'Privacyverklaring',
integrationsPrivacyBody: 'Alle integraties op dit tabblad zijn opt-in en sturen, eenmaal ingeschakeld, gegevens naar externe diensten of naar je Navidrome-server. Last.fm ontvangt je luistergeschiedenis, Discord toont het nu spelende nummer in je profiel, Bandsintown wordt per artiest bevraagd voor tourdatums, en de "Nu aan het afspelen"-deling publiceert je huidige nummer bij andere gebruikers van je Navidrome-server. Wil je dit niet, laat dan de bijbehorende sectie gewoon uitgeschakeld.',
@@ -782,7 +781,7 @@ export const nlTranslation = {
gaplessDesc: 'Volgend nummer vooraf bufferen om stiltes tussen nummers te elimineren',
preloadMode: 'Volgend nummer vooraf laden',
preloadModeDesc: 'Wanneer met het bufferen van het volgende nummer wordt begonnen',
- nextTrackBufferingTitle: 'Volgend nummer – buffering',
+ nextTrackBufferingTitle: 'Buffering',
preloadHotCacheMutualExclusive: 'Preload en Hot Cache dienen hetzelfde doel — slechts één kan tegelijk actief zijn. Het inschakelen van de ene schakelt de andere automatisch uit.',
preloadOff: 'Uit',
preloadBalanced: 'Gebalanceerd (30 s voor einde)',
diff --git a/src/locales/ru.ts b/src/locales/ru.ts
index 4389d7e7..4b2dbf3e 100644
--- a/src/locales/ru.ts
+++ b/src/locales/ru.ts
@@ -732,7 +732,6 @@ export const ruTranslation = {
aboutContributorsCount_other: '{{count}} вклада',
searchPlaceholder: 'Поиск в настройках…',
searchNoResults: 'Ничего не найдено по вашему запросу.',
- searchOtherTabs: 'В других вкладках',
aboutMaintainersLabel: 'Сопровождающие',
integrationsPrivacyTitle: 'Уведомление о конфиденциальности',
integrationsPrivacyBody: 'Все интеграции на этой вкладке включаются по желанию и при активации отправляют данные во внешние сервисы или на ваш сервер Navidrome. Last.fm получает вашу историю прослушивания, Discord показывает текущую композицию в вашем профиле, Bandsintown опрашивается по артисту для получения дат гастролей, а «Сейчас играет» делится текущей композицией с другими пользователями вашего сервера Navidrome. Если вы не хотите ничего из этого, просто оставьте соответствующий раздел отключённым.',
@@ -822,7 +821,7 @@ export const ruTranslation = {
gaplessDesc: 'Заранее подгружать следующий трек, чтобы не было тишины',
preloadMode: 'Предзагрузка следующего трека',
preloadModeDesc: 'Когда начинать буферизацию следующего в очереди',
- nextTrackBufferingTitle: 'Буферизация следующего трека',
+ nextTrackBufferingTitle: 'Буферизация',
preloadHotCacheMutualExclusive: 'Предзагрузка и Hot Cache выполняют одну функцию — одновременно может быть активна только одна. Включение одной автоматически отключает другую.',
preloadOff: 'Выкл.',
preloadBalanced: 'Умеренно (за 30 с до конца)',
diff --git a/src/locales/zh.ts b/src/locales/zh.ts
index 40e76be9..ce9c13ac 100644
--- a/src/locales/zh.ts
+++ b/src/locales/zh.ts
@@ -689,7 +689,6 @@ export const zhTranslation = {
aboutContributorsCount_other: '{{count}} 项贡献',
searchPlaceholder: '搜索设置…',
searchNoResults: '没有设置匹配您的搜索。',
- searchOtherTabs: '在其他标签页中',
aboutMaintainersLabel: '维护者',
integrationsPrivacyTitle: '隐私说明',
integrationsPrivacyBody: '此标签页中的所有集成均为 选择加入,启用后会向外部服务或您的 Navidrome 服务器发送数据。Last.fm 接收您的收听历史,Discord 在您的个人资料中显示正在播放的曲目,Bandsintown 会按艺人查询以获取巡演日期,"正在播放" 分享会向您 Navidrome 服务器的其他用户公开您当前的曲目。如果您不需要这些功能,只需将相应部分保持停用即可。',
@@ -777,7 +776,7 @@ export const zhTranslation = {
gaplessDesc: '预缓冲下一首曲目以消除歌曲间的间隙',
preloadMode: '预加载下一曲目',
preloadModeDesc: '何时开始缓冲队列中的下一曲目',
- nextTrackBufferingTitle: '下一曲目缓冲',
+ nextTrackBufferingTitle: '缓冲',
preloadHotCacheMutualExclusive: '预加载与热缓存用途相同——两者不能同时启用。启用其中一个会自动禁用另一个。',
preloadOff: '关闭',
preloadBalanced: '均衡(结束前30秒)',
diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx
index 36f83d95..1b50f124 100644
--- a/src/pages/Settings.tsx
+++ b/src/pages/Settings.tsx
@@ -1454,10 +1454,11 @@ export default function Settings() {
const [activeTab, setActiveTab] = useState(resolveTab((routeState as { tab?: string } | null)?.tab));
const [searchQuery, setSearchQuery] = useState('');
const [searchOpen, setSearchOpen] = useState(false);
- // -1 bedeutet: keine aktive Suche; >= 0 ist die Trefferzahl im aktuellen Tab.
- const [searchHits, setSearchHits] = useState(-1);
- const [otherTabHits, setOtherTabHits] = useState>([]);
+ const [searchResults, setSearchResults] = useState>([]);
+ const [selectedResultIdx, setSelectedResultIdx] = useState(0);
const [pendingFocusTitle, setPendingFocusTitle] = useState(null);
+ const searchInputRef = useRef(null);
+ const searchResultsListRef = useRef(null);
// Server-Liste DnD
const psyDragState = useDragDrop();
@@ -1509,16 +1510,14 @@ export default function Settings() {
if (st?.tab) setActiveTab(st.tab);
}, [routeState, location.pathname, location.search, location.hash, navigate]);
- // Settings-Suche: matcht SETTINGS_INDEX gegen den Query (Substring + Fuzzy-
- // Fallback). Aktueller Tab wird per DOM gehideded; Treffer aus anderen Tabs
- // landen in otherTabHits und werden als Liste unter dem Tab-Inhalt gerendert.
+ // Settings-Suche: matcht SETTINGS_INDEX gegen den Query (Substring + Fuzzy).
+ // Ergebnis ist eine flache Liste; aktueller Tab zuerst, dann nach Score. Wenn
+ // eine Query aktiv ist, wird der Tab-Content gerendert-nicht und stattdessen
+ // die Ergebnisliste angezeigt.
useEffect(() => {
const q = searchQuery.trim();
- const subs = document.querySelectorAll('[data-settings-search]');
if (!q) {
- subs.forEach(el => el.classList.remove('settings-sub-section--hidden'));
- setSearchHits(-1);
- setOtherTabHits([]);
+ setSearchResults([]);
return;
}
const scored = SETTINGS_INDEX.map(entry => {
@@ -1526,39 +1525,65 @@ export default function Settings() {
const hay = entry.keywords ? `${title} ${entry.keywords}` : title;
return { ...entry, title, score: matchScore(hay, q) };
}).filter(e => e.score > 0);
-
- const currentTitles = new Set(scored.filter(e => e.tab === activeTab).map(e => e.title));
- let hits = 0;
- subs.forEach(el => {
- const title = el.getAttribute('data-settings-search') || '';
- if (currentTitles.has(title)) {
- el.classList.remove('settings-sub-section--hidden');
- hits++;
- } else {
- el.classList.add('settings-sub-section--hidden');
- }
+ 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;
});
- setSearchHits(hits);
- setOtherTabHits(
- scored
- .filter(e => e.tab !== activeTab)
- .sort((a, b) => b.score - a.score)
- .slice(0, 12),
- );
- });
+ setSearchResults(scored);
+ setSelectedResultIdx(0);
+ }, [searchQuery, activeTab, t]);
- // Nach Tab-Wechsel aus der "Other tabs"-Ergebnisliste: Ziel-Sub-Section
- // oeffnen und in den Viewport scrollen.
+ // Selektion ins Blickfeld scrollen (nur wenn das Item out-of-view ist).
+ useEffect(() => {
+ if (!searchQuery || searchResults.length === 0) return;
+ const list = searchResultsListRef.current;
+ if (!list) return;
+ const item = list.children[selectedResultIdx] as HTMLElement | undefined;
+ item?.scrollIntoView({ block: 'nearest' });
+ }, [selectedResultIdx, searchQuery, searchResults.length]);
+
+ // Ctrl/Cmd+F oeffnet die Settings-Suche (nur auf der Settings-Seite — dieser
+ // Effect ist ja an Settings gebunden). Fokussiert das Feld auch wenn's schon
+ // offen ist. preventDefault blockt die native WebKit-Find-Bar.
+ useEffect(() => {
+ const onKey = (e: KeyboardEvent) => {
+ if (e.key !== 'f' && e.key !== 'F') return;
+ if (!(e.ctrlKey || e.metaKey)) return;
+ if (e.altKey || e.shiftKey) return;
+ e.preventDefault();
+ setSearchOpen(true);
+ window.setTimeout(() => {
+ searchInputRef.current?.focus();
+ searchInputRef.current?.select();
+ }, 0);
+ };
+ window.addEventListener('keydown', onKey);
+ return () => window.removeEventListener('keydown', onKey);
+ }, []);
+
+ // Nach Klick auf ein Ergebnis: Ziel-Sub-Section oeffnen, scrollen und kurz
+ // highlighten, damit der User auf dem neuen Tab sofort weiss welcher Eintrag
+ // gemeint war.
useEffect(() => {
if (!pendingFocusTitle) return;
const el = document.querySelector(
`[data-settings-search="${CSS.escape(pendingFocusTitle)}"]`,
);
- if (el) {
- if (el instanceof HTMLDetailsElement) el.open = true;
- el.scrollIntoView({ behavior: 'smooth', block: 'start' });
- setPendingFocusTitle(null);
- }
+ if (!el) return;
+ if (el instanceof HTMLDetailsElement) el.open = true;
+ el.scrollIntoView({ behavior: 'smooth', block: 'start' });
+ el.classList.remove('settings-sub-section--flash');
+ // reflow, damit die Animation bei wiederholtem Klick auf dasselbe Ziel
+ // erneut abspielt.
+ void el.offsetWidth;
+ el.classList.add('settings-sub-section--flash');
+ const timer = window.setTimeout(() => {
+ el.classList.remove('settings-sub-section--flash');
+ }, 1500);
+ setPendingFocusTitle(null);
+ return () => window.clearTimeout(timer);
}, [pendingFocusTitle, activeTab]);
useEffect(() => {
@@ -1932,17 +1957,35 @@ export default function Settings() {