mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 07:15:47 +00:00
341 lines
15 KiB
TypeScript
341 lines
15 KiB
TypeScript
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 `<RequireAuth>` 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 (
|
|
<div
|
|
className={`app-shell ${floatingPlayerBar ? 'floating-player' : ''}`}
|
|
data-mobile={isMobile || undefined}
|
|
data-mobile-player={isMobilePlayer || undefined}
|
|
data-titlebar={showTitlebar || undefined}
|
|
data-titlebar-platform={showMacTitlebar ? 'macos' : showLinuxTitlebar ? 'linux' : undefined}
|
|
data-fullscreen={isWindowFullscreen || undefined}
|
|
style={{
|
|
'--sidebar-width': isMobile ? '0px' : (isSidebarCollapsed ? '72px' : 'clamp(200px, 15vw, 220px)'),
|
|
'--queue-width': isMobile
|
|
? '0px'
|
|
: (isQueueVisible ? `${queueWidth}px` : '0px')
|
|
} as React.CSSProperties}
|
|
onContextMenu={e => e.preventDefault()}
|
|
>
|
|
{showTitlebar && <TitleBar />}
|
|
{import.meta.env.DEV && isMobile && (
|
|
<span className="dev-build-badge" aria-hidden>DEV</span>
|
|
)}
|
|
{!isMobile && (
|
|
<Sidebar
|
|
isCollapsed={isSidebarCollapsed}
|
|
toggleCollapse={() => setSidebarCollapsed(!isSidebarCollapsed)}
|
|
/>
|
|
)}
|
|
<main className="main-content">
|
|
<div className="main-content-zoom">
|
|
<header className="content-header">
|
|
<LiveSearch />
|
|
{import.meta.env.DEV && <DevNetworkModeToggle />}
|
|
<div className="spacer" />
|
|
<ConnectionIndicator status={connStatus} isLan={isLan} serverName={serverName} />
|
|
<MusicNetworkIndicator />
|
|
<NowPlayingDropdown />
|
|
<OrbitStartTrigger />
|
|
{!isMobile && !isQueueVisible && (
|
|
<button
|
|
className="queue-toggle-btn"
|
|
onClick={toggleQueue}
|
|
data-tooltip={t('player.toggleQueue')}
|
|
data-tooltip-pos="bottom"
|
|
>
|
|
<PanelRight size={18} />
|
|
</button>
|
|
)}
|
|
</header>
|
|
<OrbitSessionBar />
|
|
{connStatus === 'disconnected' && (
|
|
<OfflineBanner onRetry={connRetry} isChecking={connRetrying} showSettingsLink={!hasOfflineBrowse} serverName={serverName} />
|
|
)}
|
|
<div className="content-body app-shell-route-host">
|
|
<OverlayScrollArea
|
|
className="app-shell-route-scroll"
|
|
viewportClassName={
|
|
mainRouteInpageScrollViewportId(location.pathname)
|
|
? 'app-shell-route-scroll__viewport app-shell-route-scroll__viewport--inpage-split'
|
|
: 'app-shell-route-scroll__viewport'
|
|
}
|
|
viewportId={APP_MAIN_SCROLL_VIEWPORT_ID}
|
|
measureDeps={[location.pathname, isQueueVisible, queueWidth, floatingPlayerBar]}
|
|
railInset="panel"
|
|
>
|
|
<Suspense fallback={null}>
|
|
{perfFlags.disableMainRouteContentMount ? (
|
|
<div style={{ minHeight: '60vh' }} />
|
|
) : (
|
|
<AppRoutes />
|
|
)}
|
|
</Suspense>
|
|
</OverlayScrollArea>
|
|
</div>
|
|
</div>
|
|
</main>
|
|
{!isMobile && (
|
|
<AppShellQueueResizerSeam
|
|
isQueueVisible={isQueueVisible}
|
|
queueWidth={queueWidth}
|
|
queueHandleTop={queueHandleTop}
|
|
isMainScrolling={isMainScrolling}
|
|
setIsDraggingQueue={setIsDraggingQueue}
|
|
handleQueueHandleMouseDown={handleQueueHandleMouseDown}
|
|
t={t}
|
|
/>
|
|
)}
|
|
{!isMobile && !perfFlags.disableQueuePanelMount && <QueuePanel />}
|
|
{isMobile && !isMobilePlayer && <BottomNav />}
|
|
{!isMobilePlayer && <PlayerBar />}
|
|
{isFullscreenOpen && (
|
|
<FullscreenPlayer onClose={toggleFullscreen} />
|
|
)}
|
|
<ContextMenu />
|
|
<SongInfoModal />
|
|
<DownloadFolderModal />
|
|
<GlobalConfirmModal />
|
|
<ThemeMigrationNotice />
|
|
<OrbitAccountPicker />
|
|
<OrbitHelpModal />
|
|
{!perfFlags.disableTooltipPortal && <TooltipPortal />}
|
|
<AppUpdater />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default AppShell;
|