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, Lock, LogOut, Pencil, Plus, Power, Server, Sparkles, Trash2, User, Wifi, WifiOff } from 'lucide-react'; import { useAuthStore } from '../../store/authStore'; import type { ServerProfile } from '../../store/authStoreTypes'; import { pingWithCredentials, scheduleInstantMixProbeForServer } from '../../api/subsonic'; import { useDragDrop } from '../../contexts/DragDropContext'; import { type ServerMagicPayload } from '../../utils/server/serverMagicString'; import { showAudiomuseNavidromeServerSetting } from '../../utils/server/subsonicServerIdentity'; import { serverListDisplayLabel } from '../../utils/server/serverDisplayName'; import { switchActiveServer } from '../../utils/server/switchActiveServer'; import { AddServerForm } from './AddServerForm'; import { ServerGripHandle } from './ServerGripHandle'; const AUDIOMUSE_NV_PLUGIN_URL = 'https://github.com/NeptuneHub/AudioMuse-AI-NV-plugin'; type ServerDropTarget = { idx: number; before: boolean } | null; export function ServersTab({ initialInvite, }: { initialInvite: ServerMagicPayload | null; }) { const { t } = useTranslation(); const navigate = useNavigate(); const auth = useAuthStore(); const psyDragState = useDragDrop(); const [connStatus, setConnStatus] = useState>({}); const [showAddForm, setShowAddForm] = useState(initialInvite != null); const [editingServerId, setEditingServerId] = useState(null); const [pastedServerInvite, setPastedServerInvite] = useState(initialInvite); const [serverContainerEl, setServerContainerEl] = useState(null); const [serverDropTarget, setServerDropTarget] = useState(null); const serverDropTargetRef = useRef(null); const serversRef = useRef(auth.servers); serversRef.current = auth.servers; const addServerInviteAnchorRef = useRef(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) { setPastedServerInvite(initialInvite); setShowAddForm(true); } }, [initialInvite]); // Clear drop target when drag ends useEffect(() => { if (!psyDragState.isDragging) { serverDropTargetRef.current = null; setServerDropTarget(null); } }, [psyDragState.isDragging]); // psy-drop listener for server reorder useEffect(() => { if (!serverContainerEl) return; const onPsyDrop = (e: Event) => { const detail = (e as CustomEvent).detail; if (!detail?.data) return; let parsed: { type?: string; index?: number }; try { parsed = JSON.parse(detail.data as string); } catch { return; } if (parsed.type !== 'server_reorder' || parsed.index == null) return; const fromIdx = parsed.index; const target = serverDropTargetRef.current; serverDropTargetRef.current = null; setServerDropTarget(null); if (!target) return; const insertBefore = target.before ? target.idx : target.idx + 1; if (insertBefore === fromIdx || insertBefore === fromIdx + 1) return; const next = [...serversRef.current]; const [moved] = next.splice(fromIdx, 1); next.splice(insertBefore > fromIdx ? insertBefore - 1 : insertBefore, 0, moved); auth.setServers(next); }; serverContainerEl.addEventListener('psy-drop', onPsyDrop); return () => serverContainerEl.removeEventListener('psy-drop', onPsyDrop); }, [serverContainerEl, auth]); const handleServerDragMove = (e: React.MouseEvent) => { if (!psyDragState.isDragging || !serverContainerEl) return; const rows = serverContainerEl.querySelectorAll('[data-server-idx]'); let target: ServerDropTarget = null; for (const row of rows) { const rect = row.getBoundingClientRect(); const idx = Number(row.dataset.serverIdx); if (e.clientY < rect.top + rect.height / 2) { target = { idx, before: true }; break; } target = { idx, before: false }; } serverDropTargetRef.current = target; setServerDropTarget(target); }; const testConnection = async (server: ServerProfile) => { setConnStatus(s => ({ ...s, [server.id]: 'testing' })); try { const ping = await pingWithCredentials(server.url, server.username, server.password); if (ping.ok) { const identity = { type: ping.type, serverVersion: ping.serverVersion, openSubsonic: ping.openSubsonic, }; auth.setSubsonicServerIdentity(server.id, identity); scheduleInstantMixProbeForServer(server.id, server.url, server.username, server.password, identity); } setConnStatus(s => ({ ...s, [server.id]: ping.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 = (server: ServerProfile) => { if (confirm(t('settings.confirmDeleteServer', { name: serverListDisplayLabel(server, auth.servers) }))) { auth.removeServer(server.id); } }; const closeAddServerForm = () => { setShowAddForm(false); setPastedServerInvite(null); }; const handleAddServer = async (data: Omit) => { setShowAddForm(false); setPastedServerInvite(null); const tempId = '_new'; setConnStatus(s => ({ ...s, [tempId]: 'testing' })); try { const ping = await pingWithCredentials(data.url, data.username, data.password); 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); setConnStatus(s => ({ ...s, [id]: 'ok' })); } else { setConnStatus(s => ({ ...s, [tempId]: 'error' })); } } catch { setConnStatus(s => ({ ...s, [tempId]: 'error' })); } }; // Edit 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. const handleEditServer = async (id: string, data: Omit) => { setEditingServerId(null); auth.updateServer(id, data); setConnStatus(s => ({ ...s, [id]: 'testing' })); try { const ping = await pingWithCredentials(data.url, data.username, data.password); 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); } setConnStatus(s => ({ ...s, [id]: ping.ok ? 'ok' : 'error' })); } catch { setConnStatus(s => ({ ...s, [id]: 'error' })); } }; const handleLogout = () => { auth.logout(); navigate('/login'); }; return ( <>

{t('settings.servers')}

{t('settings.serverCompatible')}
{auth.servers.length === 0 && !showAddForm ? (
{t('settings.noServers')}
) : (
{auth.servers.map((srv, srvIdx) => { if (editingServerId === srv.id) { return ( handleEditServer(srv.id, data)} onCancel={() => setEditingServerId(null)} /> ); } const isActive = srv.id === auth.activeServerId; const status = connStatus[srv.id]; const isBefore = psyDragState.isDragging && serverDropTarget?.idx === srvIdx && serverDropTarget.before; const isAfter = psyDragState.isDragging && serverDropTarget?.idx === srvIdx && !serverDropTarget.before; return (
{serverListDisplayLabel(srv, auth.servers)} {isActive && ( {t('settings.serverActive')} )}
{srv.url.startsWith('https://') && ( )} {srv.url.replace(/^https?:\/\//, '')}
{srv.username}
{status === 'ok' && } {status === 'error' && } {status === 'testing' &&
} {!isActive && ( )}
{showAudiomuseNavidromeServerSetting( auth.subsonicServerIdentityByServer[srv.id], auth.instantMixProbeByServer[srv.id], ) && (
{t('settings.audiomuseTitle')} {!!auth.audiomuseNavidromeByServer[srv.id] && auth.audiomuseNavidromeIssueByServer[srv.id] && ( )}
{ e.preventDefault(); void openUrl(AUDIOMUSE_NV_PLUGIN_URL); }} style={{ color: 'var(--accent)', textDecoration: 'underline' }} /> ), }} />
)}
); })}
)}
{showAddForm ? ( ) : ( )}
); }