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 && }
+
+
+ );
+}