import React, { Suspense, useCallback, useEffect, useRef, useState } from 'react'; import { useLocation, useNavigate } from 'react-router-dom'; import { ensurePlaybackServerActive } from '../utils/playback/playbackServer'; import { navigatePathWithAlbumReturnTo, shouldSkipMainScrollResetOnRouteChange } from '../utils/navigation/albumDetailNavigation'; import { getCurrentWebview } from '@tauri-apps/api/webview'; import { PanelRight } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import Sidebar from '@/features/sidebar'; import PlayerBar from '../components/PlayerBar'; import BottomNav from '../components/BottomNav'; import { useIsMobile } from '../hooks/useIsMobile'; import { LiveSearch } from '@/features/search'; import DevNetworkModeToggle from '../components/DevNetworkModeToggle'; import { NowPlayingDropdown } from '@/features/nowPlaying'; import QueuePanel from '../components/QueuePanel'; import AppRoutes from './AppRoutes'; import FullscreenPlayer from '@/features/fullscreenPlayer'; import ContextMenu from '../components/ContextMenu'; import SongInfoModal from '../components/SongInfoModal'; import { DownloadFolderModal } from '@/features/offline'; import GlobalConfirmModal from '../components/GlobalConfirmModal'; import ThemeMigrationNotice from '../components/ThemeMigrationNotice'; import { OrbitAccountPicker, OrbitHelpModal } from '@/features/orbit'; import TooltipPortal from '@/ui/TooltipPortal'; import OverlayScrollArea from '@/ui/OverlayScrollArea'; import { APP_MAIN_SCROLL_VIEWPORT_ID, mainRouteInpageScrollViewportId, } from '../constants/appScroll'; import ConnectionIndicator from '../components/ConnectionIndicator'; import MusicNetworkIndicator from '../components/MusicNetworkIndicator'; import { OfflineBanner } from '@/features/offline'; import AppUpdater from '../components/AppUpdater'; import TitleBar from '../components/TitleBar'; import { OrbitSessionBar, OrbitStartTrigger } from '@/features/orbit'; import { useOrbitHost } from '@/features/orbit'; import { useOrbitGuest } from '@/features/orbit'; import { useOrbitBodyAttrs } from '@/features/orbit'; import { usePlatformShellSetup } from '../hooks/usePlatformShellSetup'; import { useOfflineBrowseContext } from '@/features/offline'; import { offlineBrowseNavFlags } from '@/features/offline'; import { useWindowFullscreenState } from '../hooks/useWindowFullscreenState'; import { useNowPlayingTrayTitle } from '../hooks/useNowPlayingTrayTitle'; import { usePrefetchReleaseNotes } from '../hooks/usePrefetchReleaseNotes'; import { useTrayMenuI18n } from '../hooks/useTrayMenuI18n'; import { useServerCapabilitiesProbe } from '../hooks/useServerCapabilitiesProbe'; import { useQueueResizer } from '../hooks/useQueueResizer'; import { useGlobalDndAndSelectionBlockers } from '../hooks/useGlobalDndAndSelectionBlockers'; import { useAppActivityTracking } from '../hooks/useAppActivityTracking'; import { useMainScrollingIndicator } from '../hooks/useMainScrollingIndicator'; import { useCoverNavigationPriority } from '../hooks/useCoverNavigationPriority'; import { useLiveSearchRouteScope } from '@/features/search'; import { useNowPlayingPrewarm } from '@/features/nowPlaying'; import { useOfflineAutoNav } from '@/features/offline'; import { useOfflineLibraryFilterSuspend } from '@/features/offline'; import { AppShellQueueResizerSeam } from '../components/AppShellQueueResizerSeam'; import { IS_LINUX, IS_MACOS } from '../utils/platform'; import { useConnectionStatus } from '../hooks/useConnectionStatus'; import { useIdlePlayQueuePull } from '../hooks/useIdlePlayQueuePull'; import { useAuthStore } from '../store/authStore'; import { usePlayerStore } from '../store/playerStore'; import '../store/previewPlayerVolumeSync'; import '../store/queueResolverBridge'; import { useThemeStore } from '../store/themeStore'; import { useFontStore } from '../store/fontStore'; import { useEqStore } from '../store/eqStore'; import { usePlaybackRateStore } from '../store/playbackRateStore'; import { usePlaybackRateOrbitSync } from '@/features/orbit'; import { usePerfProbeFlags } from '../utils/perf/perfFlags'; import { persistSidebarCollapsed, readInitialSidebarCollapsed, } from '../utils/componentHelpers/appShellHelpers'; /** * The main webview's persistent layout: titlebar (Linux + macOS) + sidebar + * main content area (header + route host + offline banner) + queue panel + * player bar + fullscreen overlay + global modals + tray-tooltip / title * sync. Mounted under `` and shared across all routes. */ export function AppShell() { const { t } = useTranslation(); const isMobile = useIsMobile(); const isWindowFullscreen = useWindowFullscreenState(); const { isTilingWm } = usePlatformShellSetup(); // Orbit session hooks: idle until the local store marks a role. useOrbitHost(); useOrbitGuest(); useOrbitBodyAttrs(); usePlaybackRateOrbitSync(); useTrayMenuI18n(); useServerCapabilitiesProbe(); const isFullscreenOpen = usePlayerStore(s => s.isFullscreenOpen); const toggleFullscreen = usePlayerStore(s => s.toggleFullscreen); const isQueueVisible = usePlayerStore(s => s.isQueueVisible); const toggleQueue = usePlayerStore(s => s.toggleQueue); const uiScale = useFontStore(s => s.uiScale); const initializeFromServerQueue = usePlayerStore(s => s.initializeFromServerQueue); const currentTrack = usePlayerStore(s => s.currentTrack); const isPlaying = usePlayerStore(s => s.isPlaying); const { status: connStatus, isRetrying: connRetrying, retry: connRetry, isLan, serverName } = useConnectionStatus(); useIdlePlayQueuePull(connStatus); const navigate = useNavigate(); const location = useLocation(); const prevPathnameRef = useRef(location.pathname); useCoverNavigationPriority(); useLiveSearchRouteScope(); useNowPlayingPrewarm(); const useCustomTitlebar = useAuthStore(s => s.useCustomTitlebar); const offlineCtx = useOfflineBrowseContext(); const offlineNav = offlineBrowseNavFlags(offlineCtx.capabilities); const hasOfflineBrowse = offlineCtx.hasBrowseCapability; const floatingPlayerBar = useThemeStore(s => s.floatingPlayerBar); const perfFlags = usePerfProbeFlags(); // Mini player → main: route requests dispatched as `psy:navigate` // CustomEvents from the bridge land here so React Router can take over. useEffect(() => { const onPsyNavigate = (e: Event) => { const detail = (e as CustomEvent).detail; if (!detail?.to) return; void ensurePlaybackServerActive().then(ok => { if (ok) navigatePathWithAlbumReturnTo(navigate, location, detail.to); }); }; window.addEventListener('psy:navigate', onPsyNavigate); return () => window.removeEventListener('psy:navigate', onPsyNavigate); }, [navigate, location]); // Reset scroll on route change only — not when the same path gets a new location.state // (Advanced Search strips `advancedSearchRestore` after applying saved scroll). useEffect(() => { const pathnameChanged = prevPathnameRef.current !== location.pathname; prevPathnameRef.current = location.pathname; if (!pathnameChanged) return; if (shouldSkipMainScrollResetOnRouteChange(location.pathname, location.state)) return; document.getElementById(APP_MAIN_SCROLL_VIEWPORT_ID)?.scrollTo({ top: 0 }); }, [location.pathname, location.state]); useOfflineAutoNav(connStatus, offlineNav, location, navigate); useOfflineLibraryFilterSuspend(); useEffect(() => { initializeFromServerQueue(); }, [initializeFromServerQueue]); useEffect(() => { useEqStore.getState().syncToRust(); usePlaybackRateStore.getState().syncToRust(); }, []); useEffect(() => { getCurrentWebview().setZoom(uiScale).catch(() => { /* setZoom may fail on platforms where the capability is unavailable; fall back silently so the rest of the shell still renders. */ }); }, [uiScale]); useNowPlayingTrayTitle(currentTrack, isPlaying); // Post-update changelog is now surfaced via a dismissible banner in the // sidebar (WhatsNewBanner) that links to the /whats-new page — no auto // modal takeover on startup. usePrefetchReleaseNotes(); const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(readInitialSidebarCollapsed); const isMainScrolling = useMainScrollingIndicator(location.pathname); const setSidebarCollapsed = useCallback((collapsed: boolean) => { persistSidebarCollapsed(collapsed); setIsSidebarCollapsed(collapsed); }, []); useEffect(() => { const onToggleSidebar = () => setSidebarCollapsed(!isSidebarCollapsed); window.addEventListener('psy:toggle-sidebar', onToggleSidebar); return () => window.removeEventListener('psy:toggle-sidebar', onToggleSidebar); }, [isSidebarCollapsed, setSidebarCollapsed]); // Expose sidebar state on the theme root so themes can react with a // `[data-theme='x'][data-sidebar-collapsed='true']` compound (contract // `stateSelectors`). Other state attributes are set in App.tsx. useEffect(() => { document.documentElement.setAttribute('data-sidebar-collapsed', isSidebarCollapsed ? 'true' : 'false'); }, [isSidebarCollapsed]); // Workaround for WebKitGTK 2.50.x text-input repaint hang on // Linux Mint / Ubuntu 24.04 (issues #342, #782). When opted in, // nudge WebKit awake on every input/textarea focus via a sync // reflow read plus a one-frame translateZ(0) toggle on the input's // parent so the rendering pipeline re-evaluates the layer tree. // Side-effect: search magnifier flickers briefly on focus. const linuxWebkitInputForceRepaint = useAuthStore(s => s.linuxWebkitInputForceRepaint); useEffect(() => { if (!linuxWebkitInputForceRepaint) return; const handler = (e: FocusEvent) => { const target = e.target; if (!(target instanceof HTMLElement)) return; const tag = target.tagName; if (tag !== 'INPUT' && tag !== 'TEXTAREA') return; const layerHost = (target.parentElement as HTMLElement | null) ?? target; void layerHost.offsetHeight; const previous = layerHost.style.transform; layerHost.style.transform = 'translateZ(0)'; requestAnimationFrame(() => { layerHost.style.transform = previous; }); }; document.addEventListener('focusin', handler, true); return () => document.removeEventListener('focusin', handler, true); }, [linuxWebkitInputForceRepaint]); const { queueWidth, setIsDraggingQueue, queueHandleTop, handleQueueHandleMouseDown, } = useQueueResizer({ isMobile, isSidebarCollapsed, isQueueVisible, toggleQueue }); useGlobalDndAndSelectionBlockers(); useAppActivityTracking(); const isMobilePlayer = isMobile && location.pathname === '/now-playing'; // Custom in-page titlebar. Linux: opt-in, native decorations off. macOS: // always on — `titleBarStyle: Overlay` lets the webview reach the top edge // with the native traffic lights floating over our themed bar, so the bar // follows the active theme instead of the grey system titlebar (#1198). // Hidden in native fullscreen (the OS chrome is gone there anyway). const showLinuxTitlebar = IS_LINUX && useCustomTitlebar && !isWindowFullscreen && !isTilingWm; const showMacTitlebar = IS_MACOS && !isWindowFullscreen; const showTitlebar = showLinuxTitlebar || showMacTitlebar; return (
e.preventDefault()} > {showTitlebar && } {import.meta.env.DEV && isMobile && ( DEV )} {!isMobile && ( setSidebarCollapsed(!isSidebarCollapsed)} /> )}
{import.meta.env.DEV && }
{!isMobile && !isQueueVisible && ( )}
{connStatus === 'disconnected' && ( )}
{perfFlags.disableMainRouteContentMount ? (
) : ( )}
{!isMobile && ( )} {!isMobile && !perfFlags.disableQueuePanelMount && } {isMobile && !isMobilePlayer && } {!isMobilePlayer && } {isFullscreenOpen && ( )} {!perfFlags.disableTooltipPortal && }
); } export default AppShell;