mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 07:15:47 +00:00
2756cb7698
utils/library is the local-index query/browse/sync engine -- feature-free shared infra consumed by the album, search, genre, artist and offline features. With its last feature edges removed (queue resolver, shuffleArray, albumBrowseCatalogChunk, genreBrowsePlayback all relocated earlier this branch), the whole directory is pure infra and belongs under lib/. Whole-directory move (58 files, 76 consumers rewritten) via resolver-based rewrite: cross-cluster relatives absolutized to @/, intra-cluster siblings kept relative. utils/library is gone; lib/library imports zero @/features. Three stale doc comments in the album/search/artist barrels repointed. tsc 0, lint 0, full suite 2353/2353, production build OK.
603 lines
28 KiB
TypeScript
603 lines
28 KiB
TypeScript
import React, { useCallback, 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, Info, Lock, LogOut, Pencil, Plus, Power, Server, Sparkles, Wifi, WifiOff } from 'lucide-react';
|
||
import { useAuthStore } from '@/store/authStore';
|
||
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 '@/lib/api/library';
|
||
import { bootstrapIndexedServer } from '@/lib/library/librarySession';
|
||
import { useLibraryIndexSync } from '@/hooks/useLibraryIndexSync';
|
||
import ServerLibraryIndexControls from '@/features/settings/components/ServerLibraryIndexControls';
|
||
import type { ServerProfile } from '@/store/authStoreTypes';
|
||
import { pingWithCredentialsForProfile, scheduleInstantMixProbeForServer } from '@/lib/api/subsonic';
|
||
import {
|
||
clearServerHttpContext,
|
||
syncServerHttpContextForProfile,
|
||
} from '@/utils/server/syncServerHttpContext';
|
||
import { type ServerMagicPayload } from '@/utils/server/serverMagicString';
|
||
import { ensureConnectUrlResolved, invalidateReachableEndpointCache } from '@/utils/server/serverEndpoint';
|
||
import {
|
||
verifySameServerEndpoints,
|
||
type VerifySameServerResult,
|
||
} from '@/utils/server/serverFingerprint';
|
||
import {
|
||
indexKeyRemapForUrlChange,
|
||
runIndexKeyRemigration,
|
||
} from '@/utils/server/serverUrlRemigration';
|
||
import { useConfirmModalStore } from '@/store/confirmModalStore';
|
||
import { showToast } from '@/utils/ui/toast';
|
||
import { FEATURE_AUDIOMUSE_SIMILAR_TRACKS } from '@/serverCapabilities/catalog';
|
||
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 '@/features/settings/components/AddServerForm';
|
||
import { ServerCapabilityHeaderBadge } from '@/features/settings/components/ServerCapabilityHeaderBadge';
|
||
import { useListReorderDnd } from '@/hooks/useListReorderDnd';
|
||
import { applyListReorderById, type ListReorderDropTarget } from '@/utils/componentHelpers/listReorder';
|
||
import { ReorderGripHandle } from '@/features/settings/components/ReorderGripHandle';
|
||
import { tooltipAttrs } from '@/ui/tooltipAttrs';
|
||
|
||
const AUDIOMUSE_NV_PLUGIN_URL = 'https://github.com/NeptuneHub/AudioMuse-AI-NV-plugin';
|
||
|
||
/** 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';
|
||
}
|
||
|
||
export function ServersTab({
|
||
initialInvite,
|
||
}: {
|
||
initialInvite: ServerMagicPayload | null;
|
||
}) {
|
||
const { t } = useTranslation();
|
||
const navigate = useNavigate();
|
||
const auth = useAuthStore();
|
||
const librarySync = useLibraryIndexSync();
|
||
|
||
const [connStatus, setConnStatus] = useState<Record<string, 'idle' | 'testing' | 'ok' | 'error'>>({});
|
||
const [showAddForm, setShowAddForm] = useState<boolean>(initialInvite != null);
|
||
const [editingServerId, setEditingServerId] = useState<string | null>(null);
|
||
const [pastedServerInvite, setPastedServerInvite] = useState<ServerMagicPayload | null>(initialInvite);
|
||
const serversRef = useRef(auth.servers);
|
||
// React Compiler refs rule: ref kept in sync with the latest value for use in effects/handlers/cleanup; not render data.
|
||
// eslint-disable-next-line react-hooks/refs
|
||
serversRef.current = auth.servers;
|
||
const addServerInviteAnchorRef = useRef<HTMLDivElement>(null);
|
||
|
||
useLayoutEffect(() => {
|
||
if (!showAddForm || !pastedServerInvite) return;
|
||
addServerInviteAnchorRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||
}, [showAddForm, pastedServerInvite]);
|
||
|
||
// Pick up later invites that arrive via the parent route handler while
|
||
// ServersTab is already mounted (initial mount is handled via useState).
|
||
useEffect(() => {
|
||
if (initialInvite) {
|
||
// React Compiler set-state-in-effect rule: local state synced with store/prop inputs when the effect’s dependencies change.
|
||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||
setPastedServerInvite(initialInvite);
|
||
setShowAddForm(true);
|
||
}
|
||
}, [initialInvite]);
|
||
|
||
const applyServerReorder = useCallback((draggedId: string, target: ListReorderDropTarget) => {
|
||
const next = applyListReorderById(serversRef.current, draggedId, target);
|
||
if (next) auth.setServers(next);
|
||
}, [auth]);
|
||
|
||
const { isDragging, setContainer, onMouseMove, dropEdge } = useListReorderDnd({
|
||
type: 'server_reorder',
|
||
apply: applyServerReorder,
|
||
});
|
||
|
||
const testConnection = async (server: ServerProfile) => {
|
||
setConnStatus(s => ({ ...s, [server.id]: 'testing' }));
|
||
try {
|
||
// Dual-address: probe through the connect layer so the test reflects
|
||
// whichever endpoint the app would actually use right now (LAN at home,
|
||
// public elsewhere). probe.baseUrl also feeds the AudioMuse probe so
|
||
// that one hits the same endpoint.
|
||
const probe = await ensureConnectUrlResolved(server);
|
||
if (probe.ok) {
|
||
const identity = {
|
||
type: probe.ping.type,
|
||
serverVersion: probe.ping.serverVersion,
|
||
openSubsonic: probe.ping.openSubsonic,
|
||
};
|
||
auth.setSubsonicServerIdentity(server.id, identity);
|
||
scheduleInstantMixProbeForServer(server.id, probe.baseUrl, server.username, server.password, identity, true);
|
||
}
|
||
setConnStatus(s => ({ ...s, [server.id]: probe.ok ? 'ok' : 'error' }));
|
||
} catch {
|
||
setConnStatus(s => ({ ...s, [server.id]: 'error' }));
|
||
}
|
||
};
|
||
|
||
const switchToServer = async (server: ServerProfile) => {
|
||
setConnStatus(s => ({ ...s, [server.id]: 'testing' }));
|
||
const ok = await switchActiveServer(server);
|
||
if (ok) {
|
||
setConnStatus(s => ({ ...s, [server.id]: 'ok' }));
|
||
// Auf der Servers-Seite bleiben, damit der User seinen Switch hier
|
||
// sofort visuell bestaetigt sieht (gruener Check, aktiv-Badge).
|
||
} else {
|
||
setConnStatus(s => ({ ...s, [server.id]: 'error' }));
|
||
}
|
||
};
|
||
|
||
const deleteServer = async (server: ServerProfile) => {
|
||
if (!confirm(t('settings.confirmDeleteServer', { name: serverListDisplayLabel(server, auth.servers) }))) {
|
||
return;
|
||
}
|
||
// §5.6: when a local library index exists for this server, let the
|
||
// user keep the cached rows (offline use) or delete them. OK =
|
||
// delete the cache, Cancel = keep it.
|
||
const hadIndex = useLibraryIndexStore.getState().isIndexEnabled(server.id);
|
||
const purgeLibrary = hadIndex && confirm(t('settings.confirmDeleteServerLibrary'));
|
||
|
||
auth.removeServer(server.id);
|
||
try {
|
||
await clearServerHttpContext(server);
|
||
await librarySyncClearSession(server.id);
|
||
if (purgeLibrary) {
|
||
await libraryDeleteServerData(server.id);
|
||
}
|
||
} catch {
|
||
/* best-effort — server already removed from the store */
|
||
}
|
||
};
|
||
|
||
const closeAddServerForm = () => {
|
||
setShowAddForm(false);
|
||
setPastedServerInvite(null);
|
||
};
|
||
|
||
/**
|
||
* Surface a dual-address verify failure as a toast (mismatch /
|
||
* insufficient / unreachable). Returns true when the result is `ok` and
|
||
* the caller should proceed; false when the user must fix something
|
||
* before save.
|
||
*/
|
||
const announceVerifyResult = (result: VerifySameServerResult): boolean => {
|
||
if (result.ok) return true;
|
||
if (result.reason === 'unreachable') {
|
||
showToast(
|
||
t('settings.dualAddressUnreachable', { host: result.unreachableHost ?? '' }),
|
||
6000,
|
||
'error',
|
||
);
|
||
} else if (result.reason === 'mismatch') {
|
||
showToast(t('settings.dualAddressMismatch'), 6000, 'error');
|
||
} else {
|
||
showToast(t('settings.dualAddressInsufficient'), 6000, 'error');
|
||
}
|
||
return false;
|
||
};
|
||
|
||
const handleAddServer = async (data: Omit<ServerProfile, 'id'>) => {
|
||
setShowAddForm(false);
|
||
setPastedServerInvite(null);
|
||
const tempId = '_new';
|
||
setConnStatus(s => ({ ...s, [tempId]: 'testing' }));
|
||
try {
|
||
// Dual-address: confirm both addresses point at the same server
|
||
// before persisting anything. Single-address adds skip verify and go
|
||
// straight to the legacy ping (which is also the connect-test).
|
||
if (data.alternateUrl) {
|
||
const verify = await verifySameServerEndpoints(
|
||
{
|
||
url: data.url,
|
||
alternateUrl: data.alternateUrl,
|
||
customHeaders: data.customHeaders,
|
||
customHeadersApplyTo: data.customHeadersApplyTo,
|
||
},
|
||
data.username,
|
||
data.password,
|
||
);
|
||
if (!announceVerifyResult(verify)) {
|
||
setConnStatus(s => ({ ...s, [tempId]: 'error' }));
|
||
return;
|
||
}
|
||
}
|
||
const ping = await pingWithCredentialsForProfile(data, data.url);
|
||
if (ping.ok) {
|
||
const id = auth.addServer(data);
|
||
const identity = {
|
||
type: ping.type,
|
||
serverVersion: ping.serverVersion,
|
||
openSubsonic: ping.openSubsonic,
|
||
};
|
||
auth.setSubsonicServerIdentity(id, identity);
|
||
scheduleInstantMixProbeForServer(id, data.url, data.username, data.password, identity, true);
|
||
setConnStatus(s => ({ ...s, [id]: 'ok' }));
|
||
const added = useAuthStore.getState().servers.find(s => s.id === id);
|
||
if (added) {
|
||
void syncServerHttpContextForProfile(added);
|
||
void bootstrapIndexedServer(added);
|
||
}
|
||
} else {
|
||
setConnStatus(s => ({ ...s, [tempId]: 'error' }));
|
||
}
|
||
} catch {
|
||
setConnStatus(s => ({ ...s, [tempId]: 'error' }));
|
||
}
|
||
};
|
||
|
||
// Edit normally saves unconditionally — ping result becomes a post-save
|
||
// status indicator (analog zum existing Test-Button) rather than blocking
|
||
// the save. Lets users update a profile even when the server is currently
|
||
// unreachable.
|
||
//
|
||
// **Dual-address exception:** when the edit introduces or changes the
|
||
// second address (or changes the primary url while a second address is
|
||
// already saved), verify both addresses are the same server *before*
|
||
// persisting. A mismatch here would silently bind library / cover / queue
|
||
// data to two unrelated boxes — the spec blocks save in v1.
|
||
const handleEditServer = async (id: string, data: Omit<ServerProfile, 'id'>) => {
|
||
const previous = auth.servers.find(s => s.id === id);
|
||
|
||
// URL-change remigration — runs BEFORE everything else when the edit
|
||
// changes the derived index key. User confirms first; on failure the
|
||
// edit is aborted with a stage-specific toast. Spec §8.
|
||
const remap = previous ? indexKeyRemapForUrlChange(previous, data) : null;
|
||
if (remap) {
|
||
const confirmed = await useConfirmModalStore.getState().request({
|
||
title: t('settings.urlRemigrationTitle'),
|
||
message: t('settings.urlRemigrationMessage', {
|
||
oldKey: remap.oldKey,
|
||
newKey: remap.newKey,
|
||
}),
|
||
confirmLabel: t('settings.urlRemigrationConfirm'),
|
||
cancelLabel: t('common.cancel'),
|
||
danger: true,
|
||
});
|
||
if (!confirmed) return;
|
||
setConnStatus(s => ({ ...s, [id]: 'testing' }));
|
||
const result = await runIndexKeyRemigration(remap);
|
||
if (!result.ok) {
|
||
const failureKey =
|
||
result.failure.stage === 'inspect'
|
||
? 'settings.urlRemigrationFailureInspect'
|
||
: result.failure.stage === 'run'
|
||
? 'settings.urlRemigrationFailureRun'
|
||
: 'settings.urlRemigrationFailureCoverRename';
|
||
showToast(t(failureKey), 8000, 'error');
|
||
setConnStatus(s => ({ ...s, [id]: 'error' }));
|
||
return;
|
||
}
|
||
}
|
||
|
||
const dualAddressChanged =
|
||
data.alternateUrl != null &&
|
||
data.alternateUrl !== '' &&
|
||
(data.alternateUrl !== previous?.alternateUrl ||
|
||
data.url !== previous?.url ||
|
||
data.username !== previous?.username ||
|
||
data.password !== previous?.password);
|
||
|
||
if (dualAddressChanged) {
|
||
setConnStatus(s => ({ ...s, [id]: 'testing' }));
|
||
const verify = await verifySameServerEndpoints(
|
||
{
|
||
url: data.url,
|
||
alternateUrl: data.alternateUrl,
|
||
customHeaders: data.customHeaders,
|
||
customHeadersApplyTo: data.customHeadersApplyTo,
|
||
},
|
||
data.username,
|
||
data.password,
|
||
);
|
||
if (!announceVerifyResult(verify)) {
|
||
setConnStatus(s => ({ ...s, [id]: 'error' }));
|
||
return;
|
||
}
|
||
}
|
||
|
||
setEditingServerId(null);
|
||
auth.updateServer(id, data);
|
||
const updated = useAuthStore.getState().servers.find(s => s.id === id);
|
||
if (updated) void syncServerHttpContextForProfile(updated);
|
||
// Profile edited → any cached sticky connect URL for this id may now be
|
||
invalidateReachableEndpointCache(id);
|
||
setConnStatus(s => ({ ...s, [id]: 'testing' }));
|
||
try {
|
||
const ping = await pingWithCredentialsForProfile(data, data.url);
|
||
if (ping.ok) {
|
||
const identity = {
|
||
type: ping.type,
|
||
serverVersion: ping.serverVersion,
|
||
openSubsonic: ping.openSubsonic,
|
||
};
|
||
auth.setSubsonicServerIdentity(id, identity);
|
||
scheduleInstantMixProbeForServer(id, data.url, data.username, data.password, identity, true);
|
||
}
|
||
setConnStatus(s => ({ ...s, [id]: ping.ok ? 'ok' : 'error' }));
|
||
} catch {
|
||
setConnStatus(s => ({ ...s, [id]: 'error' }));
|
||
}
|
||
};
|
||
|
||
const handleLogout = () => {
|
||
auth.logout();
|
||
navigate('/login');
|
||
};
|
||
|
||
return (
|
||
<>
|
||
<section className="settings-section">
|
||
<div className="settings-section-header">
|
||
<Server size={18} />
|
||
<h2>{t('settings.servers')}</h2>
|
||
</div>
|
||
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginBottom: '0.75rem' }}>
|
||
{t('settings.serverCompatible')}
|
||
</div>
|
||
|
||
{auth.servers.length === 0 && !showAddForm ? (
|
||
<div className="settings-card" style={{ color: 'var(--text-muted)', fontSize: 14 }}>
|
||
{t('settings.noServers')}
|
||
</div>
|
||
) : (
|
||
<div
|
||
ref={setContainer}
|
||
onMouseMove={onMouseMove}
|
||
style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}
|
||
>
|
||
{auth.servers.map((srv) => {
|
||
if (editingServerId === srv.id) {
|
||
return (
|
||
<AddServerForm
|
||
key={srv.id}
|
||
editingServer={srv}
|
||
onSave={(data) => handleEditServer(srv.id, data)}
|
||
onCancel={() => setEditingServerId(null)}
|
||
onDelete={async () => {
|
||
await deleteServer(srv);
|
||
setEditingServerId(null);
|
||
}}
|
||
/>
|
||
);
|
||
}
|
||
const isActive = srv.id === auth.activeServerId;
|
||
const status = connStatus[srv.id];
|
||
const dropEdgeKind = isDragging ? dropEdge(srv.id) : null;
|
||
const isBefore = dropEdgeKind === 'before';
|
||
const isAfter = dropEdgeKind === 'after';
|
||
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 (
|
||
<div
|
||
key={srv.id}
|
||
data-reorder-id={srv.id}
|
||
className="settings-card"
|
||
style={{
|
||
border: isActive ? '1px solid var(--accent)' : undefined,
|
||
background: isActive ? 'color-mix(in srgb, var(--accent) 10%, var(--bg-card))' : undefined,
|
||
// Drop-target indicator via inset shadow rather than
|
||
// borderTop/borderBottom: mixing the `border` shorthand with
|
||
// border-side longhands in one inline style object makes React
|
||
// clear the unset sides, which drops the top/bottom border on
|
||
// the active card (only left/right remain).
|
||
boxShadow: isBefore
|
||
? 'inset 0 2px 0 0 var(--accent)'
|
||
: isAfter
|
||
? 'inset 0 -2px 0 0 var(--accent)'
|
||
: undefined,
|
||
}}
|
||
>
|
||
<div style={{ display: 'flex', alignItems: 'stretch', gap: '0.75rem' }}>
|
||
<ReorderGripHandle id={srv.id} type="server_reorder" label={serverListDisplayLabel(srv, auth.servers)} />
|
||
<div style={{ flex: 1, display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: '1rem', flexWrap: 'wrap' }}>
|
||
<div style={{ minWidth: 0 }}>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginBottom: '2px', flexWrap: 'wrap' }}>
|
||
<span style={{ fontWeight: 600 }}>{serverSettingsEntryTitle(srv)}</span>
|
||
<ServerCapabilityHeaderBadge
|
||
serverId={srv.id}
|
||
feature={FEATURE_AUDIOMUSE_SIMILAR_TRACKS}
|
||
/>
|
||
{resolvedAudiomuse?.activation === 'auto' && audiomuseActive && auth.audiomuseNavidromeIssueByServer[srv.id] && (
|
||
<AlertTriangle
|
||
size={16}
|
||
style={{ color: 'var(--warning, #f59e0b)', flexShrink: 0 }}
|
||
data-tooltip={t('settings.audiomuseIssueHint')}
|
||
aria-label={t('settings.audiomuseIssueHint')}
|
||
/>
|
||
)}
|
||
</div>
|
||
<div style={{ fontSize: 11, color: 'var(--text-muted)', display: 'flex', alignItems: 'center', gap: 4, overflow: 'hidden' }}>
|
||
{srv.url.startsWith('https://') && (
|
||
<Lock size={10} style={{ color: 'var(--positive)', flexShrink: 0 }} aria-hidden />
|
||
)}
|
||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||
{serverIdentityLabel(srv)}
|
||
</span>
|
||
<button
|
||
type="button"
|
||
className="btn btn-ghost settings-server-version-info-btn"
|
||
{...tooltipAttrs(versionTooltip, { click: true })}
|
||
>
|
||
<Info size={12} aria-hidden />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div style={{ display: 'flex', gap: '6px', flexShrink: 0, alignItems: 'center', marginLeft: 'auto' }}>
|
||
{status === 'ok' && <CheckCircle2 size={16} style={{ color: 'var(--positive)' }} />}
|
||
{status === 'error' && <WifiOff size={16} style={{ color: 'var(--danger)' }} />}
|
||
{status === 'testing' && <div className="spinner" style={{ width: 16, height: 16 }} />}
|
||
<button
|
||
className="btn btn-ghost"
|
||
style={{ padding: '4px 8px' }}
|
||
onClick={() => {
|
||
setShowAddForm(false);
|
||
setPastedServerInvite(null);
|
||
setEditingServerId(srv.id);
|
||
}}
|
||
data-tooltip={t('settings.editServer')}
|
||
id={`settings-edit-server-${srv.id}`}
|
||
>
|
||
<Pencil size={14} />
|
||
</button>
|
||
<button
|
||
className="btn btn-surface"
|
||
style={{ fontSize: 12, padding: '4px 10px' }}
|
||
onClick={() => testConnection(srv)}
|
||
disabled={status === 'testing'}
|
||
data-tooltip={t('settings.testBtn')}
|
||
aria-label={t('settings.testBtn')}
|
||
>
|
||
<Wifi size={13} />
|
||
<span className="server-card-btn-label">{t('settings.testBtn')}</span>
|
||
</button>
|
||
{isActive ? (
|
||
<span className="settings-server-inline-badge settings-server-inline-badge--positive settings-server-use-active-slot">
|
||
{t('settings.serverActive')}
|
||
</span>
|
||
) : (
|
||
<button
|
||
className="btn btn-primary settings-server-use-active-slot"
|
||
style={{ fontSize: 12, padding: '4px 10px' }}
|
||
onClick={() => switchToServer(srv)}
|
||
disabled={status === 'testing'}
|
||
id={`settings-use-server-${srv.id}`}
|
||
data-tooltip={t('settings.useServer')}
|
||
aria-label={t('settings.useServer')}
|
||
>
|
||
<Power size={13} />
|
||
<span className="server-card-btn-label">{t('settings.useServer')}</span>
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<ServerLibraryIndexControls
|
||
status={librarySync.statusByServer[serverIndexKeyForProfile(srv)] ?? null}
|
||
connection={librarySync.connectionByServer[serverIndexKeyForProfile(srv)] ?? 'unknown'}
|
||
progressLabel={librarySync.progressByServer[serverIndexKeyForProfile(srv)] ?? null}
|
||
busy={librarySync.busyServerId === serverIndexKeyForProfile(srv)}
|
||
actionsDisabled={librarySync.globalBusy && librarySync.busyServerId !== serverIndexKeyForProfile(srv)}
|
||
onFullSync={() => void librarySync.runServerAction(serverIndexKeyForProfile(srv), 'full')}
|
||
onDeltaSync={() => void librarySync.runServerAction(serverIndexKeyForProfile(srv), 'delta')}
|
||
onVerify={() => void librarySync.runServerAction(serverIndexKeyForProfile(srv), 'verify')}
|
||
onCancel={() => void librarySync.handleCancel()}
|
||
/>
|
||
{(() => {
|
||
if (!showLegacyAudiomuseToggle) return null;
|
||
const audiomuseManualActive = !!auth.audiomuseNavidromeByServer[srv.id];
|
||
return (
|
||
<div
|
||
className="settings-toggle-row"
|
||
data-settings-search={t('settings.audiomuseTitle')}
|
||
style={{ marginTop: '0.75rem', paddingTop: '0.75rem', borderTop: '1px solid color-mix(in srgb, var(--text-muted) 18%, transparent)' }}
|
||
>
|
||
<div style={{ display: 'flex', alignItems: 'flex-start', gap: '0.5rem', minWidth: 0 }}>
|
||
<Sparkles size={16} style={{ color: 'var(--accent)', flexShrink: 0, marginTop: 2 }} />
|
||
<div>
|
||
<div style={{ fontWeight: 500, display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
||
{t('settings.audiomuseTitle')}
|
||
{audiomuseManualActive && auth.audiomuseNavidromeIssueByServer[srv.id] && (
|
||
<AlertTriangle
|
||
size={16}
|
||
style={{ color: 'var(--warning, #f59e0b)', flexShrink: 0 }}
|
||
data-tooltip={t('settings.audiomuseIssueHint')}
|
||
aria-label={t('settings.audiomuseIssueHint')}
|
||
/>
|
||
)}
|
||
</div>
|
||
<div style={{ fontSize: 12, color: 'var(--text-muted)', lineHeight: 1.45 }}>
|
||
<Trans
|
||
i18nKey="settings.audiomuseDesc"
|
||
components={{
|
||
pluginLink: (
|
||
<a
|
||
href={AUDIOMUSE_NV_PLUGIN_URL}
|
||
onClick={e => {
|
||
e.preventDefault();
|
||
void openUrl(AUDIOMUSE_NV_PLUGIN_URL);
|
||
}}
|
||
style={{ color: 'var(--accent)', textDecoration: 'underline' }}
|
||
/>
|
||
),
|
||
}}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<label className="toggle-switch" aria-label={t('settings.audiomuseTitle')}>
|
||
<input
|
||
type="checkbox"
|
||
checked={audiomuseManualActive}
|
||
onChange={e => auth.setAudiomuseNavidromeEnabled(srv.id, e.target.checked)}
|
||
/>
|
||
<span className="toggle-track" />
|
||
</label>
|
||
</div>
|
||
);
|
||
})()}
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
|
||
<div
|
||
ref={addServerInviteAnchorRef}
|
||
id="settings-add-server-anchor"
|
||
style={{ scrollMarginTop: '12px' }}
|
||
>
|
||
{showAddForm ? (
|
||
<AddServerForm
|
||
initialInvite={pastedServerInvite}
|
||
onSave={handleAddServer}
|
||
onCancel={closeAddServerForm}
|
||
/>
|
||
) : (
|
||
<button
|
||
className="btn btn-surface"
|
||
style={{ marginTop: '0.75rem' }}
|
||
onClick={() => {
|
||
setPastedServerInvite(null);
|
||
setShowAddForm(true);
|
||
}}
|
||
id="settings-add-server-btn"
|
||
>
|
||
<Plus size={16} /> {t('settings.addServer')}
|
||
</button>
|
||
)}
|
||
</div>
|
||
</section>
|
||
|
||
<section className="settings-section">
|
||
<button className="btn btn-danger" onClick={handleLogout} id="settings-logout-btn">
|
||
<LogOut size={16} /> {t('settings.logout')}
|
||
</button>
|
||
</section>
|
||
</>
|
||
);
|
||
}
|