feat(servers): scan actions + edit existing server profiles (#780)

* feat(server-scan): plumbing for triggering library scans

Adds `startScan` / `getScanStatus` against the Subsonic API
(`fullScan=true` is Navidrome's extension), a small per-server scan
store, and a global polling hook (2 s cadence) that emits a toast when
each scan finishes. Scans can run on any configured server, including
inactive ones, by reusing `apiForServer`.

UI surfaces follow in the next commit.

* feat(server-scan): expose Quick / Full Scan in switcher + settings cards

Adds a `ServerScanActions` component with two variants (compact for the
server-switcher dropdown, card for the Settings server cards) backed by
the scan store from the previous commit. Full Scan requires a second
click within 3 s to confirm, matching the playlist-delete pattern.
Status slot shows a spinner with running track count while scanning, a
green check when finished, and a red icon on error.

The switcher row is converted from a single button to a flex container
so per-server scan controls don't hijack the server-switch click.
i18n added across all 9 locales.

* fix(server-scan): reorder switcher row to check / name / scan actions

Moves the check / spinner slot from the right edge to the left so the
spinner pop-in on server switch doesn't sit next to the scan icons.
Removes the layout shift that briefly hovered the Quick scan button
when the row re-rendered.

* feat(servers): edit existing server profiles in Settings → Servers

* Pencil-button on each server card opens an inline edit form that
  replaces the card (prefilled name / URL / username / password).
* `AddServerForm` reused with an `editingServer` prop — title flips to
  "Edit Server", submit label to "Save", magic-string field hidden (the
  edit scope is manual fields; magic-string remains an add-time invite
  shortcut).
* Edit saves unconditionally — ping runs post-save as a status indicator
  (analog to the existing Test button) instead of gating the save. Lets
  users update a profile when the server is currently unreachable.
* Translations across all 9 locales (`editServer`, `editServerTitle`).

* fix(servers): submit Add/Edit Server form on Enter

Wrapped the form body in a real <form>, made the submit button
type="submit", marked Cancel as type="button" so Enter no longer
cancels. Add-Mode now also responds to Enter — same flow, consistent
across both modes.

* fix(servers): collapse card action buttons to icon-only on narrow screens

* Quick-Scan / Full-Scan / Test buttons in each server card hide their
  text label below 1100px viewport via the .server-card-btn-label class
  and a single media query in connection-indicator.css.
* Labels remain accessible via data-tooltip and aria-label so screen
  readers + hover both keep working in the collapsed state.
* No content reflow above the breakpoint — pure additive CSS.

* fix(servers): include Use button in icon-only narrow-screen collapse

The Use ("Verwenden") button on inactive server cards lacked the
.server-card-btn-label wrapper, so its text stayed visible at narrow
viewports and pushed Edit/Delete off-screen. Added a Power icon and
wrapped the label so it collapses alongside the other action buttons.

* docs(changelog,credits): #780 server scan + edit
This commit is contained in:
Frank Stellmacher
2026-05-18 16:32:40 +02:00
committed by GitHub
parent 562218f447
commit bca45d5a80
20 changed files with 592 additions and 37 deletions
+9
View File
@@ -25,6 +25,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Servers — scan triggers + edit existing profiles
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#780](https://github.com/Psychotoxical/psysonic/pull/780)**
* Quick/Full Scan buttons on each server card and in the switcher dropdown (Full needs a two-step confirm).
* Pencil button opens an inline edit form prefilled with the existing profile. Card actions collapse to icon-only on narrow viewports so Edit/Delete stay reachable.
## [1.46.0] - 2026-05-18 ## [1.46.0] - 2026-05-18
> **🙏 Special thanks to [@zz5zz](https://github.com/zz5zz)** for his tireless quirk-spotting and bug reports on the [Psysonic Discord](https://discord.gg/AMnDRErm4u) — several of the polish fixes in this release landed directly off the back of his messages. > **🙏 Special thanks to [@zz5zz](https://github.com/zz5zz)** for his tireless quirk-spotting and bug reports on the [Psysonic Discord](https://discord.gg/AMnDRErm4u) — several of the polish fixes in this release landed directly off the back of his messages.
+23
View File
@@ -0,0 +1,23 @@
import { apiForServer } from './subsonicClient';
export interface ScanStatus {
scanning: boolean;
count: number;
folderCount?: number;
lastScan?: string;
}
export async function startScan(serverId: string, fullScan: boolean): Promise<void> {
await apiForServer(serverId, 'startScan.view', fullScan ? { fullScan: 'true' } : {}, 10000);
}
export async function getScanStatus(serverId: string): Promise<ScanStatus> {
const data = await apiForServer<{ scanStatus?: ScanStatus }>(serverId, 'getScanStatus.view', {}, 8000);
const s = data.scanStatus;
return {
scanning: !!s?.scanning,
count: typeof s?.count === 'number' ? s.count : 0,
folderCount: typeof s?.folderCount === 'number' ? s.folderCount : undefined,
lastScan: typeof s?.lastScan === 'string' ? s.lastScan : undefined,
};
}
+3
View File
@@ -14,6 +14,7 @@ import { useGlobalShortcutsStore } from '../store/globalShortcutsStore';
import { initHotCachePrefetch } from '../hotCachePrefetch'; import { initHotCachePrefetch } from '../hotCachePrefetch';
import { initMiniPlayerBridgeOnMain } from '../utils/miniPlayerBridge'; import { initMiniPlayerBridgeOnMain } from '../utils/miniPlayerBridge';
import { runAdvancedModeMigration } from '../utils/migrations/advancedModeMigration'; import { runAdvancedModeMigration } from '../utils/migrations/advancedModeMigration';
import { useScanPolling } from '../hooks/useScanPolling';
import { IS_WINDOWS } from '../utils/platform'; import { IS_WINDOWS } from '../utils/platform';
import TauriEventBridge from './TauriEventBridge'; import TauriEventBridge from './TauriEventBridge';
import AppShell from './AppShell'; import AppShell from './AppShell';
@@ -34,6 +35,8 @@ export default function MainApp() {
// Advanced Mode toggle. Idempotent — flagged in localStorage. // Advanced Mode toggle. Idempotent — flagged in localStorage.
useEffect(() => { runAdvancedModeMigration(); }, []); useEffect(() => { runAdvancedModeMigration(); }, []);
useScanPolling();
// Push playback state to mini window + handle control events. // Push playback state to mini window + handle control events.
useEffect(() => { useEffect(() => {
return initMiniPlayerBridgeOnMain(); return initMiniPlayerBridgeOnMain();
+25 -13
View File
@@ -9,6 +9,7 @@ import { useAuthStore } from '../store/authStore';
import { switchActiveServer } from '../utils/server/switchActiveServer'; import { switchActiveServer } from '../utils/server/switchActiveServer';
import { showToast } from '../utils/ui/toast'; import { showToast } from '../utils/ui/toast';
import { serverListDisplayLabel } from '../utils/server/serverDisplayName'; import { serverListDisplayLabel } from '../utils/server/serverDisplayName';
import ServerScanActions from './settings/ServerScanActions';
interface Props { interface Props {
status: ConnectionStatus; status: ConnectionStatus;
@@ -163,23 +164,34 @@ export default function ConnectionIndicator({ status, isLan, serverName }: Props
const busy = switchingId !== null; const busy = switchingId !== null;
const labelText = serverListDisplayLabel(srv, servers); const labelText = serverListDisplayLabel(srv, servers);
return ( return (
<button <div
key={srv.id} key={srv.id}
type="button"
role="menuitem" role="menuitem"
className={`nav-library-dropdown-item${active ? ' nav-library-dropdown-item--selected' : ''}`} className={`nav-library-dropdown-item${active ? ' nav-library-dropdown-item--selected' : ''}`}
disabled={busy} style={{ padding: 0 }}
onClick={() => onPickServer(srv)}
> >
<span className="nav-library-dropdown-item-label">{labelText}</span> <span style={{ flexShrink: 0, marginLeft: 'var(--space-3)', display: 'inline-flex', width: 16, height: 16, alignItems: 'center', justifyContent: 'center' }} aria-hidden>
{switchingId === srv.id ? ( {switchingId === srv.id ? (
<div className="spinner" style={{ width: 14, height: 14, flexShrink: 0 }} aria-hidden /> <div className="spinner" style={{ width: 14, height: 14 }} />
) : active ? ( ) : active ? (
<Check size={16} className="nav-library-dropdown-check" aria-hidden /> <Check size={16} className="nav-library-dropdown-check" />
) : ( ) : null}
<span className="nav-library-dropdown-check-spacer" aria-hidden /> </span>
)} <button
</button> type="button"
onClick={() => onPickServer(srv)}
disabled={busy}
className="nav-library-dropdown-item-label"
style={{
flex: 1, minWidth: 0, padding: 'var(--space-2) var(--space-3)',
background: 'transparent', border: 'none', color: 'inherit',
font: 'inherit', textAlign: 'left', cursor: busy ? 'default' : 'pointer',
}}
>
{labelText}
</button>
<ServerScanActions serverId={srv.id} variant="compact" />
</div>
); );
})} })}
<div <div
+33 -21
View File
@@ -15,13 +15,20 @@ export function AddServerForm({
onSave, onSave,
onCancel, onCancel,
initialInvite = null, initialInvite = null,
editingServer = null,
}: { }: {
onSave: (data: Omit<ServerProfile, 'id'>) => void; onSave: (data: Omit<ServerProfile, 'id'>) => void;
onCancel: () => void; onCancel: () => void;
initialInvite?: ServerMagicPayload | null; initialInvite?: ServerMagicPayload | null;
editingServer?: ServerProfile | null;
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const [form, setForm] = useState({ name: '', url: '', username: '', password: '' }); const isEdit = editingServer != null;
const [form, setForm] = useState(
editingServer
? { name: editingServer.name, url: editingServer.url, username: editingServer.username, password: editingServer.password }
: { name: '', url: '', username: '', password: '' },
);
const [magicString, setMagicString] = useState(''); const [magicString, setMagicString] = useState('');
const [showPass, setShowPass] = useState(false); const [showPass, setShowPass] = useState(false);
const [blockPasswordReveal, setBlockPasswordReveal] = useState(false); const [blockPasswordReveal, setBlockPasswordReveal] = useState(false);
@@ -85,8 +92,14 @@ export function AddServerForm({
}; };
return ( return (
<div className="settings-card" style={{ marginTop: '1rem' }}> <form
<h3 style={{ fontWeight: 600, marginBottom: '1rem', fontSize: '14px' }}>{t('settings.addServerTitle')}</h3> className="settings-card"
style={{ marginTop: '1rem' }}
onSubmit={e => { e.preventDefault(); submit(); }}
>
<h3 style={{ fontWeight: 600, marginBottom: '1rem', fontSize: '14px' }}>
{isEdit ? t('settings.editServerTitle') : t('settings.addServerTitle')}
</h3>
<div className="form-group" style={{ marginBottom: '0.75rem' }}> <div className="form-group" style={{ marginBottom: '0.75rem' }}>
<label style={{ fontSize: 13 }}>{t('settings.serverName')}</label> <label style={{ fontSize: 13 }}>{t('settings.serverName')}</label>
<input className="input" type="text" value={form.name} onChange={update('name')} placeholder="My Navidrome" autoComplete="off" /> <input className="input" type="text" value={form.name} onChange={update('name')} placeholder="My Navidrome" autoComplete="off" />
@@ -142,26 +155,25 @@ export function AddServerForm({
)} )}
</div> </div>
</div> </div>
<div className="form-group" style={{ marginBottom: '0.75rem' }}> {!isEdit && (
<label style={{ fontSize: 13 }}>{t('login.orMagicString')}</label> <div className="form-group" style={{ marginBottom: '0.75rem' }}>
<input <label style={{ fontSize: 13 }}>{t('login.orMagicString')}</label>
className="input" <input
type="text" className="input"
value={magicString} type="text"
onChange={handleMagicStringChange} value={magicString}
placeholder={t('login.magicStringPlaceholder')} onChange={handleMagicStringChange}
autoComplete="off" placeholder={t('login.magicStringPlaceholder')}
/> autoComplete="off"
</div> />
</div>
)}
<div style={{ display: 'flex', gap: '8px', justifyContent: 'flex-end' }}> <div style={{ display: 'flex', gap: '8px', justifyContent: 'flex-end' }}>
<button className="btn btn-ghost" onClick={onCancel}>{t('common.cancel')}</button> <button type="button" className="btn btn-ghost" onClick={onCancel}>{t('common.cancel')}</button>
<button <button type="submit" className="btn btn-primary">
className="btn btn-primary" {isEdit ? t('common.save') : t('common.add')}
onClick={submit}
>
{t('common.add')}
</button> </button>
</div> </div>
</div> </form>
); );
} }
@@ -0,0 +1,164 @@
import React, { useEffect, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { Check, DatabaseZap, Loader2, RefreshCw, WifiOff } from 'lucide-react';
import { startScan } from '../../api/subsonicScan';
import { useScanStore } from '../../store/scanStore';
import { showToast } from '../../utils/ui/toast';
const CONFIRM_TIMEOUT_MS = 3000;
interface Props {
serverId: string;
/** `compact` for the server switcher dropdown (icon-only), `card` for the settings cards. */
variant: 'compact' | 'card';
}
export default function ServerScanActions({ serverId, variant }: Props) {
const { t } = useTranslation();
const entry = useScanStore(s => s.byServer[serverId]);
const setEntry = useScanStore(s => s.set);
const confirmTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const phase = entry?.phase ?? 'idle';
const count = entry?.count ?? 0;
const confirmingFull = entry?.confirmingFull ?? false;
const busy = phase === 'starting' || phase === 'running';
useEffect(() => () => {
if (confirmTimerRef.current) clearTimeout(confirmTimerRef.current);
}, []);
const armConfirmTimeout = () => {
if (confirmTimerRef.current) clearTimeout(confirmTimerRef.current);
confirmTimerRef.current = setTimeout(() => {
setEntry(serverId, { confirmingFull: false });
}, CONFIRM_TIMEOUT_MS);
};
const launch = async (full: boolean) => {
setEntry(serverId, { phase: 'starting', type: full ? 'full' : 'quick', count: 0, confirmingFull: false });
try {
await startScan(serverId, full);
setEntry(serverId, { phase: 'running' });
} catch (err) {
setEntry(serverId, { phase: 'error' });
showToast(typeof err === 'string' ? err : err instanceof Error ? err.message : 'Scan failed', 4000, 'error');
}
};
const onQuickClick = (e: React.MouseEvent) => {
e.stopPropagation();
if (busy) return;
void launch(false);
};
const onFullClick = (e: React.MouseEvent) => {
e.stopPropagation();
if (busy) return;
if (!confirmingFull) {
setEntry(serverId, { confirmingFull: true });
armConfirmTimeout();
return;
}
if (confirmTimerRef.current) clearTimeout(confirmTimerRef.current);
void launch(true);
};
// ── Status slot content ──────────────────────────────────────────────────
const status = (() => {
if (phase === 'running' || phase === 'starting') {
const label = count > 0 ? formatCount(count) : '';
return (
<span className="server-scan-status" data-tooltip={t('settings.scan.scanning')}>
<Loader2 size={13} className="spin-slow" />
{label && <span className="server-scan-status-count">{label}</span>}
</span>
);
}
if (phase === 'just-finished') {
return (
<span className="server-scan-status server-scan-status--done" data-tooltip={t('settings.scan.done')}>
<Check size={13} />
</span>
);
}
if (phase === 'error') {
return (
<span className="server-scan-status server-scan-status--error" data-tooltip={t('settings.scan.error')}>
<WifiOff size={13} />
</span>
);
}
return null;
})();
// ── Render ───────────────────────────────────────────────────────────────
if (variant === 'compact') {
return (
<span className="server-scan-actions server-scan-actions--compact" onClick={e => e.stopPropagation()}>
<button
type="button"
className="player-btn player-btn-sm"
onClick={onQuickClick}
disabled={busy}
data-tooltip={t('settings.scan.quickTip')}
data-tooltip-pos="bottom"
aria-label={t('settings.scan.quick')}
>
<RefreshCw size={13} />
</button>
<button
type="button"
className={`player-btn player-btn-sm${confirmingFull ? ' server-scan-btn--confirm' : ''}`}
onClick={onFullClick}
disabled={busy}
data-tooltip={confirmingFull ? t('settings.scan.confirmFull') : t('settings.scan.fullTip')}
data-tooltip-pos="bottom"
aria-label={t('settings.scan.full')}
>
<DatabaseZap size={13} />
</button>
<span className="server-scan-slot" aria-live="polite">{status}</span>
</span>
);
}
// Card variant — matches the existing `btn btn-surface` chrome in ServersTab actions.
return (
<>
<button
type="button"
className="btn btn-surface"
style={{ fontSize: 12, padding: '4px 10px' }}
onClick={onQuickClick}
disabled={busy}
data-tooltip={t('settings.scan.quickTip')}
aria-label={t('settings.scan.quick')}
>
<RefreshCw size={13} />
<span className="server-card-btn-label">{t('settings.scan.quick')}</span>
</button>
<button
type="button"
className={`btn btn-surface${confirmingFull ? ' btn-confirm-danger' : ''}`}
style={{ fontSize: 12, padding: '4px 10px' }}
onClick={onFullClick}
disabled={busy}
data-tooltip={confirmingFull ? t('settings.scan.confirmFull') : t('settings.scan.fullTip')}
aria-label={t('settings.scan.full')}
>
<DatabaseZap size={13} />
<span className="server-card-btn-label">
{confirmingFull ? t('settings.scan.confirmFullShort') : t('settings.scan.full')}
</span>
</button>
{status && <span className="server-scan-slot">{status}</span>}
</>
);
}
function formatCount(n: number): string {
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`;
return String(n);
}
+59 -3
View File
@@ -2,7 +2,7 @@ import React, { useEffect, useLayoutEffect, useRef, useState } from 'react';
import { Trans, useTranslation } from 'react-i18next'; import { Trans, useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { open as openUrl } from '@tauri-apps/plugin-shell'; import { open as openUrl } from '@tauri-apps/plugin-shell';
import { AlertTriangle, CheckCircle2, Lock, LogOut, Plus, Server, Sparkles, Trash2, User, Wifi, WifiOff } from 'lucide-react'; import { AlertTriangle, CheckCircle2, Lock, LogOut, Pencil, Plus, Power, Server, Sparkles, Trash2, User, Wifi, WifiOff } from 'lucide-react';
import { useAuthStore } from '../../store/authStore'; import { useAuthStore } from '../../store/authStore';
import type { ServerProfile } from '../../store/authStoreTypes'; import type { ServerProfile } from '../../store/authStoreTypes';
import { pingWithCredentials, scheduleInstantMixProbeForServer } from '../../api/subsonic'; import { pingWithCredentials, scheduleInstantMixProbeForServer } from '../../api/subsonic';
@@ -13,6 +13,7 @@ import { serverListDisplayLabel } from '../../utils/server/serverDisplayName';
import { switchActiveServer } from '../../utils/server/switchActiveServer'; import { switchActiveServer } from '../../utils/server/switchActiveServer';
import { AddServerForm } from './AddServerForm'; import { AddServerForm } from './AddServerForm';
import { ServerGripHandle } from './ServerGripHandle'; import { ServerGripHandle } from './ServerGripHandle';
import ServerScanActions from './ServerScanActions';
const AUDIOMUSE_NV_PLUGIN_URL = 'https://github.com/NeptuneHub/AudioMuse-AI-NV-plugin'; const AUDIOMUSE_NV_PLUGIN_URL = 'https://github.com/NeptuneHub/AudioMuse-AI-NV-plugin';
@@ -30,6 +31,7 @@ export function ServersTab({
const [connStatus, setConnStatus] = useState<Record<string, 'idle' | 'testing' | 'ok' | 'error'>>({}); const [connStatus, setConnStatus] = useState<Record<string, 'idle' | 'testing' | 'ok' | 'error'>>({});
const [showAddForm, setShowAddForm] = useState<boolean>(initialInvite != null); const [showAddForm, setShowAddForm] = useState<boolean>(initialInvite != null);
const [editingServerId, setEditingServerId] = useState<string | null>(null);
const [pastedServerInvite, setPastedServerInvite] = useState<ServerMagicPayload | null>(initialInvite); const [pastedServerInvite, setPastedServerInvite] = useState<ServerMagicPayload | null>(initialInvite);
const [serverContainerEl, setServerContainerEl] = useState<HTMLDivElement | null>(null); const [serverContainerEl, setServerContainerEl] = useState<HTMLDivElement | null>(null);
const [serverDropTarget, setServerDropTarget] = useState<ServerDropTarget>(null); const [serverDropTarget, setServerDropTarget] = useState<ServerDropTarget>(null);
@@ -168,6 +170,31 @@ export function ServersTab({
} }
}; };
// 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<ServerProfile, 'id'>) => {
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 = () => { const handleLogout = () => {
auth.logout(); auth.logout();
navigate('/login'); navigate('/login');
@@ -195,6 +222,16 @@ export function ServersTab({
style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }} style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}
> >
{auth.servers.map((srv, srvIdx) => { {auth.servers.map((srv, srvIdx) => {
if (editingServerId === srv.id) {
return (
<AddServerForm
key={srv.id}
editingServer={srv}
onSave={(data) => handleEditServer(srv.id, data)}
onCancel={() => setEditingServerId(null)}
/>
);
}
const isActive = srv.id === auth.activeServerId; const isActive = srv.id === auth.activeServerId;
const status = connStatus[srv.id]; const status = connStatus[srv.id];
const isBefore = psyDragState.isDragging && serverDropTarget?.idx === srvIdx && serverDropTarget.before; const isBefore = psyDragState.isDragging && serverDropTarget?.idx === srvIdx && serverDropTarget.before;
@@ -240,14 +277,17 @@ export function ServersTab({
{status === 'ok' && <CheckCircle2 size={16} style={{ color: 'var(--positive)' }} />} {status === 'ok' && <CheckCircle2 size={16} style={{ color: 'var(--positive)' }} />}
{status === 'error' && <WifiOff size={16} style={{ color: 'var(--danger)' }} />} {status === 'error' && <WifiOff size={16} style={{ color: 'var(--danger)' }} />}
{status === 'testing' && <div className="spinner" style={{ width: 16, height: 16 }} />} {status === 'testing' && <div className="spinner" style={{ width: 16, height: 16 }} />}
<ServerScanActions serverId={srv.id} variant="card" />
<button <button
className="btn btn-surface" className="btn btn-surface"
style={{ fontSize: 12, padding: '4px 10px' }} style={{ fontSize: 12, padding: '4px 10px' }}
onClick={() => testConnection(srv)} onClick={() => testConnection(srv)}
disabled={status === 'testing'} disabled={status === 'testing'}
data-tooltip={t('settings.testBtn')}
aria-label={t('settings.testBtn')}
> >
<Wifi size={13} /> <Wifi size={13} />
{t('settings.testBtn')} <span className="server-card-btn-label">{t('settings.testBtn')}</span>
</button> </button>
{!isActive && ( {!isActive && (
<button <button
@@ -256,10 +296,26 @@ export function ServersTab({
onClick={() => switchToServer(srv)} onClick={() => switchToServer(srv)}
disabled={status === 'testing'} disabled={status === 'testing'}
id={`settings-use-server-${srv.id}`} id={`settings-use-server-${srv.id}`}
data-tooltip={t('settings.useServer')}
aria-label={t('settings.useServer')}
> >
{t('settings.useServer')} <Power size={13} />
<span className="server-card-btn-label">{t('settings.useServer')}</span>
</button> </button>
)} )}
<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 <button
className="btn btn-ghost" className="btn btn-ghost"
style={{ color: 'var(--danger)', padding: '4px 8px' }} style={{ color: 'var(--danger)', padding: '4px 8px' }}
+1
View File
@@ -310,6 +310,7 @@ const CONTRIBUTOR_ENTRIES = [
'Home: Lossless Albums rail + dedicated /lossless-albums page with infinite scroll and header parity (selection mode, enqueue, offline, download ZIPs), streaming load via per-fetch onProgress, sidebar entry default visible, detection via Navidrome native bit_depth-sorted song cursor with always-lossless suffix allowlist (PR #506)', 'Home: Lossless Albums rail + dedicated /lossless-albums page with infinite scroll and header parity (selection mode, enqueue, offline, download ZIPs), streaming load via per-fetch onProgress, sidebar entry default visible, detection via Navidrome native bit_depth-sorted song cursor with always-lossless suffix allowlist (PR #506)',
'Accessibility: OpenDyslexic font option in the Settings picker — bundled locally via @fontsource/opendyslexic, asymmetric glyph shapes for easier b/d, p/q tracking, Latin-only with translated subtitle in all 9 locales calling out the dyslexia-friendly intent and the Cyrillic/CJK fallback (PR #507)', 'Accessibility: OpenDyslexic font option in the Settings picker — bundled locally via @fontsource/opendyslexic, asymmetric glyph shapes for easier b/d, p/q tracking, Latin-only with translated subtitle in all 9 locales calling out the dyslexia-friendly intent and the Cyrillic/CJK fallback (PR #507)',
'Settings: tri-state Clock Format (Auto / 24h / 12h) overriding the locale default for the queue ETA and the sleep-timer preview (PR #742)', 'Settings: tri-state Clock Format (Auto / 24h / 12h) overriding the locale default for the queue ETA and the sleep-timer preview (PR #742)',
'Servers: per-server library scan triggers (Quick / Full) and inline edit for existing profiles (PR #780)',
], ],
}, },
] as const; ] as const;
+64
View File
@@ -0,0 +1,64 @@
import { useEffect } from 'react';
import { getScanStatus } from '../api/subsonicScan';
import { useScanStore } from '../store/scanStore';
import { useAuthStore } from '../store/authStore';
import { serverListDisplayLabel } from '../utils/server/serverDisplayName';
import { showToast } from '../utils/ui/toast';
import i18n from '../i18n';
const POLL_MS = 2000;
const JUST_FINISHED_HOLD_MS = 2500;
/**
* Polls `getScanStatus` on each server whose scan was triggered from the app.
* Lives at the app root so dismissing the dropdown doesn't stop the poll.
*/
export function useScanPolling() {
useEffect(() => {
let cancelled = false;
const inFlight = new Set<string>();
const tick = async () => {
if (cancelled) return;
const { byServer, set } = useScanStore.getState();
const runningIds = Object.entries(byServer)
.filter(([, e]) => e.phase === 'starting' || e.phase === 'running')
.map(([id]) => id);
await Promise.all(runningIds.map(async serverId => {
if (inFlight.has(serverId)) return;
inFlight.add(serverId);
try {
const status = await getScanStatus(serverId);
if (cancelled) return;
const current = useScanStore.getState().byServer[serverId];
if (!current) return;
if (status.scanning) {
set(serverId, { phase: 'running', count: status.count });
} else {
// Scan finished — show check briefly, then auto-clear.
set(serverId, { phase: 'just-finished', count: status.count });
const servers = useAuthStore.getState().servers;
const srv = servers.find(s => s.id === serverId);
const name = srv ? serverListDisplayLabel(srv, servers) : serverId;
showToast(i18n.t('settings.scan.toast', { name, count: status.count }), 4000, 'success');
setTimeout(() => {
const after = useScanStore.getState().byServer[serverId];
if (after?.phase === 'just-finished') {
useScanStore.getState().clear(serverId);
}
}, JUST_FINISHED_HOLD_MS);
}
} catch {
if (!cancelled) set(serverId, { phase: 'error' });
} finally {
inFlight.delete(serverId);
}
}));
};
const interval = setInterval(tick, POLL_MS);
return () => { cancelled = true; clearInterval(interval); };
}, []);
}
+14
View File
@@ -22,6 +22,8 @@ export const settings = {
serverPassword: 'Passwort', serverPassword: 'Passwort',
addServer: 'Server hinzufügen', addServer: 'Server hinzufügen',
addServerTitle: 'Neuen Server hinzufügen', addServerTitle: 'Neuen Server hinzufügen',
editServer: 'Bearbeiten',
editServerTitle: 'Server bearbeiten',
useServer: 'Verwenden', useServer: 'Verwenden',
deleteServer: 'Löschen', deleteServer: 'Löschen',
noServers: 'Keine Server gespeichert.', noServers: 'Keine Server gespeichert.',
@@ -461,4 +463,16 @@ export const settings = {
floatingPlayerBarSub: 'Player-Leiste über dem Inhalt schweben lassen', floatingPlayerBarSub: 'Player-Leiste über dem Inhalt schweben lassen',
uiScaleTitle: 'Interface-Skalierung', uiScaleTitle: 'Interface-Skalierung',
uiScaleLabel: 'Zoom', uiScaleLabel: 'Zoom',
scan: {
quick: 'Quick-Scan',
full: 'Full-Scan',
quickTip: 'Quick-Scan — nur neue/geänderte Dateien indexieren',
fullTip: 'Full-Scan — alles neu indexieren',
confirmFull: 'Nochmal klicken zum Bestätigen',
confirmFullShort: 'Full-Scan bestätigen',
scanning: 'Scannt…',
done: 'Scan fertig',
error: 'Scan fehlgeschlagen',
toast: '{{name}}: Scan fertig — {{count}} Tracks indexiert',
},
}; };
+14
View File
@@ -22,6 +22,8 @@ export const settings = {
serverPassword: 'Password', serverPassword: 'Password',
addServer: 'Add Server', addServer: 'Add Server',
addServerTitle: 'Add New Server', addServerTitle: 'Add New Server',
editServer: 'Edit',
editServerTitle: 'Edit Server',
useServer: 'Use', useServer: 'Use',
deleteServer: 'Delete', deleteServer: 'Delete',
noServers: 'No servers saved.', noServers: 'No servers saved.',
@@ -464,4 +466,16 @@ export const settings = {
floatingPlayerBarSub: 'Keep the player bar floating above content', floatingPlayerBarSub: 'Keep the player bar floating above content',
uiScaleTitle: 'Interface Scale', uiScaleTitle: 'Interface Scale',
uiScaleLabel: 'Zoom', uiScaleLabel: 'Zoom',
scan: {
quick: 'Quick scan',
full: 'Full scan',
quickTip: 'Quick scan — index new/changed files',
fullTip: 'Full scan — re-index everything',
confirmFull: 'Click again to confirm full scan',
confirmFullShort: 'Confirm full scan',
scanning: 'Scanning…',
done: 'Scan finished',
error: 'Scan failed',
toast: '{{name}}: scan finished — {{count}} tracks indexed',
},
}; };
+14
View File
@@ -22,6 +22,8 @@ export const settings = {
serverPassword: 'Contraseña', serverPassword: 'Contraseña',
addServer: 'Agregar Servidor', addServer: 'Agregar Servidor',
addServerTitle: 'Agregar Nuevo Servidor', addServerTitle: 'Agregar Nuevo Servidor',
editServer: 'Editar',
editServerTitle: 'Editar Servidor',
useServer: 'Usar', useServer: 'Usar',
deleteServer: 'Eliminar', deleteServer: 'Eliminar',
noServers: 'No hay servidores guardados.', noServers: 'No hay servidores guardados.',
@@ -461,4 +463,16 @@ export const settings = {
floatingPlayerBarSub: 'Mantener la barra del reproductor flotando sobre el contenido', floatingPlayerBarSub: 'Mantener la barra del reproductor flotando sobre el contenido',
uiScaleTitle: 'Escala de Interfaz', uiScaleTitle: 'Escala de Interfaz',
uiScaleLabel: 'Zoom', uiScaleLabel: 'Zoom',
scan: {
quick: 'Escaneo rápido',
full: 'Escaneo completo',
quickTip: 'Escaneo rápido — indexar archivos nuevos/modificados',
fullTip: 'Escaneo completo — reindexar todo',
confirmFull: 'Haz clic de nuevo para confirmar',
confirmFullShort: 'Confirmar escaneo completo',
scanning: 'Escaneando…',
done: 'Escaneo finalizado',
error: 'Error en el escaneo',
toast: '{{name}}: escaneo finalizado — {{count}} pistas indexadas',
},
}; };
+14
View File
@@ -22,6 +22,8 @@ export const settings = {
serverPassword: 'Mot de passe', serverPassword: 'Mot de passe',
addServer: 'Ajouter un serveur', addServer: 'Ajouter un serveur',
addServerTitle: 'Ajouter un nouveau serveur', addServerTitle: 'Ajouter un nouveau serveur',
editServer: 'Modifier',
editServerTitle: 'Modifier le serveur',
useServer: 'Utiliser', useServer: 'Utiliser',
deleteServer: 'Supprimer', deleteServer: 'Supprimer',
noServers: 'Aucun serveur enregistré.', noServers: 'Aucun serveur enregistré.',
@@ -459,4 +461,16 @@ export const settings = {
floatingPlayerBarSub: 'Garder la barre du lecteur flottante au-dessus du contenu', floatingPlayerBarSub: 'Garder la barre du lecteur flottante au-dessus du contenu',
uiScaleTitle: "Mise à l'échelle de l'interface", uiScaleTitle: "Mise à l'échelle de l'interface",
uiScaleLabel: 'Zoom', uiScaleLabel: 'Zoom',
scan: {
quick: 'Analyse rapide',
full: 'Analyse complète',
quickTip: 'Analyse rapide — indexer les fichiers nouveaux/modifiés',
fullTip: 'Analyse complète — tout réindexer',
confirmFull: 'Cliquer à nouveau pour confirmer',
confirmFullShort: "Confirmer l'analyse complète",
scanning: 'Analyse en cours…',
done: 'Analyse terminée',
error: "Échec de l'analyse",
toast: '{{name}} : analyse terminée — {{count}} pistes indexées',
},
}; };
+14
View File
@@ -22,6 +22,8 @@ export const settings = {
serverPassword: 'Passord', serverPassword: 'Passord',
addServer: 'Legg til tjener', addServer: 'Legg til tjener',
addServerTitle: 'Legg til ny tjener', addServerTitle: 'Legg til ny tjener',
editServer: 'Rediger',
editServerTitle: 'Rediger tjener',
useServer: 'Bruk', useServer: 'Bruk',
deleteServer: 'Slett', deleteServer: 'Slett',
noServers: 'Ingen tjenere lagret.', noServers: 'Ingen tjenere lagret.',
@@ -458,4 +460,16 @@ export const settings = {
floatingPlayerBarSub: 'Hold spillerlinjen flytende over innholdet', floatingPlayerBarSub: 'Hold spillerlinjen flytende over innholdet',
uiScaleTitle: 'Grensesnittskala', uiScaleTitle: 'Grensesnittskala',
uiScaleLabel: 'Zoom', uiScaleLabel: 'Zoom',
scan: {
quick: 'Hurtigskanning',
full: 'Full skanning',
quickTip: 'Hurtigskanning — indekser nye/endrede filer',
fullTip: 'Full skanning — reindekser alt',
confirmFull: 'Klikk igjen for å bekrefte',
confirmFullShort: 'Bekreft full skanning',
scanning: 'Skanner…',
done: 'Skanning ferdig',
error: 'Skanning mislyktes',
toast: '{{name}}: skanning ferdig — {{count}} spor indeksert',
},
}; };
+14
View File
@@ -22,6 +22,8 @@ export const settings = {
serverPassword: 'Wachtwoord', serverPassword: 'Wachtwoord',
addServer: 'Server toevoegen', addServer: 'Server toevoegen',
addServerTitle: 'Nieuwe server toevoegen', addServerTitle: 'Nieuwe server toevoegen',
editServer: 'Bewerken',
editServerTitle: 'Server bewerken',
useServer: 'Gebruiken', useServer: 'Gebruiken',
deleteServer: 'Verwijderen', deleteServer: 'Verwijderen',
noServers: 'Geen servers opgeslagen.', noServers: 'Geen servers opgeslagen.',
@@ -459,4 +461,16 @@ export const settings = {
floatingPlayerBarSub: 'Houd de spelerbalk zwevend boven de inhoud', floatingPlayerBarSub: 'Houd de spelerbalk zwevend boven de inhoud',
uiScaleTitle: 'Interface schaal', uiScaleTitle: 'Interface schaal',
uiScaleLabel: 'Zoom', uiScaleLabel: 'Zoom',
scan: {
quick: 'Snel scannen',
full: 'Volledige scan',
quickTip: 'Snel scannen — nieuwe/gewijzigde bestanden indexeren',
fullTip: 'Volledige scan — alles opnieuw indexeren',
confirmFull: 'Klik nogmaals om te bevestigen',
confirmFullShort: 'Volledige scan bevestigen',
scanning: 'Scannen…',
done: 'Scan voltooid',
error: 'Scan mislukt',
toast: '{{name}}: scan voltooid — {{count}} tracks geïndexeerd',
},
}; };
+14
View File
@@ -22,6 +22,8 @@ export const settings = {
serverPassword: 'Parolă', serverPassword: 'Parolă',
addServer: 'Adaugă Server', addServer: 'Adaugă Server',
addServerTitle: 'Adaugă Server nou', addServerTitle: 'Adaugă Server nou',
editServer: 'Editează',
editServerTitle: 'Editează Server',
useServer: 'Folosește', useServer: 'Folosește',
deleteServer: 'Șterge', deleteServer: 'Șterge',
noServers: 'Niciun server salvat.', noServers: 'Niciun server salvat.',
@@ -464,4 +466,16 @@ export const settings = {
floatingPlayerBarSub: 'Păstrează bara playerului plutind deasupra conținutului', floatingPlayerBarSub: 'Păstrează bara playerului plutind deasupra conținutului',
uiScaleTitle: 'Scara interfaței', uiScaleTitle: 'Scara interfaței',
uiScaleLabel: 'Zoom', uiScaleLabel: 'Zoom',
scan: {
quick: 'Scanare rapidă',
full: 'Scanare completă',
quickTip: 'Scanare rapidă — indexează fișierele noi/modificate',
fullTip: 'Scanare completă — reindexează totul',
confirmFull: 'Apasă din nou pentru a confirma',
confirmFullShort: 'Confirmă scanarea completă',
scanning: 'Se scanează…',
done: 'Scanare finalizată',
error: 'Scanare eșuată',
toast: '{{name}}: scanare finalizată — {{count}} piese indexate',
},
}; };
+14
View File
@@ -22,6 +22,8 @@ export const settings = {
serverPassword: 'Пароль', serverPassword: 'Пароль',
addServer: 'Добавить сервер', addServer: 'Добавить сервер',
addServerTitle: 'Новый сервер', addServerTitle: 'Новый сервер',
editServer: 'Изменить',
editServerTitle: 'Изменить сервер',
useServer: 'Выбрать', useServer: 'Выбрать',
deleteServer: 'Удалить', deleteServer: 'Удалить',
noServers: 'Нет сохранённых серверов.', noServers: 'Нет сохранённых серверов.',
@@ -478,4 +480,16 @@ export const settings = {
floatingPlayerBarSub: 'Держать панель плеера плавающей над содержимым', floatingPlayerBarSub: 'Держать панель плеера плавающей над содержимым',
uiScaleTitle: 'Масштаб интерфейса', uiScaleTitle: 'Масштаб интерфейса',
uiScaleLabel: 'Масштаб', uiScaleLabel: 'Масштаб',
scan: {
quick: 'Быстрое сканирование',
full: 'Полное сканирование',
quickTip: 'Быстрое сканирование — индексация новых/изменённых файлов',
fullTip: 'Полное сканирование — переиндексировать всё',
confirmFull: 'Нажмите ещё раз для подтверждения',
confirmFullShort: 'Подтвердить полное сканирование',
scanning: 'Сканирование…',
done: 'Сканирование завершено',
error: 'Ошибка сканирования',
toast: '{{name}}: сканирование завершено — проиндексировано треков: {{count}}',
},
}; };
+14
View File
@@ -22,6 +22,8 @@ export const settings = {
serverPassword: '密码', serverPassword: '密码',
addServer: '添加服务器', addServer: '添加服务器',
addServerTitle: '添加新服务器', addServerTitle: '添加新服务器',
editServer: '编辑',
editServerTitle: '编辑服务器',
useServer: '使用', useServer: '使用',
deleteServer: '删除', deleteServer: '删除',
noServers: '未保存任何服务器。', noServers: '未保存任何服务器。',
@@ -459,4 +461,16 @@ export const settings = {
floatingPlayerBarSub: '保持播放栏悬浮在内容上方', floatingPlayerBarSub: '保持播放栏悬浮在内容上方',
uiScaleTitle: '界面缩放', uiScaleTitle: '界面缩放',
uiScaleLabel: '缩放', uiScaleLabel: '缩放',
scan: {
quick: '快速扫描',
full: '完整扫描',
quickTip: '快速扫描 — 仅索引新增/变更文件',
fullTip: '完整扫描 — 重新索引全部',
confirmFull: '再次点击确认',
confirmFullShort: '确认完整扫描',
scanning: '扫描中…',
done: '扫描完成',
error: '扫描失败',
toast: '{{name}}: 扫描完成 — 已索引 {{count}} 首曲目',
},
}; };
+33
View File
@@ -0,0 +1,33 @@
import { create } from 'zustand';
export type ScanPhase = 'idle' | 'starting' | 'running' | 'just-finished' | 'error';
export interface ScanEntry {
phase: ScanPhase;
type: 'quick' | 'full';
count: number;
/** Set while user is in "press again to confirm" state for a full scan. */
confirmingFull: boolean;
}
const EMPTY: ScanEntry = { phase: 'idle', type: 'quick', count: 0, confirmingFull: false };
interface ScanState {
byServer: Record<string, ScanEntry>;
get: (serverId: string) => ScanEntry;
set: (serverId: string, patch: Partial<ScanEntry>) => void;
clear: (serverId: string) => void;
}
export const useScanStore = create<ScanState>((set, getState) => ({
byServer: {},
get: (serverId) => getState().byServer[serverId] ?? EMPTY,
set: (serverId, patch) => set(s => ({
byServer: { ...s.byServer, [serverId]: { ...(s.byServer[serverId] ?? EMPTY), ...patch } },
})),
clear: (serverId) => set(s => {
const next = { ...s.byServer };
delete next[serverId];
return { byServer: next };
}),
}));
@@ -83,3 +83,55 @@
white-space: nowrap; white-space: nowrap;
} }
/* ─ Server scan actions (used in server switcher + settings card) ─ */
.server-scan-actions--compact {
display: inline-flex;
align-items: center;
gap: 2px;
flex-shrink: 0;
}
.server-scan-btn--confirm {
background: var(--danger, #dc3c3c) !important;
color: #fff !important;
animation: server-scan-confirm-pulse 0.7s ease-in-out infinite alternate;
}
@keyframes server-scan-confirm-pulse {
from { filter: brightness(1); }
to { filter: brightness(1.25); }
}
.server-scan-slot {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 28px;
height: 28px;
}
.server-scan-status {
display: inline-flex;
align-items: center;
gap: 4px;
color: var(--text-muted);
font-size: 11px;
font-weight: 500;
}
.server-scan-status--done { color: var(--positive, #4ade80); }
.server-scan-status--error { color: var(--danger, #dc3c3c); }
.server-scan-status-count {
font-variant-numeric: tabular-nums;
}
/* Server card actions collapse to icon-only on narrow viewports labels
* remain available as native tooltips via the existing `data-tooltip` /
* `aria-label` attributes. Breakpoint is tuned to where the action row
* starts overflowing on the default Settings layout. */
@media (max-width: 1100px) {
.server-card-btn-label {
display: none;
}
}