diff --git a/src/locales/de.ts b/src/locales/de.ts index ff1a44e8..cd19dd75 100644 --- a/src/locales/de.ts +++ b/src/locales/de.ts @@ -688,7 +688,7 @@ export const deTranslation = { aboutContributorsLabel: 'Mitwirkende', showChangelogOnUpdate: "'Was ist neu' bei Update anzeigen", showChangelogOnUpdateDesc: 'Blendet nach einem Update einen dezenten Changelog-Banner über Now Playing ein. Klick öffnet die Release Notes, X blendet ihn aus.', - randomMixTitle: 'Zufallsmix', + randomMixTitle: 'Zufallsmix-Blacklist', randomMixBlacklistTitle: 'Eigene Filter-Keywords', randomMixBlacklistDesc: 'Songs werden ausgeschlossen, wenn ein Keyword auf Genre, Titel, Album oder Künstler zutrifft (aktiv wenn die Checkbox oben an ist).', randomMixBlacklistPlaceholder: 'Keyword hinzufügen…', @@ -696,7 +696,7 @@ export const deTranslation = { randomMixBlacklistEmpty: 'Noch keine eigenen Keywords hinzugefügt.', randomMixHardcodedTitle: 'Eingebaute Keywords (aktiv wenn Checkbox an)', tabAudio: 'Audio', - tabStorage: 'Speicher & Downloads', + tabStorage: 'Offline & Cache', tabAppearance: 'Darstellung', tabLibrary: 'Bibliothek', tabServers: 'Server', @@ -707,7 +707,8 @@ export const deTranslation = { aboutContributorsCount_one: '{{count}} Beitrag', aboutContributorsCount_other: '{{count}} Beiträge', searchPlaceholder: 'Einstellungen durchsuchen…', - searchNoResults: 'Keine passenden Einstellungen in diesem Tab. Probier einen anderen.', + 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', diff --git a/src/locales/en.ts b/src/locales/en.ts index b74eb856..53966c0f 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -690,7 +690,7 @@ export const enTranslation = { aboutContributorsLabel: 'Contributors', showChangelogOnUpdate: "Show 'What's New' on update", showChangelogOnUpdateDesc: "Show a discreet changelog banner above Now Playing after an update. Click opens the release notes; X dismisses it.", - randomMixTitle: 'Random Mix', + randomMixTitle: 'Random Mix Blacklist', randomMixBlacklistTitle: 'Custom Filter Keywords', randomMixBlacklistDesc: 'Songs are excluded when any keyword matches their genre, title, album, or artist (active when the checkbox above is on).', randomMixBlacklistPlaceholder: 'Add keyword…', @@ -698,7 +698,7 @@ export const enTranslation = { randomMixBlacklistEmpty: 'No custom keywords added yet.', randomMixHardcodedTitle: 'Built-in keywords (active when checkbox is on)', tabAudio: 'Audio', - tabStorage: 'Storage & Downloads', + tabStorage: 'Offline & Cache', tabAppearance: 'Appearance', tabLibrary: 'Library', tabServers: 'Servers', @@ -709,7 +709,8 @@ export const enTranslation = { aboutContributorsCount_one: '{{count}} contribution', aboutContributorsCount_other: '{{count}} contributions', searchPlaceholder: 'Search settings…', - searchNoResults: 'No matching settings in this tab. Try another tab.', + 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', diff --git a/src/locales/es.ts b/src/locales/es.ts index 6e0dd202..a5d34165 100644 --- a/src/locales/es.ts +++ b/src/locales/es.ts @@ -680,7 +680,7 @@ export const esTranslation = { aboutContributorsLabel: 'Contribuidores', showChangelogOnUpdate: "Mostrar 'Novedades' al actualizar", showChangelogOnUpdateDesc: 'Muestra un discreto banner de changelog encima de Now Playing tras una actualización. Clic abre las notas de la versión; X lo oculta.', - randomMixTitle: 'Mezcla Aleatoria', + randomMixTitle: 'Lista negra de Mezcla Aleatoria', randomMixBlacklistTitle: 'Palabras Clave de Filtro Personalizadas', randomMixBlacklistDesc: 'Las canciones se excluyen cuando cualquier palabra clave coincide con su género, título, álbum o artista (activo cuando el checkbox de arriba está activado).', randomMixBlacklistPlaceholder: 'Agregar palabra clave…', @@ -688,7 +688,7 @@ export const esTranslation = { randomMixBlacklistEmpty: 'Aún no hay palabras clave personalizadas.', randomMixHardcodedTitle: 'Palabras clave integradas (activas cuando el checkbox está activado)', tabAudio: 'Audio', - tabStorage: 'Almacenamiento y Descargas', + tabStorage: 'Sin conexión y caché', tabAppearance: 'Apariencia', tabLibrary: 'Biblioteca', tabServers: 'Servidores', @@ -699,7 +699,8 @@ export const esTranslation = { aboutContributorsCount_one: '{{count}} contribución', aboutContributorsCount_other: '{{count}} contribuciones', searchPlaceholder: 'Buscar en ajustes…', - searchNoResults: 'No hay ajustes coincidentes en esta pestaña. Prueba con otra.', + 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.', diff --git a/src/locales/fr.ts b/src/locales/fr.ts index 347119fc..ff98514a 100644 --- a/src/locales/fr.ts +++ b/src/locales/fr.ts @@ -675,7 +675,7 @@ export const frTranslation = { aboutContributorsLabel: 'Contributeurs', showChangelogOnUpdate: "Afficher 'Quoi de neuf' lors des mises à jour", showChangelogOnUpdateDesc: "Affiche une bannière discrète du changelog au-dessus de Now Playing après une mise à jour. Un clic ouvre les notes de version, le X la masque.", - randomMixTitle: 'Mix aléatoire', + randomMixTitle: 'Liste noire du mix aléatoire', randomMixBlacklistTitle: 'Mots-clés de filtre personnalisés', randomMixBlacklistDesc: 'Les morceaux sont exclus si un mot-clé correspond à leur genre, titre, album ou artiste (actif quand la case ci-dessus est cochée).', randomMixBlacklistPlaceholder: 'Ajouter un mot-clé…', @@ -683,7 +683,7 @@ export const frTranslation = { randomMixBlacklistEmpty: 'Aucun mot-clé personnalisé ajouté.', randomMixHardcodedTitle: 'Mots-clés intégrés (actifs quand la case est cochée)', tabAudio: 'Audio', - tabStorage: 'Stockage & Téléchargements', + tabStorage: 'Hors ligne & Cache', tabAppearance: 'Apparence', tabLibrary: 'Bibliothèque', tabServers: 'Serveurs', @@ -694,7 +694,8 @@ export const frTranslation = { aboutContributorsCount_one: '{{count}} contribution', aboutContributorsCount_other: '{{count}} contributions', searchPlaceholder: 'Rechercher dans les paramètres…', - searchNoResults: 'Aucun paramètre correspondant dans cet onglet. Essayez un autre onglet.', + 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.', diff --git a/src/locales/nb.ts b/src/locales/nb.ts index c0ea48ca..dfad151d 100644 --- a/src/locales/nb.ts +++ b/src/locales/nb.ts @@ -674,7 +674,7 @@ export const nbTranslation = { aboutContributorsLabel: 'Bidragsytere', showChangelogOnUpdate: "Vis 'Hva er nytt' ved oppdatering til ny versjon", showChangelogOnUpdateDesc: "Viser et diskret changelog-banner over Now Playing etter en oppdatering. Klikk åpner versjonsnotatene; X skjuler det.", - randomMixTitle: 'Tilfeldig miks', + randomMixTitle: 'Svarteliste for tilfeldig miks', randomMixBlacklistTitle: 'Egendefinerte filternøkkelord', randomMixBlacklistDesc: 'Sanger ekskluderes når et nøkkelord samsvarer med sjanger, tittel, album eller artist (aktiv når avkrysningsboksen ovenfor er på).', randomMixBlacklistPlaceholder: 'Legg til nøkkelord…', @@ -688,11 +688,13 @@ export const nbTranslation = { tabPersonalisation: 'Personalisering', tabIntegrations: 'Integrasjoner', tabAppearance: 'Utseende', + tabStorage: 'Frakoblet & Cache', inputKeybindingsTitle: 'Tastatursnarveier', aboutContributorsCount_one: '{{count}} bidrag', aboutContributorsCount_other: '{{count}} bidrag', searchPlaceholder: 'Søk i innstillinger…', - searchNoResults: 'Ingen treff i denne fanen. Prøv en annen fane.', + 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.', diff --git a/src/locales/nl.ts b/src/locales/nl.ts index da9117e5..a94b195d 100644 --- a/src/locales/nl.ts +++ b/src/locales/nl.ts @@ -674,7 +674,7 @@ export const nlTranslation = { aboutContributorsLabel: 'Bijdragers', showChangelogOnUpdate: "'Wat is nieuw' tonen bij update", showChangelogOnUpdateDesc: 'Toont een discrete changelog-banner boven Now Playing na een update. Klik opent de release-notities; X verbergt hem.', - randomMixTitle: 'Willekeurige mix', + randomMixTitle: 'Willekeurige mix-blacklist', randomMixBlacklistTitle: 'Aangepaste filtertrefwoorden', randomMixBlacklistDesc: 'Nummers worden uitgesloten als een trefwoord overeenkomt met hun genre, titel, album of artiest (actief wanneer het selectievakje hierboven is aangevinkt).', randomMixBlacklistPlaceholder: 'Trefwoord toevoegen…', @@ -682,7 +682,7 @@ export const nlTranslation = { randomMixBlacklistEmpty: 'Nog geen aangepaste trefwoorden toegevoegd.', randomMixHardcodedTitle: 'Ingebouwde trefwoorden (actief wanneer selectievakje is aangevinkt)', tabAudio: 'Audio', - tabStorage: 'Opslag & Downloads', + tabStorage: 'Offline & Cache', tabAppearance: 'Weergave', tabLibrary: 'Bibliotheek', tabServers: 'Servers', @@ -693,7 +693,8 @@ export const nlTranslation = { aboutContributorsCount_one: '{{count}} bijdrage', aboutContributorsCount_other: '{{count}} bijdragen', searchPlaceholder: 'Instellingen zoeken…', - searchNoResults: 'Geen overeenkomende instellingen in dit tabblad. Probeer een ander tabblad.', + 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.', diff --git a/src/locales/ru.ts b/src/locales/ru.ts index a3dee578..4389d7e7 100644 --- a/src/locales/ru.ts +++ b/src/locales/ru.ts @@ -709,7 +709,7 @@ export const ruTranslation = { aboutContributorsLabel: 'Участники', showChangelogOnUpdate: 'Показывать «Что нового» после обновления', showChangelogOnUpdateDesc: 'После обновления над «Сейчас играет» появится ненавязчивый баннер журнала изменений. Клик открывает заметки о выпуске, X скрывает.', - randomMixTitle: 'Случайный микс', + randomMixTitle: 'Чёрный список случайного микса', randomMixBlacklistTitle: 'Свои слова-фильтры', randomMixBlacklistDesc: 'Треки скрываются, если слово встречается в жанре, названии, альбоме или исполнителе (когда включён фильтр выше).', @@ -718,7 +718,7 @@ export const ruTranslation = { randomMixBlacklistEmpty: 'Пока пусто.', randomMixHardcodedTitle: 'Встроенные слова (при включённом фильтре)', tabAudio: 'Звук', - tabStorage: 'Хранилище и загрузки', + tabStorage: 'Офлайн и кэш', tabAppearance: 'Внешний вид', tabLibrary: 'Библиотека', tabServers: 'Серверы', @@ -731,7 +731,8 @@ export const ruTranslation = { aboutContributorsCount_many: '{{count}} вкладов', aboutContributorsCount_other: '{{count}} вклада', searchPlaceholder: 'Поиск в настройках…', - searchNoResults: 'В этой вкладке нет подходящих настроек. Попробуйте другую вкладку.', + searchNoResults: 'Ничего не найдено по вашему запросу.', + searchOtherTabs: 'В других вкладках', aboutMaintainersLabel: 'Сопровождающие', integrationsPrivacyTitle: 'Уведомление о конфиденциальности', integrationsPrivacyBody: 'Все интеграции на этой вкладке включаются по желанию и при активации отправляют данные во внешние сервисы или на ваш сервер Navidrome. Last.fm получает вашу историю прослушивания, Discord показывает текущую композицию в вашем профиле, Bandsintown опрашивается по артисту для получения дат гастролей, а «Сейчас играет» делится текущей композицией с другими пользователями вашего сервера Navidrome. Если вы не хотите ничего из этого, просто оставьте соответствующий раздел отключённым.', diff --git a/src/locales/zh.ts b/src/locales/zh.ts index 30ec61b0..40e76be9 100644 --- a/src/locales/zh.ts +++ b/src/locales/zh.ts @@ -669,7 +669,7 @@ export const zhTranslation = { aboutContributorsLabel: '贡献者', showChangelogOnUpdate: '更新时显示"新功能"', showChangelogOnUpdateDesc: '更新后在「正在播放」上方显示一个低调的更新日志横幅。点击打开发行说明,X 按钮关闭。', - randomMixTitle: '随机混音', + randomMixTitle: '随机混音黑名单', randomMixBlacklistTitle: '自定义过滤关键词', randomMixBlacklistDesc: '当任何关键词匹配流派、标题、专辑或艺术家时,歌曲将被排除(当上方复选框开启时生效)。', randomMixBlacklistPlaceholder: '添加关键词…', @@ -677,7 +677,7 @@ export const zhTranslation = { randomMixBlacklistEmpty: '尚未添加自定义关键词。', randomMixHardcodedTitle: '内置关键词(复选框开启时生效)', tabAudio: '音频', - tabStorage: '存储与下载', + tabStorage: '离线与缓存', tabAppearance: '外观', tabLibrary: '媒体库', tabServers: '服务器', @@ -688,7 +688,8 @@ export const zhTranslation = { aboutContributorsCount_one: '{{count}} 项贡献', aboutContributorsCount_other: '{{count}} 项贡献', searchPlaceholder: '搜索设置…', - searchNoResults: '此标签页中没有匹配的设置。请尝试其他标签页。', + searchNoResults: '没有设置匹配您的搜索。', + searchOtherTabs: '在其他标签页中', aboutMaintainersLabel: '维护者', integrationsPrivacyTitle: '隐私说明', integrationsPrivacyBody: '此标签页中的所有集成均为 选择加入,启用后会向外部服务或您的 Navidrome 服务器发送数据。Last.fm 接收您的收听历史,Discord 在您的个人资料中显示正在播放的曲目,Bandsintown 会按艺人查询以获取巡演日期,"正在播放" 分享会向您 Navidrome 服务器的其他用户公开您当前的曲目。如果您不需要这些功能,只需将相应部分保持停用即可。', diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx index 7319f89e..36f83d95 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -228,6 +228,62 @@ function resolveTab(input: string | undefined | null): Tab { return (known as string[]).includes(input) ? (input as Tab) : 'servers'; } +// Statischer Suchindex ueber alle Sub-Sections aller Tabs. Mitpflegen, wenn eine +// neue SettingsSubSection hinzukommt — sonst taucht sie nicht in der Suche auf. +type SearchIndexEntry = { tab: Tab; titleKey: string; keywords?: string }; +const SETTINGS_INDEX: SearchIndexEntry[] = [ + { tab: 'audio', titleKey: 'settings.audioOutputDevice', keywords: 'output device speakers headphones alsa wasapi coreaudio' }, + { tab: 'audio', titleKey: 'settings.hiResTitle', keywords: 'hi-res hires resampling bit depth sample rate dsd 24bit' }, + { tab: 'audio', titleKey: 'settings.eqTitle', keywords: 'equalizer eq bass treble autoeq filter pre-gain' }, + { tab: 'audio', titleKey: 'settings.playbackTitle', keywords: 'playback crossfade gapless replaygain replay gain volume' }, + { tab: 'lyrics', titleKey: 'settings.lyricsSourcesTitle', keywords: 'lyrics sources providers lrclib netease server youlyplus karaoke standard static' }, + { tab: 'lyrics', titleKey: 'settings.sidebarLyricsStyle', keywords: 'lyrics scroll style classic apple music' }, + { tab: 'integrations', titleKey: 'settings.lfmTitle', keywords: 'last.fm lastfm scrobble' }, + { tab: 'integrations', titleKey: 'settings.discordRichPresence', keywords: 'discord rich presence rpc' }, + { tab: 'integrations', titleKey: 'settings.enableBandsintown', keywords: 'bandsintown concerts tours events' }, + { tab: 'integrations', titleKey: 'settings.nowPlayingEnabled', keywords: 'now playing share dropdown presence' }, + { tab: 'personalisation',titleKey: 'settings.sidebarTitle', keywords: 'sidebar nav navigation items reorder customize' }, + { tab: 'personalisation',titleKey: 'settings.artistLayoutTitle', keywords: 'artist page layout sections order' }, + { tab: 'personalisation',titleKey: 'settings.homeCustomizerTitle', keywords: 'home page customize sections' }, + { 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: 'storage', titleKey: 'settings.offlineDirTitle', keywords: 'offline library download directory folder cache' }, + { tab: 'storage', titleKey: 'settings.nextTrackBufferingTitle', keywords: 'next track buffering preload hot cache streaming' }, + { tab: 'storage', titleKey: 'settings.downloadsTitle', keywords: 'downloads zip export archive folder' }, + { tab: 'appearance', titleKey: 'settings.theme', keywords: 'theme color palette dark light' }, + { tab: 'appearance', titleKey: 'settings.themeSchedulerTitle', keywords: 'theme scheduler auto time dark mode sunset' }, + { tab: 'appearance', titleKey: 'settings.visualOptionsTitle', keywords: 'visual options animations effects titlebar mini player' }, + { tab: 'appearance', titleKey: 'settings.uiScaleTitle', keywords: 'ui scale zoom dpi size' }, + { tab: 'appearance', titleKey: 'settings.font', keywords: 'font typography typeface' }, + { tab: 'appearance', titleKey: 'settings.fsPlayerSection', keywords: 'fullscreen player mesh blob' }, + { tab: 'appearance', titleKey: 'settings.seekbarStyle', keywords: 'seekbar progress bar waveform' }, + { tab: 'input', titleKey: 'settings.inputKeybindingsTitle', keywords: 'keybindings shortcuts hotkeys keyboard' }, + { tab: 'input', titleKey: 'settings.globalShortcutsTitle', keywords: 'global shortcuts hotkeys system-wide media keys' }, + { tab: 'system', titleKey: 'settings.language', keywords: 'language locale translation i18n' }, + { tab: 'system', titleKey: 'settings.behavior', keywords: 'behavior tray minimize close start smooth scroll linux' }, + { tab: 'system', titleKey: 'settings.backupTitle', keywords: 'backup export import settings restore' }, + { tab: 'system', titleKey: 'settings.loggingTitle', keywords: 'log logs diagnostic debug verbose' }, + { tab: 'system', titleKey: 'settings.aboutTitle', keywords: 'about version update changelog release notes' }, + { tab: 'system', titleKey: 'settings.aboutContributorsLabel', keywords: 'contributors credits maintainers' }, +]; + +// Substring-first, Fuzzy-Fallback (alle Query-Zeichen in Reihenfolge im +// Haystack). Rueckgabe 0 = kein Match. Hoeher = besser. +function matchScore(haystack: string, needle: string): number { + if (!needle) return 0; + const h = haystack.toLowerCase(); + const n = needle.toLowerCase(); + const idx = h.indexOf(n); + if (idx >= 0) return 1000 - Math.min(999, idx); + let hi = 0; + for (const ch of n) { + const j = h.indexOf(ch, hi); + if (j < 0) return 0; + hi = j + 1; + } + return Math.max(1, 100 - Math.min(99, hi - n.length)); +} + function AddServerForm({ onSave, onCancel, @@ -1400,6 +1456,8 @@ export default function Settings() { 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 [pendingFocusTitle, setPendingFocusTitle] = useState(null); // Server-Liste DnD const psyDragState = useDragDrop(); @@ -1451,22 +1509,29 @@ export default function Settings() { if (st?.tab) setActiveTab(st.tab); }, [routeState, location.pathname, location.search, location.hash, navigate]); - // In-Page-Suche: filtert Sub-Sections des aktiven Tabs per data-settings-search - // Attribut. DOM-basiert, damit wir nicht den Query durch jede Komponente - // propagieren muessen. Laeuft nach jedem Render, also auch wenn der Tab - // wechselt und neue Sub-Sections im Baum erscheinen. + // 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. useEffect(() => { - const q = searchQuery.trim().toLowerCase(); + 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([]); 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); + + const currentTitles = new Set(scored.filter(e => e.tab === activeTab).map(e => e.title)); let hits = 0; subs.forEach(el => { - const text = (el.getAttribute('data-settings-search') || '').toLowerCase(); - if (text.includes(q)) { + const title = el.getAttribute('data-settings-search') || ''; + if (currentTitles.has(title)) { el.classList.remove('settings-sub-section--hidden'); hits++; } else { @@ -1474,8 +1539,28 @@ export default function Settings() { } }); setSearchHits(hits); + setOtherTabHits( + scored + .filter(e => e.tab !== activeTab) + .sort((a, b) => b.score - a.score) + .slice(0, 12), + ); }); + // Nach Tab-Wechsel aus der "Other tabs"-Ergebnisliste: Ziel-Sub-Section + // oeffnen und in den Viewport scrollen. + 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); + } + }, [pendingFocusTitle, activeTab]); + useEffect(() => { const server = auth.getActiveServer(); setNdAuthChecked(false); @@ -1888,12 +1973,40 @@ export default function Settings() { ))} - {searchQuery && searchHits === 0 && ( + {searchQuery && searchHits === 0 && otherTabHits.length === 0 && (
{t('settings.searchNoResults')}
)} + {searchQuery && otherTabHits.length > 0 && ( +
+
{t('settings.searchOtherTabs')}
+
    + {otherTabHits.map(hit => { + const tabLabelKey = TAB_LABEL_KEY[hit.tab]; + return ( +
  • + +
  • + ); + })} +
+
+ )} + {/* ── Audio ────────────────────────────────────────────────────────────── */} {activeTab === 'audio' && ( <> @@ -2124,187 +2237,6 @@ export default function Settings() { - {/* Next Track Buffering */} - } - > -
-
- {t('settings.preloadHotCacheMutualExclusive')} -
- - {/* Preload mode */} -
-
-
{t('settings.preloadMode')}
-
{t('settings.preloadModeDesc')}
-
- -
- {auth.preloadMode !== 'off' && ( - <> -
- {(['balanced', 'early', 'custom'] as const).map(mode => ( - - ))} -
- {auth.preloadMode === 'custom' && ( -
- auth.setPreloadCustomSeconds(parseInt(e.target.value))} - style={{ flex: 1, minWidth: 80, maxWidth: 200 }} - /> - - {t('settings.preloadCustomSeconds', { n: auth.preloadCustomSeconds })} - -
- )} - - )} - -
- - {/* Hot Cache */} -
-
-
{t('settings.hotCacheTitle')}
-
{t('settings.hotCacheDisclaimer')}
-
- -
- - {auth.hotCacheEnabled && ( -
-
- - {auth.hotCacheDownloadDir && ( - - )} - -
- {auth.hotCacheDownloadDir && ( -
- {t('settings.hotCacheDirHint')} -
- )} - -
- -
-
- {t('settings.cacheUsedHot')} - {hotCacheBytes !== null ? formatBytes(hotCacheBytes) : '…'} -
-
- {t('settings.hotCacheTrackCount')} - {hotCacheTrackCount} -
-
- -
-
{t('settings.hotCacheMaxMb')}
-
- auth.setHotCacheMaxMb(parseInt(e.target.value, 10))} style={{ flex: 1, minWidth: 80, maxWidth: 200 }} id="hot-cache-max-mb-slider" /> - {snapHotCacheMb(auth.hotCacheMaxMb)} MB -
-
-
-
{t('settings.hotCacheDebounce')}
-
- auth.setHotCacheDebounceSec(parseInt(e.target.value, 10))} style={{ flex: 1, minWidth: 80, maxWidth: 200 }} id="hot-cache-debounce-slider" /> - - {Math.min(600, Math.max(0, auth.hotCacheDebounceSec)) === 0 - ? t('settings.hotCacheDebounceImmediate') - : t('settings.hotCacheDebounceSeconds', { n: Math.min(600, Math.max(0, auth.hotCacheDebounceSec)) })} - -
-
- -
- -
- )} - -
- - )} @@ -2323,32 +2255,29 @@ export default function Settings() { title={t('settings.sidebarLyricsStyle')} icon={} > -
-
- {(['classic', 'apple'] as const).map(style => { - const key = style === 'classic' ? 'Classic' : 'Apple'; - return ( - - ); - })} -
+
+ {(['classic', 'apple'] as const).map(style => { + const key = style === 'classic' ? 'Classic' : 'Apple'; + const other = style === 'classic' ? 'apple' : 'classic'; + return ( +
+
+
+
{t(`settings.sidebarLyricsStyle${key}` as any)}
+
{t(`settings.sidebarLyricsStyle${key}Desc` as any)}
+
+ +
+
+ ); + })}
@@ -2615,10 +2544,11 @@ export default function Settings() { {/* ── Library (legacy 'general' + 'server') ────────────────────────────── */} {activeTab === 'library' && ( <> - {/* Random Mix */} + {/* Random Mix Blacklist */} } + defaultOpen >

@@ -2805,7 +2735,7 @@ export default function Settings() { )} - {/* ── Storage & Downloads ───────────────────────────────────────────────── */} + {/* ── Offline & Cache ──────────────────────────────────────────────────── */} {activeTab === 'storage' && ( <> {/* Offline Library (In-App) — includes cache settings */} @@ -2904,6 +2834,187 @@ export default function Settings() {

+ {/* Next Track Buffering */} + } + > +
+
+ {t('settings.preloadHotCacheMutualExclusive')} +
+ + {/* Preload mode */} +
+
+
{t('settings.preloadMode')}
+
{t('settings.preloadModeDesc')}
+
+ +
+ {auth.preloadMode !== 'off' && ( + <> +
+ {(['balanced', 'early', 'custom'] as const).map(mode => ( + + ))} +
+ {auth.preloadMode === 'custom' && ( +
+ auth.setPreloadCustomSeconds(parseInt(e.target.value))} + style={{ flex: 1, minWidth: 80, maxWidth: 200 }} + /> + + {t('settings.preloadCustomSeconds', { n: auth.preloadCustomSeconds })} + +
+ )} + + )} + +
+ + {/* Hot Cache */} +
+
+
{t('settings.hotCacheTitle')}
+
{t('settings.hotCacheDisclaimer')}
+
+ +
+ + {auth.hotCacheEnabled && ( +
+
+ + {auth.hotCacheDownloadDir && ( + + )} + +
+ {auth.hotCacheDownloadDir && ( +
+ {t('settings.hotCacheDirHint')} +
+ )} + +
+ +
+
+ {t('settings.cacheUsedHot')} + {hotCacheBytes !== null ? formatBytes(hotCacheBytes) : '…'} +
+
+ {t('settings.hotCacheTrackCount')} + {hotCacheTrackCount} +
+
+ +
+
{t('settings.hotCacheMaxMb')}
+
+ auth.setHotCacheMaxMb(parseInt(e.target.value, 10))} style={{ flex: 1, minWidth: 80, maxWidth: 200 }} id="hot-cache-max-mb-slider" /> + {snapHotCacheMb(auth.hotCacheMaxMb)} MB +
+
+
+
{t('settings.hotCacheDebounce')}
+
+ auth.setHotCacheDebounceSec(parseInt(e.target.value, 10))} style={{ flex: 1, minWidth: 80, maxWidth: 200 }} id="hot-cache-debounce-slider" /> + + {Math.min(600, Math.max(0, auth.hotCacheDebounceSec)) === 0 + ? t('settings.hotCacheDebounceImmediate') + : t('settings.hotCacheDebounceSeconds', { n: Math.min(600, Math.max(0, auth.hotCacheDebounceSec)) })} + +
+
+ +
+ +
+ )} + +
+ + {/* ZIP Export & Archiving */} = { + library: 'settings.tabLibrary', + servers: 'settings.tabServers', + audio: 'settings.tabAudio', + lyrics: 'settings.tabLyrics', + appearance: 'settings.tabAppearance', + personalisation: 'settings.tabPersonalisation', + integrations: 'settings.tabIntegrations', + input: 'settings.tabInput', + storage: 'settings.tabStorage', + system: 'settings.tabSystem', + users: 'settings.tabUsers', +}; + function HomeCustomizer() { const { t } = useTranslation(); const { sections, toggleSection } = useHomeStore(); @@ -4125,73 +4250,46 @@ function LyricsSourcesCustomizer() { {/* Mode switch — standard three-provider pipeline vs. YouLyPlus karaoke. YouLyPlus misses silently fall back to the standard pipeline. */} -
-
- - -
-
- - {/* Static-only toggle — suppresses line/word tracking in both modes. */}
-
{t('settings.lyricsStaticOnly')}
-
{t('settings.lyricsStaticOnlyDesc')}
+
{t('settings.lyricsModeLyricsplus')}
+
{t('settings.lyricsModeLyricsplusDesc')}
-
+
+
+
+
+
{t('settings.lyricsModeStandard')}
+
{t('settings.lyricsModeStandardDesc')}
+
+
{lyricsMode === 'standard' && ( -
+
{lyricsSources.map((src, i) => { const label = t(LYRICS_SOURCE_LABEL_KEYS[src.id]); const isBefore = isPsyDragging && dropTarget?.idx === i && dropTarget.before; @@ -4217,6 +4315,20 @@ function LyricsSourcesCustomizer() { })}
)} + + {/* Static-only toggle — suppresses line/word tracking in both modes. */} +
+
+
+
{t('settings.lyricsStaticOnly')}
+
{t('settings.lyricsStaticOnlyDesc')}
+
+ +
+
); } diff --git a/src/styles/components.css b/src/styles/components.css index 4cdcac08..60620316 100644 --- a/src/styles/components.css +++ b/src/styles/components.css @@ -2605,6 +2605,66 @@ text-align: center; } +.settings-search-other-tabs { + margin-top: var(--space-4); + margin-bottom: var(--space-6); + padding: var(--space-3); + border-radius: var(--radius-md); + background: var(--bg-card); + border: 1px solid var(--border-subtle); +} +.settings-search-other-tabs-title { + font-size: 12px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--text-muted); + margin-bottom: var(--space-2); +} +.settings-search-other-tabs-list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 2px; +} +.settings-search-other-tab-item { + width: 100%; + display: flex; + align-items: center; + gap: var(--space-2); + padding: 8px 10px; + background: transparent; + border: 1px solid transparent; + border-radius: var(--radius-sm); + color: var(--text-primary); + text-align: left; + cursor: pointer; + font-size: 13px; + transition: background 0.12s, border-color 0.12s; +} +.settings-search-other-tab-item:hover { + background: var(--bg-hover); + border-color: var(--border); +} +.settings-search-other-tab-badge { + flex-shrink: 0; + font-size: 11px; + font-weight: 500; + padding: 2px 8px; + border-radius: 999px; + background: color-mix(in srgb, var(--accent) 15%, transparent); + color: var(--accent); +} +.settings-search-other-tab-title { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + /* ── Mitwirkende: Card-Grid mit klappbaren Beitragslisten ────────────────── */ .contributors-grid { display: grid;