chore(servers): remove quick/full scan buttons from server cards (#843)

Drops the Quick/Full scan actions and all supporting logic — they are no
longer needed. Removes the ServerScanActions component (incl. the unused
compact variant), the subsonicScan API, the scanStore, and the app-root
useScanPolling hook (no more background scan polling). Cleans up the
.server-scan-* CSS and the settings.scan i18n block across all 9 locales.

440 deletions, no new code; tsc + bundle clean.
This commit is contained in:
Frank Stellmacher
2026-05-21 22:43:46 +02:00
committed by GitHub
parent f9f96f024f
commit 1a7a2a0bfc
16 changed files with 0 additions and 440 deletions
-23
View File
@@ -1,23 +0,0 @@
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,7 +14,6 @@ import { useGlobalShortcutsStore } from '../store/globalShortcutsStore';
import { initHotCachePrefetch } from '../hotCachePrefetch';
import { initMiniPlayerBridgeOnMain } from '../utils/miniPlayerBridge';
import { runAdvancedModeMigration } from '../utils/migrations/advancedModeMigration';
import { useScanPolling } from '../hooks/useScanPolling';
import { IS_WINDOWS } from '../utils/platform';
import TauriEventBridge from './TauriEventBridge';
import AppShell from './AppShell';
@@ -35,8 +34,6 @@ export default function MainApp() {
// Advanced Mode toggle. Idempotent — flagged in localStorage.
useEffect(() => { runAdvancedModeMigration(); }, []);
useScanPolling();
// Push playback state to mini window + handle control events.
useEffect(() => {
return initMiniPlayerBridgeOnMain();
@@ -1,164 +0,0 @@
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);
}
-2
View File
@@ -13,7 +13,6 @@ import { serverListDisplayLabel } from '../../utils/server/serverDisplayName';
import { switchActiveServer } from '../../utils/server/switchActiveServer';
import { AddServerForm } from './AddServerForm';
import { ServerGripHandle } from './ServerGripHandle';
import ServerScanActions from './ServerScanActions';
const AUDIOMUSE_NV_PLUGIN_URL = 'https://github.com/NeptuneHub/AudioMuse-AI-NV-plugin';
@@ -277,7 +276,6 @@ export function ServersTab({
{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 }} />}
<ServerScanActions serverId={srv.id} variant="card" />
<button
className="btn btn-surface"
style={{ fontSize: 12, padding: '4px 10px' }}
-64
View File
@@ -1,64 +0,0 @@
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); };
}, []);
}
-12
View File
@@ -470,16 +470,4 @@ export const settings = {
floatingPlayerBarSub: 'Player-Leiste über dem Inhalt schweben lassen',
uiScaleTitle: 'Interface-Skalierung',
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',
},
};
-12
View File
@@ -473,16 +473,4 @@ export const settings = {
floatingPlayerBarSub: 'Keep the player bar floating above content',
uiScaleTitle: 'Interface Scale',
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',
},
};
-12
View File
@@ -470,16 +470,4 @@ export const settings = {
floatingPlayerBarSub: 'Mantener la barra del reproductor flotando sobre el contenido',
uiScaleTitle: 'Escala de Interfaz',
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',
},
};
-12
View File
@@ -468,16 +468,4 @@ export const settings = {
floatingPlayerBarSub: 'Garder la barre du lecteur flottante au-dessus du contenu',
uiScaleTitle: "Mise à l'échelle de l'interface",
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',
},
};
-12
View File
@@ -467,16 +467,4 @@ export const settings = {
floatingPlayerBarSub: 'Hold spillerlinjen flytende over innholdet',
uiScaleTitle: 'Grensesnittskala',
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',
},
};
-12
View File
@@ -468,16 +468,4 @@ export const settings = {
floatingPlayerBarSub: 'Houd de spelerbalk zwevend boven de inhoud',
uiScaleTitle: 'Interface schaal',
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',
},
};
-12
View File
@@ -473,16 +473,4 @@ export const settings = {
floatingPlayerBarSub: 'Păstrează bara playerului plutind deasupra conținutului',
uiScaleTitle: 'Scara interfaței',
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',
},
};
-12
View File
@@ -487,16 +487,4 @@ export const settings = {
floatingPlayerBarSub: 'Держать панель плеера плавающей над содержимым',
uiScaleTitle: 'Масштаб интерфейса',
uiScaleLabel: 'Масштаб',
scan: {
quick: 'Быстрое сканирование',
full: 'Полное сканирование',
quickTip: 'Быстрое сканирование — индексация новых/изменённых файлов',
fullTip: 'Полное сканирование — переиндексировать всё',
confirmFull: 'Нажмите ещё раз для подтверждения',
confirmFullShort: 'Подтвердить полное сканирование',
scanning: 'Сканирование…',
done: 'Сканирование завершено',
error: 'Ошибка сканирования',
toast: '{{name}}: сканирование завершено — проиндексировано треков: {{count}}',
},
};
-12
View File
@@ -467,16 +467,4 @@ export const settings = {
floatingPlayerBarSub: '保持播放栏悬浮在内容上方',
uiScaleTitle: '界面缩放',
uiScaleLabel: '缩放',
scan: {
quick: '快速扫描',
full: '完整扫描',
quickTip: '快速扫描 — 仅索引新增/变更文件',
fullTip: '完整扫描 — 重新索引全部',
confirmFull: '再次点击确认',
confirmFullShort: '确认完整扫描',
scanning: '扫描中…',
done: '扫描完成',
error: '扫描失败',
toast: '{{name}}: 扫描完成 — 已索引 {{count}} 首曲目',
},
};
-33
View File
@@ -1,33 +0,0 @@
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,49 +83,6 @@
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