Files
Psychotoxical-psysonic/src/app/AppShell.tsx
T
cucadmuh d3e5a6b704 feat: library browse navigation — restore filters, scroll, and search on back (#936)
* feat(albums): restore scroll position when returning from album detail

Save in-page scroll and grid depth when opening an album from All Albums,
then on browser back restore filters, preload enough rows, and apply scroll
before revealing the grid to avoid a visible jump from the top.

* feat(albums): smart back navigation and restore browse session on return

Remember the originating route when opening album detail, restore All Albums
filters/scroll on back (including explicit returnTo navigation), hide the grid
until scroll is applied, and fix filters being cleared after albumBrowseRestore
state is stripped from the location.

* feat(search): restore Advanced Search session when returning from album

Stash filters and results when leaving /search/advanced for album detail,
then restore them on back navigation (POP or returnTo with advancedSearchRestore).

* feat(search): restore Advanced Search album row scroll on return from album

Save horizontal scrollLeft when opening an album from Advanced Search and
reapply it via AlbumRow on return; keep main viewport at top. Add snapshot
helpers and session stash fields; extend AlbumRow with restoreScrollLeft.

* feat(search): restore Advanced Search session scroll and artist return path

Save filters, main scroll, and album-row scroll when leaving to album or
artist; restore without flash via hidden-until-ready. Add useNavigateToArtist,
restoreMainViewportScroll helper, and AppShell scroll reset only on pathname change.

* feat(search): speed up Advanced Search back restore and year-only queries

Reveal the page right after sync scroll instead of blocking on full viewport
and album-row restore. Retry local index without the ready gate during sync;
use open-ended byYear params on network fallback, matching All Albums browse.

* feat(search): restore Advanced Search artist row scroll on back

Save leave snapshot when opening artist from ArtistCardLocal, persist
artistRowScrollLeft in session stash, and keep row restore targets in refs
so horizontal scroll survives finishLeaveRestoreUi like vertical scrollTop.

* feat(nav): route mouse back on album/artist detail like UI back

Trap history popstate when returnTo is set and call navigateAlbumDetailBack
so browser/mouse back restores browse/search session the same way as the header button.

* feat(artists): restore browse filters and scroll on back from artist detail

Persist Artists page filters, view settings, and vertical scroll when opening
an artist and returning via UI or mouse back, matching All Albums behavior.

* feat(search): unify quick and advanced search; fix LiveSearch dismiss on Enter

Serve /search and /search/advanced from one page with shared session restore
and scroll snapshot. Reset live search overlay state when navigating to full
search so the dropdown does not linger or reopen.

* feat(tracks): unify with search session and restore scroll on back

Route /tracks through AdvancedSearch with shared leave snapshot, song
browse stash, and main-viewport scroll restore when returning from album
or artist detail. Wait for hero/rails layout before applying scroll.

* refactor(search): rename AdvancedSearch page to SearchBrowsePage

The shared route shell serves /search, /search/advanced, and /tracks;
rename the page component and refresh stale file references in comments.

* feat(albums): restore New Releases and Random Albums on back from detail

Unify album grid leave-restore with surface-scoped session stash, live scroll
snapshot sync, and in-page scroll for Random Albums. Keep the same random
batch when returning from album detail; Refresh fetches anew and scrolls up.

* docs: add CHANGELOG and credits for PR #936
2026-06-01 03:11:27 +03:00

315 lines
14 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 { invoke } from '@tauri-apps/api/core';
import { getCurrentWebview } from '@tauri-apps/api/webview';
import { PanelRight } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import Sidebar from '../components/Sidebar';
import PlayerBar from '../components/PlayerBar';
import BottomNav from '../components/BottomNav';
import { useIsMobile } from '../hooks/useIsMobile';
import LiveSearch from '../components/LiveSearch';
import NowPlayingDropdown from '../components/NowPlayingDropdown';
import QueuePanel from '../components/QueuePanel';
import AppRoutes from './AppRoutes';
import FullscreenPlayer from '../components/FullscreenPlayer';
import ContextMenu from '../components/ContextMenu';
import SongInfoModal from '../components/SongInfoModal';
import DownloadFolderModal from '../components/DownloadFolderModal';
import GlobalConfirmModal from '../components/GlobalConfirmModal';
import OrbitAccountPicker from '../components/OrbitAccountPicker';
import OrbitHelpModal from '../components/OrbitHelpModal';
import TooltipPortal from '../components/TooltipPortal';
import OverlayScrollArea from '../components/OverlayScrollArea';
import {
APP_MAIN_SCROLL_VIEWPORT_ID,
mainRouteInpageScrollViewportId,
} from '../constants/appScroll';
import ConnectionIndicator from '../components/ConnectionIndicator';
import LastfmIndicator from '../components/LastfmIndicator';
import OfflineBanner from '../components/OfflineBanner';
import AppUpdater from '../components/AppUpdater';
import TitleBar from '../components/TitleBar';
import OrbitSessionBar from '../components/OrbitSessionBar';
import OrbitStartTrigger from '../components/OrbitStartTrigger';
import { useOrbitHost } from '../hooks/useOrbitHost';
import { useOrbitGuest } from '../hooks/useOrbitGuest';
import { useOrbitBodyAttrs } from '../hooks/useOrbitBodyAttrs';
import { usePlatformShellSetup } from '../hooks/usePlatformShellSetup';
import { hasAnyOfflineAlbums } from '../utils/offline/offlineLibraryHelpers';
import { useWindowFullscreenState } from '../hooks/useWindowFullscreenState';
import { useNowPlayingTrayTitle } from '../hooks/useNowPlayingTrayTitle';
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 { useNowPlayingPrewarm } from '../hooks/useNowPlayingPrewarm';
import { useOfflineAutoNav } from '../hooks/useOfflineAutoNav';
import { AppShellQueueResizerSeam } from '../components/AppShellQueueResizerSeam';
import { IS_LINUX } from '../utils/platform';
import { useConnectionStatus } from '../hooks/useConnectionStatus';
import { useAuthStore } from '../store/authStore';
import { useOfflineStore } from '../store/offlineStore';
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 '../hooks/usePlaybackRateOrbitSync';
import { usePerfProbeFlags } from '../utils/perf/perfFlags';
import {
persistSidebarCollapsed,
readInitialSidebarCollapsed,
} from '../utils/componentHelpers/appShellHelpers';
/**
* The main webview's persistent layout: titlebar (Linux only) + 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();
const navigate = useNavigate();
const location = useLocation();
const prevPathnameRef = useRef(location.pathname);
useCoverNavigationPriority();
useNowPlayingPrewarm();
const useCustomTitlebar = useAuthStore(s => s.useCustomTitlebar);
const offlineAlbums = useOfflineStore(s => s.albums);
const hasOfflineContent = hasAnyOfflineAlbums(offlineAlbums);
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, hasOfflineContent, location.pathname, navigate);
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.
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]);
// 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,
isDraggingQueue,
setIsDraggingQueue,
queueHandleTop,
handleQueueHandleMouseDown,
} = useQueueResizer({ isMobile, isSidebarCollapsed, isQueueVisible, toggleQueue });
useGlobalDndAndSelectionBlockers();
useAppActivityTracking();
const isMobilePlayer = isMobile && location.pathname === '/now-playing';
return (
<div
className={`app-shell ${floatingPlayerBar ? 'floating-player' : ''}`}
data-mobile={isMobile || undefined}
data-mobile-player={isMobilePlayer || undefined}
data-titlebar={(IS_LINUX && useCustomTitlebar && !isWindowFullscreen && !isTilingWm) || 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()}
>
{IS_LINUX && useCustomTitlebar && !isWindowFullscreen && !isTilingWm && <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 />
<div className="spacer" />
<ConnectionIndicator status={connStatus} isLan={isLan} serverName={serverName} />
<LastfmIndicator />
<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={!hasOfflineContent} 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 />
<OrbitAccountPicker />
<OrbitHelpModal />
{!perfFlags.disableTooltipPortal && <TooltipPortal />}
<AppUpdater />
</div>
);
}
export default AppShell;