Files
Psychotoxical-psysonic/src/app/MainApp.tsx
T
Frank Stellmacher 796c7567ea refactor(app): Phase C.2 — move AppShell into src/app/AppShell.tsx (#560)
Companion to C.1. The full `AppShell` component — the persistent
sidebar / header / route host / queue-resizer / player-bar layout plus
its ~25 effects (tray-tooltip + title sync, Orbit role/phase body marker,
platform attribute, fullscreen tracking, music-folders + rating-support
probe, sidebar persistence, queue drag, WebKitGTK DnD/select-all
blockers, blur/hidden cosmetic-animation pause) — moves into
`src/app/AppShell.tsx` together with its three private helpers
(`readInitialSidebarCollapsed`, `persistSidebarCollapsed`,
`shouldSuppressQueueResizerMouseDown`). `MainApp` now imports `AppShell`
from the new file instead of the App.tsx re-export.

`App.tsx` 1232 → 560 LOC. What's left is the `TauriEventBridge` (~475
LOC, Phase D) plus the ~50-LOC `App()` default-export that splits
between `MiniPlayerApp` and `MainApp`. Imports that were AppShell-only
(Sidebar / PlayerBar / 9 components / 7 hooks / 3 platform helpers /
useOfflineStore / useConnectionStatus / useEqStore / usePerfProbeFlags /
useTranslation / useIsMobile / probeEntityRatingSupport / Suspense /
useCallback / useRef / useState / useLocation / getCurrentWindow's UI
use / ConnectionIndicator / LastfmIndicator / AppUpdater / TitleBar /
OrbitSessionBar / OrbitStartTrigger / useOrbitHost / useOrbitGuest /
cleanupOrphanedOrbitPlaylists / IS_MACOS / IS_WINDOWS / IS_LINUX /
APP_MAIN_SCROLL_VIEWPORT_ID / AppRoutes / lucide icons) all leave with
the component.

No behaviour change — pure code move + import-graph shuffle. Tests
unchanged; the existing AppShell behaviour is already covered indirectly
by the per-component tests it composes.

Pre-PR check: PASS (frontend tests, tsc, coverage gates, prod build,
backend tests, clippy, backend coverage gates).
2026-05-12 10:29:22 +02:00

132 lines
4.7 KiB
TypeScript

import { lazy, Suspense, useEffect, useState } from 'react';
import { BrowserRouter, Route, Routes } from 'react-router-dom';
import { invoke } from '@tauri-apps/api/core';
import { showToast } from '../utils/toast';
import { WindowVisibilityProvider } from '../hooks/useWindowVisibility';
import { DragDropProvider } from '../contexts/DragDropContext';
import PasteClipboardHandler from '../components/PasteClipboardHandler';
import ExportPickerModal from '../components/ExportPickerModal';
import ZipDownloadOverlay from '../components/ZipDownloadOverlay';
import FpsOverlay from '../components/FpsOverlay';
import { useAuthStore } from '../store/authStore';
import { useGlobalShortcutsStore } from '../store/globalShortcutsStore';
import { initAudioListeners } from '../store/playerStore';
import { initHotCachePrefetch } from '../hotCachePrefetch';
import { initMiniPlayerBridgeOnMain } from '../utils/miniPlayerBridge';
import { IS_WINDOWS } from '../utils/platform';
import { TauriEventBridge } from '../App';
import AppShell from './AppShell';
import RequireAuth from './RequireAuth';
const Login = lazy(() => import('../pages/Login'));
/**
* Main webview tree. Hosts the router, the application shell (sidebar /
* player bar / queue panel / main scroll viewport), the Tauri event bridge,
* and all background lifecycle hooks (audio listeners, hot-cache prefetch,
* global shortcuts, mini-player bridge, easter egg, scrollbar auto-hide).
*/
export default function MainApp() {
const [exportPickerOpen, setExportPickerOpen] = useState(false);
// Push playback state to mini window + handle control events.
useEffect(() => {
return initMiniPlayerBridgeOnMain();
}, []);
// Optionally pre-create the mini player webview hidden so the first open
// is instant. Windows already does this unconditionally in Rust .setup() as
// a hang workaround — skip here to avoid double-building.
const preloadMiniPlayer = useAuthStore(s => s.preloadMiniPlayer);
useEffect(() => {
if (IS_WINDOWS || !preloadMiniPlayer) return;
invoke('preload_mini_player').catch(() => {});
}, [preloadMiniPlayer]);
useEffect(() => {
return initAudioListeners();
}, []);
useEffect(() => {
return initHotCachePrefetch();
}, []);
useEffect(() => {
useGlobalShortcutsStore.getState().registerAll();
}, []);
// ── Easter egg: Ctrl+Shift+Alt+N → export new albums image ──
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (!e.ctrlKey || !e.shiftKey || !e.altKey || e.code !== 'KeyN') return;
e.preventDefault();
setExportPickerOpen(true);
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, []);
const handleExport = async (since: number) => {
setExportPickerOpen(false);
try {
const { exportNewAlbumsImage } = await import('../utils/exportNewAlbums');
const result = await exportNewAlbumsImage(since);
if (result) {
const files = result.paths.length > 1 ? ` (${result.paths.length} Dateien)` : '';
showToast(`📸 ${result.count} Alben exportiert${files}`);
} else {
showToast('📭 Keine Alben in diesem Zeitraum gefunden');
}
} catch (err) {
showToast(`❌ Export fehlgeschlagen: ${String(err).slice(0, 80)}`);
console.error('[easter egg] export failed:', err);
}
};
useEffect(() => {
const timers = new Map<HTMLElement, ReturnType<typeof setTimeout>>();
const onScroll = (e: Event) => {
const el = e.target as HTMLElement;
el.classList.add('is-scrolling');
const existing = timers.get(el);
if (existing !== undefined) clearTimeout(existing);
timers.set(el, setTimeout(() => {
el.classList.remove('is-scrolling');
timers.delete(el);
}, 800));
};
document.addEventListener('scroll', onScroll, true);
return () => {
document.removeEventListener('scroll', onScroll, true);
timers.forEach(t => clearTimeout(t));
};
}, []);
return (
<WindowVisibilityProvider>
<BrowserRouter>
<PasteClipboardHandler />
<TauriEventBridge />
<Suspense fallback={null}>
<Routes>
<Route path="/login" element={<Login />} />
<Route
path="/*"
element={
<RequireAuth>
<DragDropProvider>
<AppShell />
</DragDropProvider>
</RequireAuth>
}
/>
</Routes>
</Suspense>
{exportPickerOpen && <ExportPickerModal onConfirm={handleExport} onClose={() => setExportPickerOpen(false)} />}
<ZipDownloadOverlay />
<FpsOverlay />
</BrowserRouter>
</WindowVisibilityProvider>
);
}