diff --git a/CHANGELOG.md b/CHANGELOG.md index b156057f..74f65e28 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -101,6 +101,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Changed +### Settings → Servers — compact server cards + +**By [@cucadmuh](https://github.com/cucadmuh), PR [#1054](https://github.com/Psychotoxical/psysonic/pull/1054)** + +* Two-line header: custom entry name plus `user@host`, HTTPS lock, and a clickable version info tooltip (hover or tap). +* Navidrome **0.62+**: green **AudioMuse-AI** inline badge when the plugin is detected; older Navidrome keeps the manual toggle row below the card. +* **Use** and **Active** share one rightmost slot (green badge vs primary button); card actions are edit → test → use/active. Delete lives in the edit form footer. +* **TooltipPortal**: `data-tooltip-click` for click-pinned tooltips (touch and explicit open without the 1s hover delay). + + + ### Dependencies — npm and Rust refresh **By [@cucadmuh](https://github.com/cucadmuh), PR [#997](https://github.com/Psychotoxical/psysonic/pull/997)** diff --git a/src/components/TooltipPortal.test.tsx b/src/components/TooltipPortal.test.tsx index f452a47d..5d9a8124 100644 --- a/src/components/TooltipPortal.test.tsx +++ b/src/components/TooltipPortal.test.tsx @@ -66,4 +66,65 @@ describe('TooltipPortal open delay', () => { fireEvent.mouseDown(btn); expect(screen.queryByText('Play this album')).toBeNull(); }); + + it('shows immediately on click when data-tooltip-click is set', () => { + renderWithProviders( + <> + + + , + ); + const btn = screen.getByText('info'); + + fireEvent.click(btn); + expect(screen.getByText('Server version')).toBeInTheDocument(); + }); + + it('does not hide click-mode tooltips on mousedown', () => { + renderWithProviders( + <> + + + , + ); + const btn = screen.getByText('info'); + + fireEvent.click(btn); + expect(screen.getByText('Server version')).toBeInTheDocument(); + + fireEvent.mouseDown(btn); + expect(screen.getByText('Server version')).toBeInTheDocument(); + }); + + it('toggles off when the click anchor is clicked again', () => { + renderWithProviders( + <> + + + , + ); + const btn = screen.getByText('info'); + + fireEvent.click(btn); + expect(screen.getByText('Server version')).toBeInTheDocument(); + + fireEvent.click(btn); + expect(screen.queryByText('Server version')).toBeNull(); + }); + + it('keeps click-opened tooltips visible after mouseout', () => { + renderWithProviders( + <> + + + , + ); + const btn = screen.getByText('info'); + + fireEvent.click(btn); + expect(screen.getByText('Server version')).toBeInTheDocument(); + + fireEvent.mouseOut(btn, { relatedTarget: document.body }); + expect(screen.getByText('Server version')).toBeInTheDocument(); + }); }); diff --git a/src/components/TooltipPortal.tsx b/src/components/TooltipPortal.tsx index cc47996a..20a5fd91 100644 --- a/src/components/TooltipPortal.tsx +++ b/src/components/TooltipPortal.tsx @@ -21,6 +21,8 @@ export default function TooltipPortal() { const pendingTimerRef = useRef(null); // Anchor whose tooltip is currently visible. const shownAnchorRef = useRef(null); + /** Click-opened tooltips stay until outside click / second click, not pointer leave. */ + const clickPinnedRef = useRef(false); useEffect(() => { const clearPending = () => { @@ -34,13 +36,15 @@ export default function TooltipPortal() { const hide = () => { clearPending(); shownAnchorRef.current = null; + clickPinnedRef.current = false; setTooltip(null); }; - const showAnchor = (anchor: HTMLElement) => { + const showAnchor = (anchor: HTMLElement, opts?: { clickPinned?: boolean }) => { const text = anchor.getAttribute('data-tooltip'); if (!text) return; shownAnchorRef.current = anchor; + clickPinnedRef.current = !!opts?.clickPinned; // Fresh rect: layout may have shifted during the open delay. setTooltip({ text, @@ -75,16 +79,40 @@ export default function TooltipPortal() { // Moving within the same anchor (e.g. onto a child icon) must not cancel the delay. const to = e.relatedTarget as Node | null; if (to && anchor.contains(to)) return; + if (clickPinnedRef.current) return; hide(); }; const onMove = (e: MouseEvent) => { if (!pendingAnchorRef.current && !shownAnchorRef.current) return; + if (clickPinnedRef.current) return; const anchor = (e.target as HTMLElement).closest('[data-tooltip]'); if (!anchor) hide(); }; /** Clicking a tooltip anchor (e.g. opening a dropdown) keeps the cursor inside the element, so mouseout never runs — hide immediately. */ const onDown = (e: MouseEvent) => { - if ((e.target as HTMLElement).closest('[data-tooltip]')) hide(); + const anchor = (e.target as HTMLElement).closest('[data-tooltip]') as HTMLElement | null; + if (anchor?.hasAttribute('data-tooltip-click')) return; + if (anchor) hide(); + }; + const onClick = (e: MouseEvent) => { + const anchor = (e.target as HTMLElement).closest('[data-tooltip-click]') as HTMLElement | null; + if (!anchor?.getAttribute('data-tooltip')) return; + e.preventDefault(); + clearPending(); + if (shownAnchorRef.current === anchor && clickPinnedRef.current) { + hide(); + return; + } + showAnchor(anchor, { clickPinned: true }); + }; + const onDocumentClick = (e: MouseEvent) => { + window.setTimeout(() => { + const shown = shownAnchorRef.current; + if (!shown?.hasAttribute('data-tooltip-click') || !clickPinnedRef.current) return; + const target = e.target as Node; + if (shown.contains(target)) return; + hide(); + }, 0); }; /** Wheel interactions (e.g. volume on overflow button) should suppress tooltip immediately. */ const onWheel = (e: WheelEvent) => { @@ -94,6 +122,8 @@ export default function TooltipPortal() { document.addEventListener('mouseout', onOut); document.addEventListener('mousemove', onMove, { passive: true }); document.addEventListener('mousedown', onDown, true); + document.addEventListener('click', onClick, true); + document.addEventListener('click', onDocumentClick); document.addEventListener('wheel', onWheel, { capture: true, passive: true }); return () => { clearPending(); @@ -101,6 +131,8 @@ export default function TooltipPortal() { document.removeEventListener('mouseout', onOut); document.removeEventListener('mousemove', onMove); document.removeEventListener('mousedown', onDown, true); + document.removeEventListener('click', onClick, true); + document.removeEventListener('click', onDocumentClick); document.removeEventListener('wheel', onWheel, true); }; }, []); diff --git a/src/components/settings/AddServerForm.tsx b/src/components/settings/AddServerForm.tsx index 41ee6b7b..893aed12 100644 --- a/src/components/settings/AddServerForm.tsx +++ b/src/components/settings/AddServerForm.tsx @@ -41,11 +41,13 @@ function hostnameForDnsHint(rawUrl: string): string | null { export function AddServerForm({ onSave, onCancel, + onDelete, initialInvite = null, editingServer = null, }: { onSave: (data: Omit) => void | Promise; onCancel: () => void; + onDelete?: () => void | Promise; initialInvite?: ServerMagicPayload | null; editingServer?: ServerProfile | null; }) { @@ -349,11 +351,20 @@ export function AddServerForm({ /> )} -
- - +
+ {isEdit && onDelete ? ( + + ) : ( + + )} +
+ + +
); diff --git a/src/components/settings/ServerCapabilityHeaderBadge.tsx b/src/components/settings/ServerCapabilityHeaderBadge.tsx new file mode 100644 index 00000000..5c344814 --- /dev/null +++ b/src/components/settings/ServerCapabilityHeaderBadge.tsx @@ -0,0 +1,25 @@ +import { useTranslation } from 'react-i18next'; +import { getCapabilityDefinition } from '../../serverCapabilities/catalog'; +import { isFeatureActiveForServer, resolveFeatureForServer } from '../../serverCapabilities/storeView'; + +/** Inline badge for auto-managed server capabilities (Settings → Servers header row). */ +export function ServerCapabilityHeaderBadge({ + serverId, + feature, +}: { + serverId: string; + feature: string; +}) { + const { t } = useTranslation(); + const resolved = resolveFeatureForServer(serverId, feature); + if (!resolved || resolved.activation !== 'auto') return null; + if (!isFeatureActiveForServer(serverId, feature)) return null; + + const def = getCapabilityDefinition(feature); + const labelKey = def?.badgeLabelKey ?? def?.labelKey; + if (!labelKey) return null; + + return ( + {t(labelKey)} + ); +} diff --git a/src/components/settings/ServersTab.tsx b/src/components/settings/ServersTab.tsx index bf2a4e72..e271e3d8 100644 --- a/src/components/settings/ServersTab.tsx +++ b/src/components/settings/ServersTab.tsx @@ -2,9 +2,10 @@ import React, { useEffect, useLayoutEffect, useRef, useState } from 'react'; import { Trans, useTranslation } from 'react-i18next'; import { useNavigate } from 'react-router-dom'; import { open as openUrl } from '@tauri-apps/plugin-shell'; -import { AlertTriangle, CheckCircle2, Globe, Lock, LogOut, Pencil, Plus, Power, Server, Sparkles, Trash2, User, Wifi, WifiOff } from 'lucide-react'; +import { AlertTriangle, CheckCircle2, Info, Lock, LogOut, Pencil, Plus, Power, Server, Sparkles, Wifi, WifiOff } from 'lucide-react'; import { useAuthStore } from '../../store/authStore'; -import { formatServerSoftware } from '../../utils/server/subsonicServerIdentity'; +import { formatServerSoftware, isNavidromeAudiomuseSoftwareEligible, type InstantMixProbeResult, type SubsonicServerIdentity } from '../../utils/server/subsonicServerIdentity'; +import { buildCapabilityContext } from '../../serverCapabilities/context'; import { useLibraryIndexStore } from '../../store/libraryIndexStore'; import { libraryDeleteServerData, librarySyncClearSession } from '../../api/library'; import { bootstrapIndexedServer } from '../../utils/library/librarySession'; @@ -25,36 +26,37 @@ import { } from '../../utils/server/serverUrlRemigration'; import { useConfirmModalStore } from '../../store/confirmModalStore'; import { showToast } from '../../utils/ui/toast'; -import PerfProbeStatusBadge, { type PerfProbeBadgeTone } from '../sidebar/perfProbe/PerfProbeStatusBadge'; import { FEATURE_AUDIOMUSE_SIMILAR_TRACKS } from '../../serverCapabilities/catalog'; -import { resolveFeatureForServer } from '../../serverCapabilities/storeView'; -import type { CapabilityStatus, ResolvedCapability } from '../../serverCapabilities/types'; -import { serverListDisplayLabel } from '../../utils/server/serverDisplayName'; +import { isFeatureActiveForServer, resolveFeatureForServer } from '../../serverCapabilities/storeView'; +import type { ResolvedCapability } from '../../serverCapabilities/types'; +import { serverIdentityLabel, serverListDisplayLabel, serverSettingsEntryTitle } from '../../utils/server/serverDisplayName'; import { serverIndexKeyForProfile } from '../../utils/server/serverIndexKey'; import { switchActiveServer } from '../../utils/server/switchActiveServer'; import { AddServerForm } from './AddServerForm'; +import { ServerCapabilityHeaderBadge } from './ServerCapabilityHeaderBadge'; import { ServerGripHandle } from './ServerGripHandle'; +import { tooltipAttrs } from '../tooltipAttrs'; const AUDIOMUSE_NV_PLUGIN_URL = 'https://github.com/NeptuneHub/AudioMuse-AI-NV-plugin'; -function audiomuseProbeBadge( - status: CapabilityStatus, - t: (key: string) => string, -): { tone: PerfProbeBadgeTone; label: string } { - switch (status) { - case 'present': return { tone: 'ok', label: t('settings.audiomuseStatusActive') }; - case 'absent': return { tone: 'muted', label: t('settings.audiomuseStatusNotDetected') }; - case 'error': return { tone: 'error', label: t('settings.audiomuseStatusProbeFailed') }; - default: return { tone: 'warn', label: t('settings.audiomuseStatusChecking') }; - } -} - -/** Row visibility: hide only when a manual (legacy) strategy proves the feature absent. */ +/** Row visibility: same as main — hide only when manual strategy proves the feature absent. */ function showAudiomuseRow(resolved: ResolvedCapability | null): boolean { if (!resolved || resolved.strategyId === null || resolved.status === 'ineligible') return false; return !(resolved.activation === 'manual' && resolved.status === 'absent'); } +/** Legacy Navidrome (< 0.62): manual toggle row below the card (not the auto header badge). */ +function showLegacyAudiomuseToggleRow( + identity: SubsonicServerIdentity | undefined, + instantMixProbe: InstantMixProbeResult | undefined, + resolved: ResolvedCapability | null, +): boolean { + const ctx = buildCapabilityContext(identity); + if (ctx.isNavidrome && ctx.semverGte([0, 62, 0])) return false; + if (showAudiomuseRow(resolved)) return true; + return isNavidromeAudiomuseSoftwareEligible(identity) && instantMixProbe !== 'empty'; +} + type ServerDropTarget = { idx: number; before: boolean } | null; export function ServersTab({ @@ -388,6 +390,10 @@ export function ServersTab({ editingServer={srv} onSave={(data) => handleEditServer(srv.id, data)} onCancel={() => setEditingServerId(null)} + onDelete={async () => { + await deleteServer(srv); + setEditingServerId(null); + }} /> ); } @@ -396,6 +402,15 @@ export function ServersTab({ const isBefore = psyDragState.isDragging && serverDropTarget?.idx === srvIdx && serverDropTarget.before; const isAfter = psyDragState.isDragging && serverDropTarget?.idx === srvIdx && !serverDropTarget.before; const serverSoftware = formatServerSoftware(auth.subsonicServerIdentityByServer[srv.id]); + const serverIdentity = auth.subsonicServerIdentityByServer[srv.id]; + const resolvedAudiomuse = resolveFeatureForServer(srv.id, FEATURE_AUDIOMUSE_SIMILAR_TRACKS); + const versionTooltip = serverSoftware ?? t('settings.serverVersionUnknown'); + const audiomuseActive = isFeatureActiveForServer(srv.id, FEATURE_AUDIOMUSE_SIMILAR_TRACKS); + const showLegacyAudiomuseToggle = showLegacyAudiomuseToggleRow( + serverIdentity, + auth.instantMixProbeByServer[srv.id], + resolvedAudiomuse, + ); return (
-
- {serverListDisplayLabel(srv, auth.servers)} - {isActive && ( - - {t('settings.serverActive')} - +
+ {serverSettingsEntryTitle(srv)} + + {resolvedAudiomuse?.activation === 'auto' && audiomuseActive && auth.audiomuseNavidromeIssueByServer[srv.id] && ( + )}
- {serverSoftware && ( -
- - {serverSoftware} -
- )}
- {srv.url.startsWith('https://') ? ( - - ) : ( - + {srv.url.startsWith('https://') && ( + )} - {srv.url.replace(/^https?:\/\//, '')} + {serverIdentityLabel(srv)} -
-
- - {srv.username} +
{status === 'ok' && } {status === 'error' && } {status === 'testing' &&
} - - {!isActive && ( - - )} + {isActive ? ( + + {t('settings.serverActive')} + + ) : ( + + )}
@@ -515,11 +527,8 @@ export function ServersTab({ onCancel={() => void librarySync.handleCancel()} /> {(() => { - const resolved = resolveFeatureForServer(srv.id, FEATURE_AUDIOMUSE_SIMILAR_TRACKS); - if (!showAudiomuseRow(resolved) || !resolved) return null; - const autoManaged = resolved.activation === 'auto'; - const probeBadge = audiomuseProbeBadge(resolved.status, t); - const audiomuseActive = !!auth.audiomuseNavidromeByServer[srv.id]; + if (!showLegacyAudiomuseToggle) return null; + const audiomuseManualActive = !!auth.audiomuseNavidromeByServer[srv.id]; return (
{t('settings.audiomuseTitle')} - {audiomuseActive && auth.audiomuseNavidromeIssueByServer[srv.id] && ( + {audiomuseManualActive && auth.audiomuseNavidromeIssueByServer[srv.id] && ( )}
- {!autoManaged && ( -
- { - e.preventDefault(); - void openUrl(AUDIOMUSE_NV_PLUGIN_URL); - }} - style={{ color: 'var(--accent)', textDecoration: 'underline' }} - /> - ), - }} - /> -
- )} +
+ { + e.preventDefault(); + void openUrl(AUDIOMUSE_NV_PLUGIN_URL); + }} + style={{ color: 'var(--accent)', textDecoration: 'underline' }} + /> + ), + }} + /> +
- {autoManaged ? ( - {probeBadge.label} - ) : ( - - )} +
); })()} diff --git a/src/components/tooltipAttrs.test.ts b/src/components/tooltipAttrs.test.ts index b3768a86..6bdc9b91 100644 --- a/src/components/tooltipAttrs.test.ts +++ b/src/components/tooltipAttrs.test.ts @@ -18,4 +18,9 @@ describe('tooltipAttrs', () => { expect('data-tooltip-wrap' in tooltipAttrs('x', { wrap: true })).toBe(true); expect('data-tooltip-wrap' in tooltipAttrs('x')).toBe(false); }); + + it('adds the click marker only when requested', () => { + expect('data-tooltip-click' in tooltipAttrs('x', { click: true })).toBe(true); + expect('data-tooltip-click' in tooltipAttrs('x')).toBe(false); + }); }); diff --git a/src/components/tooltipAttrs.ts b/src/components/tooltipAttrs.ts index 28e704b2..2c2ad900 100644 --- a/src/components/tooltipAttrs.ts +++ b/src/components/tooltipAttrs.ts @@ -10,15 +10,17 @@ * `pos: 'bottom'` is a viewport-edge escape hatch only (forces the tooltip * below the anchor instead of the default auto-flip) — do not use it on * ordinary toolbar buttons. `wrap` enables multi-line wrapping. + * `click` shows the tooltip immediately on click (for touch / explicit open). */ export function tooltipAttrs( text: string, - opts?: { pos?: 'bottom'; wrap?: boolean }, + opts?: { pos?: 'bottom'; wrap?: boolean; click?: boolean }, ): Record { return { 'data-tooltip': text, 'aria-label': text, ...(opts?.pos ? { 'data-tooltip-pos': opts.pos } : {}), ...(opts?.wrap ? { 'data-tooltip-wrap': '' } : {}), + ...(opts?.click ? { 'data-tooltip-click': '' } : {}), }; } diff --git a/src/config/settingsCredits.ts b/src/config/settingsCredits.ts index 4a3b1446..3deec39e 100644 --- a/src/config/settingsCredits.ts +++ b/src/config/settingsCredits.ts @@ -158,6 +158,7 @@ const CONTRIBUTOR_ENTRIES = [ 'Artist page: Top Tracks play when album list is empty on the page (PR #1031)', 'PsyLab Connections tab with server capability readout, Navidrome admin-role probe, and tab-bar layout fix; server-capability framework with AudioMuse detection via OpenSubsonic sonicSimilarity and sonic Instant Mix routing on Navidrome ≥0.62 (PR #1033)', 'Library DB: named slow-write op labels for macOS playback-stall diagnosis (PR #1043)', + 'Settings → Servers: compact two-line cards, capability header badges, unified use/active action, delete in edit form, click-pinned version tooltip (PR #1054)', ], }, { diff --git a/src/locales/de/settings.ts b/src/locales/de/settings.ts index 55c3fd40..3a7814d2 100644 --- a/src/locales/de/settings.ts +++ b/src/locales/de/settings.ts @@ -47,6 +47,7 @@ export const settings = { deleteServer: 'Löschen', noServers: 'Keine Server gespeichert.', serverActive: 'Aktiv', + serverVersionUnknown: 'Serverversion unbekannt', confirmDeleteServer: 'Server „{{name}}" löschen?', confirmDeleteServerLibrary: 'Auch den lokalen Bibliotheksindex dieses Servers löschen? Auf Abbrechen klicken, um die zwischengespeicherte Kopie für die Offline-Nutzung zu behalten.', serverConnecting: 'Verbinde…', @@ -106,6 +107,7 @@ export const settings = { userMgmtMagicStringModalDesc: 'Subsonic-Passwort für „{{username}}" eingeben — es wird in den kopierten Magic-String übernommen.', userMgmtMagicStringModalConfirm: 'String kopieren', audiomuseTitle: 'AudioMuse-AI (Navidrome)', + audiomuseBadge: 'AudioMuse-AI', audiomuseStatusActive: 'Aktiv', audiomuseStatusChecking: 'Wird geprüft…', audiomuseStatusNotDetected: 'Nicht erkannt', diff --git a/src/locales/en/settings.ts b/src/locales/en/settings.ts index 4d7c6906..a8fdcbb0 100644 --- a/src/locales/en/settings.ts +++ b/src/locales/en/settings.ts @@ -47,6 +47,7 @@ export const settings = { deleteServer: 'Delete', noServers: 'No servers saved.', serverActive: 'Active', + serverVersionUnknown: 'Server version unknown', confirmDeleteServer: 'Delete server "{{name}}"?', confirmDeleteServerLibrary: 'Also delete this server\'s local library index? Click Cancel to keep the cached copy for offline use.', serverConnecting: 'Connecting…', @@ -106,6 +107,7 @@ export const settings = { userMgmtMagicStringModalDesc: 'Enter the Subsonic password for "{{username}}". It is included in the copied magic string.', userMgmtMagicStringModalConfirm: 'Copy string', audiomuseTitle: 'AudioMuse-AI (Navidrome)', + audiomuseBadge: 'AudioMuse-AI', audiomuseDesc: 'Turn on if this server has the AudioMuse-AI Navidrome plugin configured. Enables Instant Mix from tracks and uses server-side similar artists instead of Last.fm on artist pages.', audiomuseStatusActive: 'Active', diff --git a/src/locales/es/settings.ts b/src/locales/es/settings.ts index 78da2ad8..3fcb6049 100644 --- a/src/locales/es/settings.ts +++ b/src/locales/es/settings.ts @@ -47,6 +47,7 @@ export const settings = { deleteServer: 'Eliminar', noServers: 'No hay servidores guardados.', serverActive: 'Activo', + serverVersionUnknown: 'Versión del servidor desconocida', confirmDeleteServer: '¿Eliminar servidor "{{name}}"?', serverConnecting: 'Conectando…', serverConnected: '¡Conectado!', @@ -105,6 +106,7 @@ export const settings = { userMgmtMagicStringModalDesc: 'Introduce la contraseña Subsonic de «{{username}}». Se incluirá en la cadena mágica copiada.', userMgmtMagicStringModalConfirm: 'Copiar cadena', audiomuseTitle: 'AudioMuse-AI (Navidrome)', + audiomuseBadge: 'AudioMuse-AI', audiomuseStatusActive: 'Activo', audiomuseStatusChecking: 'Comprobando…', audiomuseStatusNotDetected: 'No detectado', diff --git a/src/locales/fr/settings.ts b/src/locales/fr/settings.ts index a80a6769..e6a09b7b 100644 --- a/src/locales/fr/settings.ts +++ b/src/locales/fr/settings.ts @@ -47,6 +47,7 @@ export const settings = { deleteServer: 'Supprimer', noServers: 'Aucun serveur enregistré.', serverActive: 'Actif', + serverVersionUnknown: 'Version du serveur inconnue', confirmDeleteServer: 'Supprimer le serveur « {{name}} » ?', serverConnecting: 'Connexion…', serverConnected: 'Connecté !', @@ -105,6 +106,7 @@ export const settings = { userMgmtMagicStringModalDesc: 'Saisissez le mot de passe Subsonic de « {{username}} ». Il est inclus dans la chaîne magique copiée.', userMgmtMagicStringModalConfirm: 'Copier la chaîne', audiomuseTitle: 'AudioMuse-AI (Navidrome)', + audiomuseBadge: 'AudioMuse-AI', audiomuseStatusActive: 'Actif', audiomuseStatusChecking: 'Vérification…', audiomuseStatusNotDetected: 'Non détecté', diff --git a/src/locales/nb/settings.ts b/src/locales/nb/settings.ts index fb6cc2bc..2bd1c83f 100644 --- a/src/locales/nb/settings.ts +++ b/src/locales/nb/settings.ts @@ -47,6 +47,7 @@ export const settings = { deleteServer: 'Slett', noServers: 'Ingen tjenere lagret.', serverActive: 'Aktiv', + serverVersionUnknown: 'Serverversjon ukjent', confirmDeleteServer: 'Slett tjener "{{name}}"?', serverConnecting: 'Kobler til…', serverConnected: 'Tilkoblet!', @@ -105,6 +106,7 @@ export const settings = { userMgmtMagicStringModalDesc: 'Skriv inn Subsonic-passordet for «{{username}}». Det tas med i den kopierte magic string-en.', userMgmtMagicStringModalConfirm: 'Kopier streng', audiomuseTitle: 'AudioMuse-AI (Navidrome)', + audiomuseBadge: 'AudioMuse-AI', audiomuseStatusActive: 'Aktiv', audiomuseStatusChecking: 'Sjekker…', audiomuseStatusNotDetected: 'Ikke oppdaget', diff --git a/src/locales/nl/settings.ts b/src/locales/nl/settings.ts index f5e902fb..36481587 100644 --- a/src/locales/nl/settings.ts +++ b/src/locales/nl/settings.ts @@ -47,6 +47,7 @@ export const settings = { deleteServer: 'Verwijderen', noServers: 'Geen servers opgeslagen.', serverActive: 'Actief', + serverVersionUnknown: 'Serverversie onbekend', confirmDeleteServer: 'Server "{{name}}" verwijderen?', serverConnecting: 'Verbinden…', serverConnected: 'Verbonden!', @@ -105,6 +106,7 @@ export const settings = { userMgmtMagicStringModalDesc: 'Voer het Subsonic-wachtwoord voor „{{username}}" in. Het wordt opgenomen in de gekopieerde magic string.', userMgmtMagicStringModalConfirm: 'String kopiëren', audiomuseTitle: 'AudioMuse-AI (Navidrome)', + audiomuseBadge: 'AudioMuse-AI', audiomuseStatusActive: 'Actief', audiomuseStatusChecking: 'Controleren…', audiomuseStatusNotDetected: 'Niet gedetecteerd', diff --git a/src/locales/ro/settings.ts b/src/locales/ro/settings.ts index 5af19467..57c2ebce 100644 --- a/src/locales/ro/settings.ts +++ b/src/locales/ro/settings.ts @@ -47,6 +47,7 @@ export const settings = { deleteServer: 'Șterge', noServers: 'Niciun server salvat.', serverActive: 'Activ', + serverVersionUnknown: 'Versiunea serverului necunoscută', confirmDeleteServer: 'Șterge server "{{name}}"?', serverConnecting: 'Se conectează…', serverConnected: 'Conectat!', @@ -105,6 +106,7 @@ export const settings = { userMgmtMagicStringModalDesc: 'Introdu parola Subsonic pentru "{{username}}". Este inclusă în string-ul magic copiat.', userMgmtMagicStringModalConfirm: 'Copiază string-ul', audiomuseTitle: 'AudioMuse-AI (Navidrome)', + audiomuseBadge: 'AudioMuse-AI', audiomuseStatusActive: 'Activ', audiomuseStatusChecking: 'Se verifică…', audiomuseStatusNotDetected: 'Nedetectat', diff --git a/src/locales/ru/settings.ts b/src/locales/ru/settings.ts index 57b558c7..4762911f 100644 --- a/src/locales/ru/settings.ts +++ b/src/locales/ru/settings.ts @@ -47,6 +47,7 @@ export const settings = { deleteServer: 'Удалить', noServers: 'Нет сохранённых серверов.', serverActive: 'Активен', + serverVersionUnknown: 'Версия сервера неизвестна', confirmDeleteServer: 'Удалить сервер «{{name}}»?', serverConnecting: 'Подключение…', serverConnected: 'Подключено!', @@ -105,6 +106,7 @@ export const settings = { userMgmtMagicStringModalDesc: 'Введите пароль Subsonic для «{{username}}» — он попадёт в копируемую magic string.', userMgmtMagicStringModalConfirm: 'Скопировать строку', audiomuseTitle: 'AudioMuse-AI (Navidrome)', + audiomuseBadge: 'AudioMuse-AI', audiomuseStatusActive: 'Активно', audiomuseStatusChecking: 'Проверка…', audiomuseStatusNotDetected: 'Не обнаружено', diff --git a/src/locales/zh/settings.ts b/src/locales/zh/settings.ts index ea9bab71..5b0150d0 100644 --- a/src/locales/zh/settings.ts +++ b/src/locales/zh/settings.ts @@ -47,6 +47,7 @@ export const settings = { deleteServer: '删除', noServers: '未保存任何服务器。', serverActive: '当前使用', + serverVersionUnknown: '服务器版本未知', confirmDeleteServer: '删除服务器 "{{name}}"?', serverConnecting: '正在连接…', serverConnected: '已连接!', @@ -105,6 +106,7 @@ export const settings = { userMgmtMagicStringModalDesc: '请输入用户「{{username}}」的 Subsonic 密码,它会包含在复制的魔法字符串中。', userMgmtMagicStringModalConfirm: '复制字符串', audiomuseTitle: 'AudioMuse-AI(Navidrome)', + audiomuseBadge: 'AudioMuse-AI', audiomuseStatusActive: '已启用', audiomuseStatusChecking: '检测中…', audiomuseStatusNotDetected: '未检测到', diff --git a/src/serverCapabilities/catalog.ts b/src/serverCapabilities/catalog.ts index 5404484f..808a6be8 100644 --- a/src/serverCapabilities/catalog.ts +++ b/src/serverCapabilities/catalog.ts @@ -26,6 +26,7 @@ export const SERVER_CAPABILITY_CATALOG: CapabilityDefinition[] = [ { feature: FEATURE_AUDIOMUSE_SIMILAR_TRACKS, labelKey: 'settings.audiomuseTitle', + badgeLabelKey: 'settings.audiomuseBadge', strategies: [ { // Navidrome ≥ 0.62: AudioMuse plugin advertised via OpenSubsonic. diff --git a/src/serverCapabilities/types.ts b/src/serverCapabilities/types.ts index 3dcc3b78..8e3405fa 100644 --- a/src/serverCapabilities/types.ts +++ b/src/serverCapabilities/types.ts @@ -64,6 +64,8 @@ export interface CapabilityStrategy { export interface CapabilityDefinition { feature: string; labelKey: string; + /** Shorter label for inline header badges in Settings → Servers. */ + badgeLabelKey?: string; strategies: CapabilityStrategy[]; } diff --git a/src/styles/components/settings.css b/src/styles/components/settings.css index eea37a4e..89ded270 100644 --- a/src/styles/components/settings.css +++ b/src/styles/components/settings.css @@ -190,6 +190,55 @@ margin-right: var(--space-2); } +.settings-server-inline-badge { + display: inline-flex; + align-items: center; + font-size: 11px; + font-weight: 600; + padding: 1px 6px; + border-radius: var(--radius-sm); + background: var(--accent); + color: var(--text-on-accent); + flex-shrink: 0; +} + +.settings-server-inline-badge--positive { + background: var(--positive); + color: var(--text-on-accent, #111); +} + +.settings-server-version-info-btn { + padding: 2px; + min-width: 20px; + min-height: 20px; + height: auto; + line-height: 1; + color: var(--text-muted); + flex-shrink: 0; + cursor: help; + display: inline-flex; + align-items: center; + justify-content: center; +} + +.settings-server-use-active-slot { + font-size: 12px; + padding: 4px 10px; + min-height: 28px; + box-sizing: border-box; +} + +button.settings-server-use-active-slot { + display: inline-flex; + align-items: center; + gap: 6px; +} + +span.settings-server-use-active-slot { + display: inline-flex; + align-items: center; +} + /* ── In-Page-Suche fuer Settings ─────────────────────────────────────────── */ .settings-header { display: flex; diff --git a/src/utils/server/serverDisplayName.test.ts b/src/utils/server/serverDisplayName.test.ts index 6ead9464..f136fb71 100644 --- a/src/utils/server/serverDisplayName.test.ts +++ b/src/utils/server/serverDisplayName.test.ts @@ -1,6 +1,6 @@ import type { ServerProfile } from '../../store/authStoreTypes'; import { describe, expect, it } from 'vitest'; -import { serverListDisplayLabel, shortHostFromServerUrl } from './serverDisplayName'; +import { serverIdentityLabel, serverListDisplayLabel, serverSettingsEntryTitle, shortHostFromServerUrl } from './serverDisplayName'; function srv(p: Partial & Pick): ServerProfile { return { @@ -21,6 +21,28 @@ describe('shortHostFromServerUrl', () => { }); }); +describe('serverIdentityLabel', () => { + it('formats username@host', () => { + const a = srv({ id: '1', url: 'https://music.shstk.ru', username: 'cucadmuh', password: 'p', name: 'Home' }); + expect(serverIdentityLabel(a)).toBe('cucadmuh@music.shstk.ru'); + }); + it('keeps port in host', () => { + const a = srv({ id: '1', url: 'http://127.0.0.1:4533', username: 'admin', password: 'p' }); + expect(serverIdentityLabel(a)).toBe('admin@127.0.0.1:4533'); + }); +}); + +describe('serverSettingsEntryTitle', () => { + it('prefers custom entry name', () => { + const a = srv({ id: '1', url: 'https://music.shstk.ru', username: 'u', password: 'p', name: 'Home NAS' }); + expect(serverSettingsEntryTitle(a)).toBe('Home NAS'); + }); + it('falls back to short host when name empty', () => { + const a = srv({ id: '1', url: 'https://music.shstk.ru', username: 'u', password: 'p', name: '' }); + expect(serverSettingsEntryTitle(a)).toBe('music.shstk.ru'); + }); +}); + describe('serverListDisplayLabel', () => { it('uses short host when name empty', () => { const a = srv({ id: '1', url: 'https://a.com', username: 'u', password: 'p', name: '' }); diff --git a/src/utils/server/serverDisplayName.ts b/src/utils/server/serverDisplayName.ts index ee4d5f14..2efb3a59 100644 --- a/src/utils/server/serverDisplayName.ts +++ b/src/utils/server/serverDisplayName.ts @@ -15,6 +15,20 @@ export function shortHostFromServerUrl(urlRaw?: string | null): string { } } +/** Settings server card primary line: `username@host`. */ +export function serverIdentityLabel(server: ServerProfile): string { + const shortHost = shortHostFromServerUrl(server.url); + const host = shortHost || (typeof server.url === 'string' ? server.url.trim() : ''); + return `${server.username}@${host}`; +} + +/** Settings server card title: custom entry name, or short host when unset. */ +export function serverSettingsEntryTitle(server: ServerProfile): string { + const nameTrim = (server.name || '').trim(); + if (nameTrim) return nameTrim; + return shortHostFromServerUrl(server.url) || (typeof server.url === 'string' ? server.url.trim() : ''); +} + /** * Label for server lists and chrome: if several servers share the same effective name, * show `username@host` so entries stay distinguishable.