feat(settings): cross-tab fuzzy search + tab restructure polish (#263)

Cross-tab settings search:
- Static SETTINGS_INDEX covering every sub-section of every tab, with
  free-form keywords per entry so "scrobble" finds Last.fm, "replay gain"
  finds Playback, etc.
- matchScore() does substring-first ranking, falls back to a char-in-order
  fuzzy match so typos still yield hits.
- Active tab keeps its in-place hide/show filter; matches from other tabs
  render at the top as a clickable "In other tabs" list (tab badge +
  sub-section title). Clicking clears the query, switches tab, and scrolls
  + opens the target sub-section.
- All 8 locales updated: new searchOtherTabs key, searchNoResults reworded
  (old "try another tab" hint is now handled by the list itself).

Settings restructure polish:
- "Next Track Buffering" moved from Audio to the Storage tab — sits
  between Offline Library and Downloads.
- Storage tab label renamed across 8 locales: "Storage & Downloads" →
  "Offline & Cache" (equivalents per locale). nb locale was missing
  tabStorage entirely; added. Tab id stays 'storage' to avoid breaking
  persisted state.
- Library tab: Random Mix sub-section now opens by default (previously
  both sub-sections started collapsed) and is renamed "Random Mix
  Blacklist" in all 8 locales — the section is entirely a blacklist
  config, the old title was misleading.
- Lyrics tab:
  * Standard / YouLyPlus rows use the same toggle style as the "Show
    lyrics as static text" row and stack vertically. Order swapped so
    YouLyPlus is first.
  * Provider list (Server / LRCLIB / Netease) moved to appear directly
    under the Standard toggle as its sub-item, indented, instead of after
    the static-text toggle.
  * Classic / Apple Music-like rows likewise rebuilt as vertical toggle
    rows.
- One pre-existing typo carried in from #261 fixed along the way: the
  openAddServerInvite handler in Settings referenced the renamed tab id
  'server' (pre-#259) — now 'servers'. [Note: already landed in PR #262,
  keeping this commit clean of that.]

Co-authored-by: Psychotoxical <dev@psysonic.app>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Frank Stellmacher
2026-04-22 14:25:46 +02:00
committed by GitHub
parent e89ae18780
commit ae7b94f190
10 changed files with 478 additions and 297 deletions
+4 -3
View File
@@ -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 <strong>Opt-in</strong> 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',
+4 -3
View File
@@ -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 <strong>opt-in</strong> 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',
+4 -3
View File
@@ -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 <strong>opt-in</strong> 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.',
+4 -3
View File
@@ -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 <strong>facultatives</strong> 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.',
+4 -2
View File
@@ -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 <strong>frivillige</strong> 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.',
+4 -3
View File
@@ -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 <strong>opt-in</strong> 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.',
+4 -3
View File
@@ -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: 'Все интеграции на этой вкладке включаются <strong>по желанию</strong> и при активации отправляют данные во внешние сервисы или на ваш сервер Navidrome. Last.fm получает вашу историю прослушивания, Discord показывает текущую композицию в вашем профиле, Bandsintown опрашивается по артисту для получения дат гастролей, а «Сейчас играет» делится текущей композицией с другими пользователями вашего сервера Navidrome. Если вы не хотите ничего из этого, просто оставьте соответствующий раздел отключённым.',
+4 -3
View File
@@ -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: '此标签页中的所有集成均为 <strong>选择加入</strong>,启用后会向外部服务或您的 Navidrome 服务器发送数据。Last.fm 接收您的收听历史,Discord 在您的个人资料中显示正在播放的曲目,Bandsintown 会按艺人查询以获取巡演日期,"正在播放" 分享会向您 Navidrome 服务器的其他用户公开您当前的曲目。如果您不需要这些功能,只需将相应部分保持停用即可。',
+386 -274
View File
@@ -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<number>(-1);
const [otherTabHits, setOtherTabHits] = useState<Array<{ tab: Tab; titleKey: string; title: string; score: number }>>([]);
const [pendingFocusTitle, setPendingFocusTitle] = useState<string | null>(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<HTMLElement>('[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<HTMLElement>(
`[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() {
))}
</nav>
{searchQuery && searchHits === 0 && (
{searchQuery && searchHits === 0 && otherTabHits.length === 0 && (
<div className="settings-search-empty" role="status">
{t('settings.searchNoResults')}
</div>
)}
{searchQuery && otherTabHits.length > 0 && (
<div className="settings-search-other-tabs" role="region" aria-label={t('settings.searchOtherTabs')}>
<div className="settings-search-other-tabs-title">{t('settings.searchOtherTabs')}</div>
<ul className="settings-search-other-tabs-list">
{otherTabHits.map(hit => {
const tabLabelKey = TAB_LABEL_KEY[hit.tab];
return (
<li key={`${hit.tab}:${hit.titleKey}`}>
<button
type="button"
className="settings-search-other-tab-item"
onClick={() => {
setSearchQuery('');
setSearchOpen(false);
setPendingFocusTitle(hit.title);
setActiveTab(hit.tab);
}}
>
<span className="settings-search-other-tab-badge">{t(tabLabelKey as any)}</span>
<span className="settings-search-other-tab-title">{hit.title}</span>
</button>
</li>
);
})}
</ul>
</div>
)}
{/* ── Audio ────────────────────────────────────────────────────────────── */}
{activeTab === 'audio' && (
<>
@@ -2124,187 +2237,6 @@ export default function Settings() {
</div>
</SettingsSubSection>
{/* Next Track Buffering */}
<SettingsSubSection
title={t('settings.nextTrackBufferingTitle')}
icon={<Download size={16} />}
>
<div className="settings-card">
<div style={{ fontSize: 12, color: 'var(--text-muted)', lineHeight: 1.5, marginBottom: '0.75rem' }}>
{t('settings.preloadHotCacheMutualExclusive')}
</div>
{/* Preload mode */}
<div className="settings-toggle-row">
<div>
<div style={{ fontWeight: 500 }}>{t('settings.preloadMode')}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.preloadModeDesc')}</div>
</div>
<label className="toggle-switch" aria-label={t('settings.preloadMode')}>
<input
type="checkbox"
checked={auth.preloadMode !== 'off'}
onChange={e => {
if (e.target.checked) {
auth.setPreloadMode('balanced');
if (auth.hotCacheEnabled) auth.setHotCacheEnabled(false);
} else {
auth.setPreloadMode('off');
}
}}
/>
<span className="toggle-track" />
</label>
</div>
{auth.preloadMode !== 'off' && (
<>
<div style={{ paddingLeft: '1rem', marginTop: '0.5rem', display: 'flex', alignItems: 'center', gap: '0.75rem', flexWrap: 'wrap' }}>
{(['balanced', 'early', 'custom'] as const).map(mode => (
<button
key={mode}
className={`btn ${auth.preloadMode === mode ? 'btn-primary' : 'btn-surface'}`}
style={{ fontSize: 12, padding: '3px 12px' }}
onClick={() => auth.setPreloadMode(mode)}
>
{t(`settings.preload${mode.charAt(0).toUpperCase() + mode.slice(1)}` as any)}
</button>
))}
</div>
{auth.preloadMode === 'custom' && (
<div style={{ paddingLeft: '1rem', marginTop: '0.5rem', display: 'flex', alignItems: 'center', gap: '0.75rem', flexWrap: 'wrap' }}>
<input
type="range"
min={5} max={120} step={5}
value={auth.preloadCustomSeconds}
onChange={e => auth.setPreloadCustomSeconds(parseInt(e.target.value))}
style={{ flex: 1, minWidth: 80, maxWidth: 200 }}
/>
<span style={{ fontSize: 13, color: 'var(--text-secondary)', minWidth: 36 }}>
{t('settings.preloadCustomSeconds', { n: auth.preloadCustomSeconds })}
</span>
</div>
)}
</>
)}
<div className="divider" />
{/* Hot Cache */}
<div className="settings-toggle-row">
<div>
<div style={{ fontWeight: 500 }}>{t('settings.hotCacheTitle')}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.hotCacheDisclaimer')}</div>
</div>
<label className="toggle-switch" aria-label={t('settings.hotCacheEnabled')}>
<input
type="checkbox"
checked={auth.hotCacheEnabled}
onChange={async e => {
const enabled = e.target.checked;
if (!enabled) {
await clearHotCacheDisk(auth.hotCacheDownloadDir || null);
setHotCacheBytes(0);
auth.setHotCacheEnabled(false);
} else {
auth.setHotCacheEnabled(true);
if (auth.preloadMode !== 'off') auth.setPreloadMode('off');
invoke<number>('get_hot_cache_size', { customDir: auth.hotCacheDownloadDir || null })
.then(setHotCacheBytes)
.catch(() => setHotCacheBytes(0));
}
}}
id="hot-cache-enabled-toggle"
/>
<span className="toggle-track" />
</label>
</div>
{auth.hotCacheEnabled && (
<div style={{ marginTop: '1.25rem' }}>
<div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
<input
className="input"
type="text"
readOnly
value={auth.hotCacheDownloadDir || t('settings.hotCacheDirDefault')}
style={{ flex: 1, minWidth: 0, fontSize: 13, color: auth.hotCacheDownloadDir ? 'var(--text-primary)' : 'var(--text-muted)', cursor: 'default' }}
/>
{auth.hotCacheDownloadDir && (
<button
type="button"
className="btn btn-ghost"
onClick={() => {
auth.setHotCacheDownloadDir('');
useHotCacheStore.setState({ entries: {} });
invoke<number>('get_hot_cache_size', { customDir: null }).then(setHotCacheBytes).catch(() => setHotCacheBytes(0));
}}
data-tooltip={t('settings.hotCacheDirClear')}
style={{ color: 'var(--text-muted)', flexShrink: 0 }}
>
<X size={16} />
</button>
)}
<button type="button" className="btn btn-surface" onClick={pickHotCacheDir} style={{ flexShrink: 0 }}>
<FolderOpen size={16} /> {t('settings.hotCacheDirChange')}
</button>
</div>
{auth.hotCacheDownloadDir && (
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginTop: 8, lineHeight: 1.4 }}>
{t('settings.hotCacheDirHint')}
</div>
)}
<div style={{ borderTop: '1px solid var(--border)', margin: '16px 0' }} />
<div style={{ fontSize: 12, marginBottom: 12, display: 'flex', flexDirection: 'column', gap: 3 }}>
<div style={{ color: 'var(--text-secondary)' }}>
<span style={{ color: 'var(--text-muted)', marginRight: 4 }}>{t('settings.cacheUsedHot')}</span>
{hotCacheBytes !== null ? formatBytes(hotCacheBytes) : '…'}
</div>
<div style={{ color: 'var(--text-secondary)' }}>
<span style={{ color: 'var(--text-muted)', marginRight: 4 }}>{t('settings.hotCacheTrackCount')}</span>
{hotCacheTrackCount}
</div>
</div>
<div>
<div style={{ fontWeight: 500, marginBottom: 6 }}>{t('settings.hotCacheMaxMb')}</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
<input type="range" min={32} max={20000} step={32} value={snapHotCacheMb(auth.hotCacheMaxMb)} onChange={e => auth.setHotCacheMaxMb(parseInt(e.target.value, 10))} style={{ flex: 1, minWidth: 80, maxWidth: 200 }} id="hot-cache-max-mb-slider" />
<span style={{ fontSize: 13, color: 'var(--text-secondary)', minWidth: 60 }}>{snapHotCacheMb(auth.hotCacheMaxMb)} MB</span>
</div>
</div>
<div style={{ marginTop: '0.75rem' }}>
<div style={{ fontWeight: 500, marginBottom: 6 }}>{t('settings.hotCacheDebounce')}</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
<input type="range" min={0} max={600} step={1} value={Math.min(600, Math.max(0, auth.hotCacheDebounceSec))} onChange={e => auth.setHotCacheDebounceSec(parseInt(e.target.value, 10))} style={{ flex: 1, minWidth: 80, maxWidth: 200 }} id="hot-cache-debounce-slider" />
<span style={{ fontSize: 13, color: 'var(--text-secondary)', minWidth: 80 }}>
{Math.min(600, Math.max(0, auth.hotCacheDebounceSec)) === 0
? t('settings.hotCacheDebounceImmediate')
: t('settings.hotCacheDebounceSeconds', { n: Math.min(600, Math.max(0, auth.hotCacheDebounceSec)) })}
</span>
</div>
</div>
<div style={{ borderTop: '1px solid var(--border)', margin: '16px 0' }} />
<button
type="button"
className="btn btn-ghost"
style={{ fontSize: 13 }}
onClick={async () => {
await clearHotCacheDisk(auth.hotCacheDownloadDir || null);
const b = await invoke<number>('get_hot_cache_size', { customDir: auth.hotCacheDownloadDir || null }).catch(() => 0);
setHotCacheBytes(b);
}}
>
<Trash2 size={14} /> {t('settings.hotCacheClearBtn')}
</button>
</div>
)}
</div>
</SettingsSubSection>
</>
)}
@@ -2323,32 +2255,29 @@ export default function Settings() {
title={t('settings.sidebarLyricsStyle')}
icon={<AudioLines size={16} />}
>
<div className="settings-card">
<div style={{ display: 'flex', gap: 8 }}>
{(['classic', 'apple'] as const).map(style => {
const key = style === 'classic' ? 'Classic' : 'Apple';
return (
<button
key={style}
onClick={() => auth.setSidebarLyricsStyle(style)}
style={{
flex: 1,
padding: '10px 14px',
borderRadius: 10,
border: `2px solid ${auth.sidebarLyricsStyle === style ? 'var(--accent)' : 'var(--border)'}`,
background: auth.sidebarLyricsStyle === style ? 'color-mix(in srgb, var(--accent) 12%, transparent)' : 'var(--bg-secondary)',
cursor: 'pointer',
textAlign: 'left',
color: 'var(--text-primary)',
transition: 'border-color 0.15s, background 0.15s',
}}
>
<div style={{ fontWeight: 600, fontSize: 13 }}>{t(`settings.sidebarLyricsStyle${key}` as any)}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 4 }}>{t(`settings.sidebarLyricsStyle${key}Desc` as any)}</div>
</button>
);
})}
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
{(['classic', 'apple'] as const).map(style => {
const key = style === 'classic' ? 'Classic' : 'Apple';
const other = style === 'classic' ? 'apple' : 'classic';
return (
<div key={style} className="settings-card">
<div className="settings-toggle-row">
<div>
<div style={{ fontWeight: 500 }}>{t(`settings.sidebarLyricsStyle${key}` as any)}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t(`settings.sidebarLyricsStyle${key}Desc` as any)}</div>
</div>
<label className="toggle-switch" aria-label={t(`settings.sidebarLyricsStyle${key}` as any)}>
<input
type="checkbox"
checked={auth.sidebarLyricsStyle === style}
onChange={e => auth.setSidebarLyricsStyle(e.target.checked ? style : other)}
/>
<span className="toggle-track" />
</label>
</div>
</div>
);
})}
</div>
</SettingsSubSection>
</>
@@ -2615,10 +2544,11 @@ export default function Settings() {
{/* ── Library (legacy 'general' + 'server') ────────────────────────────── */}
{activeTab === 'library' && (
<>
{/* Random Mix */}
{/* Random Mix Blacklist */}
<SettingsSubSection
title={t('settings.randomMixTitle')}
icon={<Shuffle size={16} />}
defaultOpen
>
<div className="settings-card">
<p style={{ fontSize: 13, color: 'var(--text-secondary)', marginBottom: '1rem', lineHeight: 1.5 }}>
@@ -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() {
</div>
</SettingsSubSection>
{/* Next Track Buffering */}
<SettingsSubSection
title={t('settings.nextTrackBufferingTitle')}
icon={<Download size={16} />}
>
<div className="settings-card">
<div style={{ fontSize: 12, color: 'var(--text-muted)', lineHeight: 1.5, marginBottom: '0.75rem' }}>
{t('settings.preloadHotCacheMutualExclusive')}
</div>
{/* Preload mode */}
<div className="settings-toggle-row">
<div>
<div style={{ fontWeight: 500 }}>{t('settings.preloadMode')}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.preloadModeDesc')}</div>
</div>
<label className="toggle-switch" aria-label={t('settings.preloadMode')}>
<input
type="checkbox"
checked={auth.preloadMode !== 'off'}
onChange={e => {
if (e.target.checked) {
auth.setPreloadMode('balanced');
if (auth.hotCacheEnabled) auth.setHotCacheEnabled(false);
} else {
auth.setPreloadMode('off');
}
}}
/>
<span className="toggle-track" />
</label>
</div>
{auth.preloadMode !== 'off' && (
<>
<div style={{ paddingLeft: '1rem', marginTop: '0.5rem', display: 'flex', alignItems: 'center', gap: '0.75rem', flexWrap: 'wrap' }}>
{(['balanced', 'early', 'custom'] as const).map(mode => (
<button
key={mode}
className={`btn ${auth.preloadMode === mode ? 'btn-primary' : 'btn-surface'}`}
style={{ fontSize: 12, padding: '3px 12px' }}
onClick={() => auth.setPreloadMode(mode)}
>
{t(`settings.preload${mode.charAt(0).toUpperCase() + mode.slice(1)}` as any)}
</button>
))}
</div>
{auth.preloadMode === 'custom' && (
<div style={{ paddingLeft: '1rem', marginTop: '0.5rem', display: 'flex', alignItems: 'center', gap: '0.75rem', flexWrap: 'wrap' }}>
<input
type="range"
min={5} max={120} step={5}
value={auth.preloadCustomSeconds}
onChange={e => auth.setPreloadCustomSeconds(parseInt(e.target.value))}
style={{ flex: 1, minWidth: 80, maxWidth: 200 }}
/>
<span style={{ fontSize: 13, color: 'var(--text-secondary)', minWidth: 36 }}>
{t('settings.preloadCustomSeconds', { n: auth.preloadCustomSeconds })}
</span>
</div>
)}
</>
)}
<div className="divider" />
{/* Hot Cache */}
<div className="settings-toggle-row">
<div>
<div style={{ fontWeight: 500 }}>{t('settings.hotCacheTitle')}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.hotCacheDisclaimer')}</div>
</div>
<label className="toggle-switch" aria-label={t('settings.hotCacheEnabled')}>
<input
type="checkbox"
checked={auth.hotCacheEnabled}
onChange={async e => {
const enabled = e.target.checked;
if (!enabled) {
await clearHotCacheDisk(auth.hotCacheDownloadDir || null);
setHotCacheBytes(0);
auth.setHotCacheEnabled(false);
} else {
auth.setHotCacheEnabled(true);
if (auth.preloadMode !== 'off') auth.setPreloadMode('off');
invoke<number>('get_hot_cache_size', { customDir: auth.hotCacheDownloadDir || null })
.then(setHotCacheBytes)
.catch(() => setHotCacheBytes(0));
}
}}
id="hot-cache-enabled-toggle"
/>
<span className="toggle-track" />
</label>
</div>
{auth.hotCacheEnabled && (
<div style={{ marginTop: '1.25rem' }}>
<div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
<input
className="input"
type="text"
readOnly
value={auth.hotCacheDownloadDir || t('settings.hotCacheDirDefault')}
style={{ flex: 1, minWidth: 0, fontSize: 13, color: auth.hotCacheDownloadDir ? 'var(--text-primary)' : 'var(--text-muted)', cursor: 'default' }}
/>
{auth.hotCacheDownloadDir && (
<button
type="button"
className="btn btn-ghost"
onClick={() => {
auth.setHotCacheDownloadDir('');
useHotCacheStore.setState({ entries: {} });
invoke<number>('get_hot_cache_size', { customDir: null }).then(setHotCacheBytes).catch(() => setHotCacheBytes(0));
}}
data-tooltip={t('settings.hotCacheDirClear')}
style={{ color: 'var(--text-muted)', flexShrink: 0 }}
>
<X size={16} />
</button>
)}
<button type="button" className="btn btn-surface" onClick={pickHotCacheDir} style={{ flexShrink: 0 }}>
<FolderOpen size={16} /> {t('settings.hotCacheDirChange')}
</button>
</div>
{auth.hotCacheDownloadDir && (
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginTop: 8, lineHeight: 1.4 }}>
{t('settings.hotCacheDirHint')}
</div>
)}
<div style={{ borderTop: '1px solid var(--border)', margin: '16px 0' }} />
<div style={{ fontSize: 12, marginBottom: 12, display: 'flex', flexDirection: 'column', gap: 3 }}>
<div style={{ color: 'var(--text-secondary)' }}>
<span style={{ color: 'var(--text-muted)', marginRight: 4 }}>{t('settings.cacheUsedHot')}</span>
{hotCacheBytes !== null ? formatBytes(hotCacheBytes) : '…'}
</div>
<div style={{ color: 'var(--text-secondary)' }}>
<span style={{ color: 'var(--text-muted)', marginRight: 4 }}>{t('settings.hotCacheTrackCount')}</span>
{hotCacheTrackCount}
</div>
</div>
<div>
<div style={{ fontWeight: 500, marginBottom: 6 }}>{t('settings.hotCacheMaxMb')}</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
<input type="range" min={32} max={20000} step={32} value={snapHotCacheMb(auth.hotCacheMaxMb)} onChange={e => auth.setHotCacheMaxMb(parseInt(e.target.value, 10))} style={{ flex: 1, minWidth: 80, maxWidth: 200 }} id="hot-cache-max-mb-slider" />
<span style={{ fontSize: 13, color: 'var(--text-secondary)', minWidth: 60 }}>{snapHotCacheMb(auth.hotCacheMaxMb)} MB</span>
</div>
</div>
<div style={{ marginTop: '0.75rem' }}>
<div style={{ fontWeight: 500, marginBottom: 6 }}>{t('settings.hotCacheDebounce')}</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
<input type="range" min={0} max={600} step={1} value={Math.min(600, Math.max(0, auth.hotCacheDebounceSec))} onChange={e => auth.setHotCacheDebounceSec(parseInt(e.target.value, 10))} style={{ flex: 1, minWidth: 80, maxWidth: 200 }} id="hot-cache-debounce-slider" />
<span style={{ fontSize: 13, color: 'var(--text-secondary)', minWidth: 80 }}>
{Math.min(600, Math.max(0, auth.hotCacheDebounceSec)) === 0
? t('settings.hotCacheDebounceImmediate')
: t('settings.hotCacheDebounceSeconds', { n: Math.min(600, Math.max(0, auth.hotCacheDebounceSec)) })}
</span>
</div>
</div>
<div style={{ borderTop: '1px solid var(--border)', margin: '16px 0' }} />
<button
type="button"
className="btn btn-ghost"
style={{ fontSize: 13 }}
onClick={async () => {
await clearHotCacheDisk(auth.hotCacheDownloadDir || null);
const b = await invoke<number>('get_hot_cache_size', { customDir: auth.hotCacheDownloadDir || null }).catch(() => 0);
setHotCacheBytes(b);
}}
>
<Trash2 size={14} /> {t('settings.hotCacheClearBtn')}
</button>
</div>
)}
</div>
</SettingsSubSection>
{/* ZIP Export & Archiving */}
<SettingsSubSection
title={t('settings.downloadsTitle')}
@@ -3952,6 +4063,20 @@ export default function Settings() {
);
}
const TAB_LABEL_KEY: Record<Tab, string> = {
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. */}
<div className="settings-card" style={{ marginBottom: '0.75rem', padding: '0.75rem 1rem' }}>
<div style={{ display: 'flex', gap: '1rem', alignItems: 'stretch', flexWrap: 'wrap' }}>
<label
style={{
flex: 1, minWidth: 220, cursor: 'pointer',
display: 'flex', gap: '0.6rem', alignItems: 'flex-start',
padding: '0.5rem', borderRadius: 6,
background: lyricsMode === 'standard' ? 'color-mix(in srgb, var(--accent) 12%, transparent)' : 'transparent',
border: `1px solid ${lyricsMode === 'standard' ? 'var(--accent)' : 'transparent'}`,
}}
>
<input
type="radio"
name="lyrics-mode"
checked={lyricsMode === 'standard'}
onChange={() => setLyricsMode('standard')}
style={{ marginTop: 3 }}
/>
<span>
<div style={{ fontWeight: 500 }}>{t('settings.lyricsModeStandard')}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 2 }}>
{t('settings.lyricsModeStandardDesc')}
</div>
</span>
</label>
<label
style={{
flex: 1, minWidth: 220, cursor: 'pointer',
display: 'flex', gap: '0.6rem', alignItems: 'flex-start',
padding: '0.5rem', borderRadius: 6,
background: lyricsMode === 'lyricsplus' ? 'color-mix(in srgb, var(--accent) 12%, transparent)' : 'transparent',
border: `1px solid ${lyricsMode === 'lyricsplus' ? 'var(--accent)' : 'transparent'}`,
}}
>
<input
type="radio"
name="lyrics-mode"
checked={lyricsMode === 'lyricsplus'}
onChange={() => setLyricsMode('lyricsplus')}
style={{ marginTop: 3 }}
/>
<span>
<div style={{ fontWeight: 500 }}>{t('settings.lyricsModeLyricsplus')}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 2 }}>
{t('settings.lyricsModeLyricsplusDesc')}
</div>
</span>
</label>
</div>
</div>
{/* Static-only toggle — suppresses line/word tracking in both modes. */}
<div className="settings-card" style={{ marginBottom: '0.75rem' }}>
<div className="settings-toggle-row">
<div>
<div style={{ fontWeight: 500 }}>{t('settings.lyricsStaticOnly')}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.lyricsStaticOnlyDesc')}</div>
<div style={{ fontWeight: 500 }}>{t('settings.lyricsModeLyricsplus')}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.lyricsModeLyricsplusDesc')}</div>
</div>
<label className="toggle-switch" aria-label={t('settings.lyricsStaticOnly')}>
<input type="checkbox" checked={lyricsStaticOnly} onChange={e => setLyricsStaticOnly(e.target.checked)} />
<label className="toggle-switch" aria-label={t('settings.lyricsModeLyricsplus')}>
<input
type="checkbox"
checked={lyricsMode === 'lyricsplus'}
onChange={e => { if (e.target.checked) setLyricsMode('lyricsplus'); else setLyricsMode('standard'); }}
/>
<span className="toggle-track" />
</label>
</div>
</div>
<div className="settings-card" style={{ marginBottom: '0.75rem' }}>
<div className="settings-toggle-row">
<div>
<div style={{ fontWeight: 500 }}>{t('settings.lyricsModeStandard')}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.lyricsModeStandardDesc')}</div>
</div>
<label className="toggle-switch" aria-label={t('settings.lyricsModeStandard')}>
<input
type="checkbox"
checked={lyricsMode === 'standard'}
onChange={e => { if (e.target.checked) setLyricsMode('standard'); else setLyricsMode('lyricsplus'); }}
/>
<span className="toggle-track" />
</label>
</div>
</div>
{lyricsMode === 'standard' && (
<div className="settings-card" style={{ padding: '4px 0' }} ref={setContainerEl} onMouseMove={handleMouseMove}>
<div
className="settings-card"
style={{ padding: '4px 0', marginBottom: '0.75rem', marginLeft: '1rem' }}
ref={setContainerEl}
onMouseMove={handleMouseMove}
>
{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() {
})}
</div>
)}
{/* Static-only toggle — suppresses line/word tracking in both modes. */}
<div className="settings-card" style={{ marginBottom: '0.75rem' }}>
<div className="settings-toggle-row">
<div>
<div style={{ fontWeight: 500 }}>{t('settings.lyricsStaticOnly')}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.lyricsStaticOnlyDesc')}</div>
</div>
<label className="toggle-switch" aria-label={t('settings.lyricsStaticOnly')}>
<input type="checkbox" checked={lyricsStaticOnly} onChange={e => setLyricsStaticOnly(e.target.checked)} />
<span className="toggle-track" />
</label>
</div>
</div>
</section>
);
}
+60
View File
@@ -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;