From f09da2d2a33971610c2c61175a25764407ba2cbb Mon Sep 17 00:00:00 2001 From: Frank Stellmacher <171614930+Psychotoxical@users.noreply.github.com> Date: Tue, 12 May 2026 02:08:39 +0200 Subject: [PATCH] =?UTF-8?q?refactor(app):=20Phase=20B.2=20=E2=80=94=20spli?= =?UTF-8?q?t=20App()=20into=20MiniPlayerApp=20+=20MainApp=20(#557)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .github/frontend-hot-path-files.txt | 3 + src/App.tsx | 172 +++------------------------- src/app/MainApp.tsx | 129 +++++++++++++++++++++ src/app/MiniPlayerApp.test.tsx | 127 ++++++++++++++++++++ src/app/MiniPlayerApp.tsx | 48 ++++++++ 5 files changed, 320 insertions(+), 159 deletions(-) create mode 100644 src/app/MainApp.tsx create mode 100644 src/app/MiniPlayerApp.test.tsx create mode 100644 src/app/MiniPlayerApp.tsx diff --git a/.github/frontend-hot-path-files.txt b/.github/frontend-hot-path-files.txt index 0613bd21..c3bddd43 100644 --- a/.github/frontend-hot-path-files.txt +++ b/.github/frontend-hot-path-files.txt @@ -42,5 +42,8 @@ src/utils/buildInfiniteQueueCandidates.ts src/app/windowKind.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) ──────────────── src/store/previewStore.ts diff --git a/src/App.tsx b/src/App.tsx index 5bec5016..5c462d6d 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,6 +1,6 @@ import React, { useEffect, useState, useCallback, useRef, lazy, Suspense } from 'react'; 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 { invoke } from '@tauri-apps/api/core'; import { getCurrentWindow } from '@tauri-apps/api/window'; @@ -11,7 +11,6 @@ import PlayerBar from './components/PlayerBar'; import BottomNav from './components/BottomNav'; import MobilePlayerView from './components/MobilePlayerView'; import { useIsMobile } from './hooks/useIsMobile'; -import { WindowVisibilityProvider } from './hooks/useWindowVisibility'; import LiveSearch from './components/LiveSearch'; import NowPlayingDropdown from './components/NowPlayingDropdown'; import QueuePanel from './components/QueuePanel'; @@ -28,7 +27,6 @@ const NewReleases = lazy(() => import('./pages/NewReleases')); const Favorites = lazy(() => import('./pages/Favorites')); const RandomMix = lazy(() => import('./pages/RandomMix')); const RandomLanding = lazy(() => import('./pages/RandomLanding')); -const Login = lazy(() => import('./pages/Login')); const AlbumDetail = lazy(() => import('./pages/AlbumDetail')); const MostPlayed = lazy(() => import('./pages/MostPlayed')); const LosslessAlbums = lazy(() => import('./pages/LosslessAlbums')); @@ -51,8 +49,6 @@ const FolderBrowser = lazy(() => import('./pages/FolderBrowser')); const InternetRadio = lazy(() => import('./pages/InternetRadio')); const Genres = lazy(() => import('./pages/Genres')); const GenreDetail = lazy(() => import('./pages/GenreDetail')); -import MiniPlayer from './components/MiniPlayer'; -import { initMiniPlayerBridgeOnMain } from './utils/miniPlayerBridge'; import FullscreenPlayer from './components/FullscreenPlayer'; import ContextMenu from './components/ContextMenu'; import SongInfoModal from './components/SongInfoModal'; @@ -60,14 +56,12 @@ import DownloadFolderModal from './components/DownloadFolderModal'; import GlobalConfirmModal from './components/GlobalConfirmModal'; import OrbitAccountPicker from './components/OrbitAccountPicker'; import OrbitHelpModal from './components/OrbitHelpModal'; -import { DragDropProvider } from './contexts/DragDropContext'; import TooltipPortal from './components/TooltipPortal'; import OverlayScrollArea from './components/OverlayScrollArea'; import { APP_MAIN_SCROLL_VIEWPORT_ID } from './constants/appScroll'; import ConnectionIndicator from './components/ConnectionIndicator'; import LastfmIndicator from './components/LastfmIndicator'; import OfflineBanner from './components/OfflineBanner'; -import ExportPickerModal from './components/ExportPickerModal'; import AppUpdater from './components/AppUpdater'; import TitleBar from './components/TitleBar'; import OrbitSessionBar from './components/OrbitSessionBar'; @@ -87,7 +81,6 @@ import { search as subsonicSearch, } from './api/subsonic'; import { useOfflineStore } from './store/offlineStore'; -import { initHotCachePrefetch } from './hotCachePrefetch'; import i18n from './i18n'; import { switchActiveServer } from './utils/switchActiveServer'; import { @@ -108,11 +101,10 @@ import { useZipDownloadStore } from './store/zipDownloadStore'; import { usePreviewStore } from './store/previewStore'; import { DEFAULT_IN_APP_BINDINGS, canRunShortcutActionInMiniWindow, executeCliPlayerCommand, executeRuntimeAction, isGlobalShortcutActionId, isShortcutAction } from './config/shortcutActions'; 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 { getWindowKind } from './app/windowKind'; +import MiniPlayerApp from './app/MiniPlayerApp'; +import MainApp from './app/MainApp'; 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(); if (!isLoggedIn || !activeServerId || servers.length === 0) return ; return <>{children}; @@ -177,7 +169,7 @@ function shouldSuppressQueueResizerMouseDown(clientX: number, clientY: number, q return false; } -function AppShell() { +export function AppShell() { const { t, i18n } = useTranslation(); const isMobile = useIsMobile(); const [isWindowFullscreen, setIsWindowFullscreen] = useState(false); @@ -791,7 +783,7 @@ function AppShell() { } // Media key + tray event handler -function TauriEventBridge() { +export function TauriEventBridge() { const navigate = useNavigate(); // ZIP download progress events from Rust @@ -1267,18 +1259,15 @@ function TauriEventBridge() { } 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 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(() => { document.documentElement.setAttribute('data-theme', effectiveTheme); }, [effectiveTheme]); @@ -1315,140 +1304,5 @@ export default function App() { ); }, [trackPreviewDurationSec]); - // Main window only: push playback state to mini window + handle control events. - 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 ( - - - - {!perfFlags.disableTooltipPortal && } - - - ); - } - - // UI scaling is scoped to .main-content via an inner wrapper (see
- // 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>(); - 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 ( - - - - - - - } /> - - - - - - } - /> - - - {exportPickerOpen && setExportPickerOpen(false)} />} - - - - - ); + return getWindowKind() === 'mini' ? : ; } diff --git a/src/app/MainApp.tsx b/src/app/MainApp.tsx new file mode 100644 index 00000000..7c785d24 --- /dev/null +++ b/src/app/MainApp.tsx @@ -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>(); + 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 ( + + + + + + + } /> + + + + + + } + /> + + + {exportPickerOpen && setExportPickerOpen(false)} />} + + + + + ); +} diff --git a/src/app/MiniPlayerApp.test.tsx b/src/app/MiniPlayerApp.test.tsx new file mode 100644 index 00000000..aeb30ef0 --- /dev/null +++ b/src/app/MiniPlayerApp.test.tsx @@ -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: () =>
})); +vi.mock('../components/GlobalConfirmModal', () => ({ default: () =>
})); +vi.mock('../components/TooltipPortal', () => ({ default: () =>
})); +vi.mock('../components/FpsOverlay', () => ({ default: () =>
})); +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(); + 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(); + expect(queryByTestId('tooltip-portal')).toBeNull(); + }); + + describe('storage event handler', () => { + it('rehydrates themeStore on psysonic_theme writes', () => { + render(); + fireStorage('psysonic_theme'); + expect(themeRehydrate).toHaveBeenCalledTimes(1); + expect(fontRehydrate).not.toHaveBeenCalled(); + }); + + it('rehydrates fontStore on psysonic_font writes', () => { + render(); + fireStorage('psysonic_font'); + expect(fontRehydrate).toHaveBeenCalledTimes(1); + }); + + it('rehydrates keybindingsStore on psysonic_keybindings writes', () => { + render(); + fireStorage('psysonic_keybindings'); + expect(keybindingsRehydrate).toHaveBeenCalledTimes(1); + }); + + it('switches i18n language on psysonic_language writes', () => { + render(); + fireStorage('psysonic_language', 'de'); + expect(i18n.changeLanguage).toHaveBeenCalledWith('de'); + }); + + it('ignores psysonic_language when newValue is empty', () => { + render(); + fireStorage('psysonic_language', ''); + expect(i18n.changeLanguage).not.toHaveBeenCalled(); + }); + + it('ignores unrelated storage keys', () => { + render(); + 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(); + fireStorage(null); + expect(themeRehydrate).not.toHaveBeenCalled(); + }); + + it('detaches the listener on unmount', () => { + const { unmount } = render(); + unmount(); + fireStorage('psysonic_theme'); + expect(themeRehydrate).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/src/app/MiniPlayerApp.tsx b/src/app/MiniPlayerApp.tsx new file mode 100644 index 00000000..8f10c75f --- /dev/null +++ b/src/app/MiniPlayerApp.tsx @@ -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 ( + + + + {!perfFlags.disableTooltipPortal && } + + + ); +}