mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-22 14:35:41 +00:00
refactor(app): Phase B.2 — split App() into MiniPlayerApp + MainApp (#557)
The 186-LOC default export shrinks to a thin window-kind switch with
shared document-attribute hooks. The mini-player tree and the main-app
tree each move into their own module under src/app/.
- src/app/MiniPlayerApp.tsx (48 LOC):
DragDropProvider + MiniPlayer + cross-window storage sync
- src/app/MainApp.tsx (129 LOC):
BrowserRouter + Routes + main-only lifecycle hooks
(audio listeners, hot cache, global shortcuts, mini-player
bridge, easter egg, scrollbar auto-hide)
AppShell + RequireAuth + TauriEventBridge are now named exports from
App.tsx so MainApp can compose them; Phase C/D will extract those into
their own modules.
App.tsx: 1453 -> 1308 LOC. Behaviour-preserving.
This commit is contained in:
committed by
GitHub
parent
0cd8998dc9
commit
f09da2d2a3
@@ -42,5 +42,8 @@ src/utils/buildInfiniteQueueCandidates.ts
|
|||||||
src/app/windowKind.ts
|
src/app/windowKind.ts
|
||||||
src/app/bootstrap.ts
|
src/app/bootstrap.ts
|
||||||
|
|
||||||
|
# ── Phase B.2: mini-player webview tree (2026-05-12) ─────────────────
|
||||||
|
src/app/MiniPlayerApp.tsx
|
||||||
|
|
||||||
# ── stores (added as their tests grew past the floor) ────────────────
|
# ── stores (added as their tests grew past the floor) ────────────────
|
||||||
src/store/previewStore.ts
|
src/store/previewStore.ts
|
||||||
|
|||||||
+13
-159
@@ -1,6 +1,6 @@
|
|||||||
import React, { useEffect, useState, useCallback, useRef, lazy, Suspense } from 'react';
|
import React, { useEffect, useState, useCallback, useRef, lazy, Suspense } from 'react';
|
||||||
import { showToast } from './utils/toast';
|
import { showToast } from './utils/toast';
|
||||||
import { BrowserRouter, Routes, Route, Navigate, useNavigate, useLocation } from 'react-router-dom';
|
import { Routes, Route, Navigate, useNavigate, useLocation } from 'react-router-dom';
|
||||||
import { listen } from '@tauri-apps/api/event';
|
import { listen } from '@tauri-apps/api/event';
|
||||||
import { invoke } from '@tauri-apps/api/core';
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
import { getCurrentWindow } from '@tauri-apps/api/window';
|
import { getCurrentWindow } from '@tauri-apps/api/window';
|
||||||
@@ -11,7 +11,6 @@ import PlayerBar from './components/PlayerBar';
|
|||||||
import BottomNav from './components/BottomNav';
|
import BottomNav from './components/BottomNav';
|
||||||
import MobilePlayerView from './components/MobilePlayerView';
|
import MobilePlayerView from './components/MobilePlayerView';
|
||||||
import { useIsMobile } from './hooks/useIsMobile';
|
import { useIsMobile } from './hooks/useIsMobile';
|
||||||
import { WindowVisibilityProvider } from './hooks/useWindowVisibility';
|
|
||||||
import LiveSearch from './components/LiveSearch';
|
import LiveSearch from './components/LiveSearch';
|
||||||
import NowPlayingDropdown from './components/NowPlayingDropdown';
|
import NowPlayingDropdown from './components/NowPlayingDropdown';
|
||||||
import QueuePanel from './components/QueuePanel';
|
import QueuePanel from './components/QueuePanel';
|
||||||
@@ -28,7 +27,6 @@ const NewReleases = lazy(() => import('./pages/NewReleases'));
|
|||||||
const Favorites = lazy(() => import('./pages/Favorites'));
|
const Favorites = lazy(() => import('./pages/Favorites'));
|
||||||
const RandomMix = lazy(() => import('./pages/RandomMix'));
|
const RandomMix = lazy(() => import('./pages/RandomMix'));
|
||||||
const RandomLanding = lazy(() => import('./pages/RandomLanding'));
|
const RandomLanding = lazy(() => import('./pages/RandomLanding'));
|
||||||
const Login = lazy(() => import('./pages/Login'));
|
|
||||||
const AlbumDetail = lazy(() => import('./pages/AlbumDetail'));
|
const AlbumDetail = lazy(() => import('./pages/AlbumDetail'));
|
||||||
const MostPlayed = lazy(() => import('./pages/MostPlayed'));
|
const MostPlayed = lazy(() => import('./pages/MostPlayed'));
|
||||||
const LosslessAlbums = lazy(() => import('./pages/LosslessAlbums'));
|
const LosslessAlbums = lazy(() => import('./pages/LosslessAlbums'));
|
||||||
@@ -51,8 +49,6 @@ const FolderBrowser = lazy(() => import('./pages/FolderBrowser'));
|
|||||||
const InternetRadio = lazy(() => import('./pages/InternetRadio'));
|
const InternetRadio = lazy(() => import('./pages/InternetRadio'));
|
||||||
const Genres = lazy(() => import('./pages/Genres'));
|
const Genres = lazy(() => import('./pages/Genres'));
|
||||||
const GenreDetail = lazy(() => import('./pages/GenreDetail'));
|
const GenreDetail = lazy(() => import('./pages/GenreDetail'));
|
||||||
import MiniPlayer from './components/MiniPlayer';
|
|
||||||
import { initMiniPlayerBridgeOnMain } from './utils/miniPlayerBridge';
|
|
||||||
import FullscreenPlayer from './components/FullscreenPlayer';
|
import FullscreenPlayer from './components/FullscreenPlayer';
|
||||||
import ContextMenu from './components/ContextMenu';
|
import ContextMenu from './components/ContextMenu';
|
||||||
import SongInfoModal from './components/SongInfoModal';
|
import SongInfoModal from './components/SongInfoModal';
|
||||||
@@ -60,14 +56,12 @@ import DownloadFolderModal from './components/DownloadFolderModal';
|
|||||||
import GlobalConfirmModal from './components/GlobalConfirmModal';
|
import GlobalConfirmModal from './components/GlobalConfirmModal';
|
||||||
import OrbitAccountPicker from './components/OrbitAccountPicker';
|
import OrbitAccountPicker from './components/OrbitAccountPicker';
|
||||||
import OrbitHelpModal from './components/OrbitHelpModal';
|
import OrbitHelpModal from './components/OrbitHelpModal';
|
||||||
import { DragDropProvider } from './contexts/DragDropContext';
|
|
||||||
import TooltipPortal from './components/TooltipPortal';
|
import TooltipPortal from './components/TooltipPortal';
|
||||||
import OverlayScrollArea from './components/OverlayScrollArea';
|
import OverlayScrollArea from './components/OverlayScrollArea';
|
||||||
import { APP_MAIN_SCROLL_VIEWPORT_ID } from './constants/appScroll';
|
import { APP_MAIN_SCROLL_VIEWPORT_ID } from './constants/appScroll';
|
||||||
import ConnectionIndicator from './components/ConnectionIndicator';
|
import ConnectionIndicator from './components/ConnectionIndicator';
|
||||||
import LastfmIndicator from './components/LastfmIndicator';
|
import LastfmIndicator from './components/LastfmIndicator';
|
||||||
import OfflineBanner from './components/OfflineBanner';
|
import OfflineBanner from './components/OfflineBanner';
|
||||||
import ExportPickerModal from './components/ExportPickerModal';
|
|
||||||
import AppUpdater from './components/AppUpdater';
|
import AppUpdater from './components/AppUpdater';
|
||||||
import TitleBar from './components/TitleBar';
|
import TitleBar from './components/TitleBar';
|
||||||
import OrbitSessionBar from './components/OrbitSessionBar';
|
import OrbitSessionBar from './components/OrbitSessionBar';
|
||||||
@@ -87,7 +81,6 @@ import {
|
|||||||
search as subsonicSearch,
|
search as subsonicSearch,
|
||||||
} from './api/subsonic';
|
} from './api/subsonic';
|
||||||
import { useOfflineStore } from './store/offlineStore';
|
import { useOfflineStore } from './store/offlineStore';
|
||||||
import { initHotCachePrefetch } from './hotCachePrefetch';
|
|
||||||
import i18n from './i18n';
|
import i18n from './i18n';
|
||||||
import { switchActiveServer } from './utils/switchActiveServer';
|
import { switchActiveServer } from './utils/switchActiveServer';
|
||||||
import {
|
import {
|
||||||
@@ -108,11 +101,10 @@ import { useZipDownloadStore } from './store/zipDownloadStore';
|
|||||||
import { usePreviewStore } from './store/previewStore';
|
import { usePreviewStore } from './store/previewStore';
|
||||||
import { DEFAULT_IN_APP_BINDINGS, canRunShortcutActionInMiniWindow, executeCliPlayerCommand, executeRuntimeAction, isGlobalShortcutActionId, isShortcutAction } from './config/shortcutActions';
|
import { DEFAULT_IN_APP_BINDINGS, canRunShortcutActionInMiniWindow, executeCliPlayerCommand, executeRuntimeAction, isGlobalShortcutActionId, isShortcutAction } from './config/shortcutActions';
|
||||||
import { matchInAppShortcutAction } from './shortcuts/runtime';
|
import { matchInAppShortcutAction } from './shortcuts/runtime';
|
||||||
import ZipDownloadOverlay from './components/ZipDownloadOverlay';
|
|
||||||
import FpsOverlay from './components/FpsOverlay';
|
|
||||||
import PasteClipboardHandler from './components/PasteClipboardHandler';
|
|
||||||
import { usePerfProbeFlags } from './utils/perfFlags';
|
import { usePerfProbeFlags } from './utils/perfFlags';
|
||||||
import { getWindowKind } from './app/windowKind';
|
import { getWindowKind } from './app/windowKind';
|
||||||
|
import MiniPlayerApp from './app/MiniPlayerApp';
|
||||||
|
import MainApp from './app/MainApp';
|
||||||
|
|
||||||
const SIDEBAR_COLLAPSED_STORAGE_KEY = 'psysonic_sidebar_collapsed';
|
const SIDEBAR_COLLAPSED_STORAGE_KEY = 'psysonic_sidebar_collapsed';
|
||||||
|
|
||||||
@@ -134,7 +126,7 @@ function persistSidebarCollapsed(collapsed: boolean): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function RequireAuth({ children }: { children: React.ReactNode }) {
|
export function RequireAuth({ children }: { children: React.ReactNode }) {
|
||||||
const { isLoggedIn, servers, activeServerId } = useAuthStore();
|
const { isLoggedIn, servers, activeServerId } = useAuthStore();
|
||||||
if (!isLoggedIn || !activeServerId || servers.length === 0) return <Navigate to="/login" replace />;
|
if (!isLoggedIn || !activeServerId || servers.length === 0) return <Navigate to="/login" replace />;
|
||||||
return <>{children}</>;
|
return <>{children}</>;
|
||||||
@@ -177,7 +169,7 @@ function shouldSuppressQueueResizerMouseDown(clientX: number, clientY: number, q
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
function AppShell() {
|
export function AppShell() {
|
||||||
const { t, i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
const [isWindowFullscreen, setIsWindowFullscreen] = useState(false);
|
const [isWindowFullscreen, setIsWindowFullscreen] = useState(false);
|
||||||
@@ -791,7 +783,7 @@ function AppShell() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Media key + tray event handler
|
// Media key + tray event handler
|
||||||
function TauriEventBridge() {
|
export function TauriEventBridge() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
// ZIP download progress events from Rust
|
// ZIP download progress events from Rust
|
||||||
@@ -1267,18 +1259,15 @@ function TauriEventBridge() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
useThemeStore(s => s.theme); // keep subscription so re-render on manual change
|
// Re-subscribe so themeStore changes trigger a re-render (the value itself
|
||||||
|
// is consumed via useThemeScheduler / data-theme attribute below).
|
||||||
|
useThemeStore(s => s.theme);
|
||||||
const effectiveTheme = useThemeScheduler();
|
const effectiveTheme = useThemeScheduler();
|
||||||
const font = useFontStore(s => s.font);
|
const font = useFontStore(s => s.font);
|
||||||
const [exportPickerOpen, setExportPickerOpen] = useState(false);
|
|
||||||
const perfFlags = usePerfProbeFlags();
|
|
||||||
|
|
||||||
// Mini Player window: detected via Tauri window label. Rendered without
|
|
||||||
// router / sidebar / full audio listeners — it just listens for state + sends
|
|
||||||
// control events. Cached synchronously by `getWindowKind()` so the initial
|
|
||||||
// render picks the right tree without flicker.
|
|
||||||
const isMiniWindow = getWindowKind() === 'mini';
|
|
||||||
|
|
||||||
|
// Document-attribute hooks are shared between both window kinds — each
|
||||||
|
// webview has its own `document`, and theme / font / track-preview tokens
|
||||||
|
// are read by CSS in both trees.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
document.documentElement.setAttribute('data-theme', effectiveTheme);
|
document.documentElement.setAttribute('data-theme', effectiveTheme);
|
||||||
}, [effectiveTheme]);
|
}, [effectiveTheme]);
|
||||||
@@ -1315,140 +1304,5 @@ export default function App() {
|
|||||||
);
|
);
|
||||||
}, [trackPreviewDurationSec]);
|
}, [trackPreviewDurationSec]);
|
||||||
|
|
||||||
// Main window only: push playback state to mini window + handle control events.
|
return getWindowKind() === 'mini' ? <MiniPlayerApp /> : <MainApp />;
|
||||||
useEffect(() => {
|
|
||||||
if (isMiniWindow) return;
|
|
||||||
return initMiniPlayerBridgeOnMain();
|
|
||||||
}, [isMiniWindow]);
|
|
||||||
|
|
||||||
// Main window only: 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 (isMiniWindow || IS_WINDOWS || !preloadMiniPlayer) return;
|
|
||||||
invoke('preload_mini_player').catch(() => {});
|
|
||||||
}, [isMiniWindow, preloadMiniPlayer]);
|
|
||||||
|
|
||||||
// Mini window only: re-hydrate persisted appearance stores when the main
|
|
||||||
// window writes new values. Both webviews share localStorage (same origin),
|
|
||||||
// so the `storage` event fires here whenever main mutates a key — but
|
|
||||||
// Zustand persist only reads localStorage on initial load, hence the
|
|
||||||
// explicit rehydrate.
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isMiniWindow) return;
|
|
||||||
const onStorage = (e: StorageEvent) => {
|
|
||||||
if (!e.key) return;
|
|
||||||
if (e.key === 'psysonic_theme') useThemeStore.persist.rehydrate();
|
|
||||||
else if (e.key === 'psysonic_font') useFontStore.persist.rehydrate();
|
|
||||||
else if (e.key === 'psysonic_keybindings') useKeybindingsStore.persist.rehydrate();
|
|
||||||
else if (e.key === 'psysonic_language' && e.newValue) {
|
|
||||||
i18n.changeLanguage(e.newValue);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
window.addEventListener('storage', onStorage);
|
|
||||||
return () => window.removeEventListener('storage', onStorage);
|
|
||||||
}, [isMiniWindow]);
|
|
||||||
|
|
||||||
if (isMiniWindow) {
|
|
||||||
return (
|
|
||||||
<DragDropProvider>
|
|
||||||
<MiniPlayer />
|
|
||||||
<GlobalConfirmModal />
|
|
||||||
{!perfFlags.disableTooltipPortal && <TooltipPortal />}
|
|
||||||
<FpsOverlay />
|
|
||||||
</DragDropProvider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// UI scaling is scoped to .main-content via an inner wrapper (see <main>
|
|
||||||
// below). Sidebar, queue, player bar and (Linux) custom title bar stay 1:1
|
|
||||||
// because they live in separate grid cells. Document-level zoom is not used
|
|
||||||
// — it broke portal positioning and Tauri window measurement.
|
|
||||||
|
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,129 @@
|
|||||||
|
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 { AppShell, RequireAuth, TauriEventBridge } from '../App';
|
||||||
|
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
/**
|
||||||
|
* Smoke + behaviour test for the mini-player webview tree. The component
|
||||||
|
* itself is mostly composition; the meaningful logic is the storage-event
|
||||||
|
* cross-window state-sync handler.
|
||||||
|
*/
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
// vi.mock factories run before module-level vars are initialized; route the
|
||||||
|
// shared mocks through vi.hoisted() so the references resolve in time.
|
||||||
|
const { themeRehydrate, fontRehydrate, keybindingsRehydrate } = vi.hoisted(() => ({
|
||||||
|
themeRehydrate: vi.fn(),
|
||||||
|
fontRehydrate: vi.fn(),
|
||||||
|
keybindingsRehydrate: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../store/themeStore', () => ({
|
||||||
|
useThemeStore: { persist: { rehydrate: themeRehydrate } },
|
||||||
|
}));
|
||||||
|
vi.mock('../store/fontStore', () => ({
|
||||||
|
useFontStore: { persist: { rehydrate: fontRehydrate } },
|
||||||
|
}));
|
||||||
|
vi.mock('../store/keybindingsStore', () => ({
|
||||||
|
useKeybindingsStore: { persist: { rehydrate: keybindingsRehydrate } },
|
||||||
|
}));
|
||||||
|
vi.mock('../utils/perfFlags', () => ({
|
||||||
|
usePerfProbeFlags: () => ({ disableTooltipPortal: true }),
|
||||||
|
}));
|
||||||
|
vi.mock('../i18n', () => ({
|
||||||
|
default: { changeLanguage: vi.fn() },
|
||||||
|
}));
|
||||||
|
vi.mock('../components/MiniPlayer', () => ({ default: () => <div data-testid="mini-player" /> }));
|
||||||
|
vi.mock('../components/GlobalConfirmModal', () => ({ default: () => <div data-testid="confirm-modal" /> }));
|
||||||
|
vi.mock('../components/TooltipPortal', () => ({ default: () => <div data-testid="tooltip-portal" /> }));
|
||||||
|
vi.mock('../components/FpsOverlay', () => ({ default: () => <div data-testid="fps-overlay" /> }));
|
||||||
|
vi.mock('../contexts/DragDropContext', () => ({
|
||||||
|
DragDropProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { render, cleanup } from '@testing-library/react';
|
||||||
|
import MiniPlayerApp from './MiniPlayerApp';
|
||||||
|
import i18n from '../i18n';
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
themeRehydrate.mockClear();
|
||||||
|
fontRehydrate.mockClear();
|
||||||
|
keybindingsRehydrate.mockClear();
|
||||||
|
vi.mocked(i18n.changeLanguage).mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
function fireStorage(key: string | null, newValue: string | null = 'x'): void {
|
||||||
|
// jsdom does not synthesize cross-tab storage events from setItem on the
|
||||||
|
// same window — dispatch one manually.
|
||||||
|
window.dispatchEvent(new StorageEvent('storage', { key: key ?? '', newValue: newValue ?? '' }));
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('MiniPlayerApp', () => {
|
||||||
|
it('renders the mini-player tree (smoke)', () => {
|
||||||
|
const { getByTestId } = render(<MiniPlayerApp />);
|
||||||
|
expect(getByTestId('mini-player')).toBeTruthy();
|
||||||
|
expect(getByTestId('confirm-modal')).toBeTruthy();
|
||||||
|
expect(getByTestId('fps-overlay')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hides the tooltip portal when the perf flag disables it', () => {
|
||||||
|
const { queryByTestId } = render(<MiniPlayerApp />);
|
||||||
|
expect(queryByTestId('tooltip-portal')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('storage event handler', () => {
|
||||||
|
it('rehydrates themeStore on psysonic_theme writes', () => {
|
||||||
|
render(<MiniPlayerApp />);
|
||||||
|
fireStorage('psysonic_theme');
|
||||||
|
expect(themeRehydrate).toHaveBeenCalledTimes(1);
|
||||||
|
expect(fontRehydrate).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rehydrates fontStore on psysonic_font writes', () => {
|
||||||
|
render(<MiniPlayerApp />);
|
||||||
|
fireStorage('psysonic_font');
|
||||||
|
expect(fontRehydrate).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rehydrates keybindingsStore on psysonic_keybindings writes', () => {
|
||||||
|
render(<MiniPlayerApp />);
|
||||||
|
fireStorage('psysonic_keybindings');
|
||||||
|
expect(keybindingsRehydrate).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('switches i18n language on psysonic_language writes', () => {
|
||||||
|
render(<MiniPlayerApp />);
|
||||||
|
fireStorage('psysonic_language', 'de');
|
||||||
|
expect(i18n.changeLanguage).toHaveBeenCalledWith('de');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores psysonic_language when newValue is empty', () => {
|
||||||
|
render(<MiniPlayerApp />);
|
||||||
|
fireStorage('psysonic_language', '');
|
||||||
|
expect(i18n.changeLanguage).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores unrelated storage keys', () => {
|
||||||
|
render(<MiniPlayerApp />);
|
||||||
|
fireStorage('some-other-key');
|
||||||
|
expect(themeRehydrate).not.toHaveBeenCalled();
|
||||||
|
expect(fontRehydrate).not.toHaveBeenCalled();
|
||||||
|
expect(keybindingsRehydrate).not.toHaveBeenCalled();
|
||||||
|
expect(i18n.changeLanguage).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores storage events with no key', () => {
|
||||||
|
render(<MiniPlayerApp />);
|
||||||
|
fireStorage(null);
|
||||||
|
expect(themeRehydrate).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detaches the listener on unmount', () => {
|
||||||
|
const { unmount } = render(<MiniPlayerApp />);
|
||||||
|
unmount();
|
||||||
|
fireStorage('psysonic_theme');
|
||||||
|
expect(themeRehydrate).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
import { DragDropProvider } from '../contexts/DragDropContext';
|
||||||
|
import MiniPlayer from '../components/MiniPlayer';
|
||||||
|
import GlobalConfirmModal from '../components/GlobalConfirmModal';
|
||||||
|
import TooltipPortal from '../components/TooltipPortal';
|
||||||
|
import FpsOverlay from '../components/FpsOverlay';
|
||||||
|
import { useThemeStore } from '../store/themeStore';
|
||||||
|
import { useFontStore } from '../store/fontStore';
|
||||||
|
import { useKeybindingsStore } from '../store/keybindingsStore';
|
||||||
|
import { usePerfProbeFlags } from '../utils/perfFlags';
|
||||||
|
import i18n from '../i18n';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mini-player webview tree. Rendered in the secondary Tauri window labelled
|
||||||
|
* "mini" — no router, no sidebar, no full audio listeners. The window listens
|
||||||
|
* for state pushes from the main webview (via the storage event below) and
|
||||||
|
* sends control events back through the mini-player bridge.
|
||||||
|
*/
|
||||||
|
export default function MiniPlayerApp() {
|
||||||
|
const perfFlags = usePerfProbeFlags();
|
||||||
|
|
||||||
|
// Both webviews share localStorage (same origin), so the `storage` event
|
||||||
|
// fires in this window whenever main mutates a persisted key — but Zustand
|
||||||
|
// persist only reads localStorage on initial load, hence the explicit
|
||||||
|
// rehydrate.
|
||||||
|
useEffect(() => {
|
||||||
|
const onStorage = (e: StorageEvent) => {
|
||||||
|
if (!e.key) return;
|
||||||
|
if (e.key === 'psysonic_theme') useThemeStore.persist.rehydrate();
|
||||||
|
else if (e.key === 'psysonic_font') useFontStore.persist.rehydrate();
|
||||||
|
else if (e.key === 'psysonic_keybindings') useKeybindingsStore.persist.rehydrate();
|
||||||
|
else if (e.key === 'psysonic_language' && e.newValue) {
|
||||||
|
i18n.changeLanguage(e.newValue);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener('storage', onStorage);
|
||||||
|
return () => window.removeEventListener('storage', onStorage);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DragDropProvider>
|
||||||
|
<MiniPlayer />
|
||||||
|
<GlobalConfirmModal />
|
||||||
|
{!perfFlags.disableTooltipPortal && <TooltipPortal />}
|
||||||
|
<FpsOverlay />
|
||||||
|
</DragDropProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user