diff --git a/src/app/AppShell.tsx b/src/app/AppShell.tsx index cc758e52..ab943760 100644 --- a/src/app/AppShell.tsx +++ b/src/app/AppShell.tsx @@ -1,10 +1,7 @@ -import { probeEntityRatingSupport } from '../api/subsonicStarRating'; -import { getMusicFolders } from '../api/subsonicLibrary'; -import React, { Suspense, useCallback, useEffect, useRef, useState } from 'react'; +import React, { Suspense, useCallback, useEffect, useState } from 'react'; import { useLocation, useNavigate } from 'react-router-dom'; import { invoke } from '@tauri-apps/api/core'; -import { getCurrentWindow } from '@tauri-apps/api/window'; -import { PanelRight, PanelRightClose } from 'lucide-react'; +import { PanelRight } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import Sidebar from '../components/Sidebar'; import PlayerBar from '../components/PlayerBar'; @@ -33,9 +30,19 @@ import OrbitSessionBar from '../components/OrbitSessionBar'; import OrbitStartTrigger from '../components/OrbitStartTrigger'; import { useOrbitHost } from '../hooks/useOrbitHost'; import { useOrbitGuest } from '../hooks/useOrbitGuest'; -import { cleanupOrphanedOrbitPlaylists } from '../utils/orbit'; -import { useOrbitStore } from '../store/orbitStore'; -import { IS_LINUX, IS_MACOS, IS_WINDOWS } from '../utils/platform'; +import { useOrbitBodyAttrs } from '../hooks/useOrbitBodyAttrs'; +import { usePlatformShellSetup } from '../hooks/usePlatformShellSetup'; +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 { 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'; @@ -44,63 +51,10 @@ import { useThemeStore } from '../store/themeStore'; import { useFontStore } from '../store/fontStore'; import { useEqStore } from '../store/eqStore'; import { usePerfProbeFlags } from '../utils/perfFlags'; - -const SIDEBAR_COLLAPSED_STORAGE_KEY = 'psysonic_sidebar_collapsed'; - -function readInitialSidebarCollapsed(): boolean { - if (typeof window === 'undefined') return false; - try { - return window.localStorage.getItem(SIDEBAR_COLLAPSED_STORAGE_KEY) === 'true'; - } catch { - return false; - } -} - -function persistSidebarCollapsed(collapsed: boolean): void { - if (typeof window === 'undefined') return; - try { - window.localStorage.setItem(SIDEBAR_COLLAPSED_STORAGE_KEY, String(collapsed)); - } catch { - // Ignore storage failures and keep in-memory UI state. - } -} - -/** - * Avoid grabbing the queue resizer when aiming at the main overlay scrollbar. - * Uses the real main viewport edge (not innerWidth − queueWidth — sidebar/zoom skew that). - * Only the main-route thumb counts (not queue/mini/sidebar thumbs — selector is scoped). - * - * The queue resizer is 6px and sits on the main|queue seam with ~3px overlapping the main - * column (layout.css `.resizer-queue`). Treating `clientX <= mainRight` as "main" suppressed - * that overlap and felt like a dead resize strip at certain widths. Thumb hit slop must not - * extend past `mainRight` or it steals grabs on the resizer. - */ -function shouldSuppressQueueResizerMouseDown(clientX: number, clientY: number, queueWidth: number): boolean { - const vp = document.getElementById(APP_MAIN_SCROLL_VIEWPORT_ID) as HTMLElement | null; - const mainRight = vp ? vp.getBoundingClientRect().right : window.innerWidth - queueWidth; - /** Pixels of the resizer that lie left of the main column's right edge (see `.resizer-queue`). */ - const RESIZER_BLEED_INTO_MAIN = 4; - - if (clientX <= mainRight - RESIZER_BLEED_INTO_MAIN) return true; - - const thumbs = document.querySelectorAll('.app-shell-route-scroll .overlay-scroll__thumb'); - const xSlop = 22; - const vPad = 40; - for (let i = 0; i < thumbs.length; i++) { - const thumb = thumbs[i]; - const style = window.getComputedStyle(thumb); - const pointerActive = style.pointerEvents !== 'none'; - const visible = Number.parseFloat(style.opacity || '0') > 0.01; - if (!pointerActive && !visible) continue; - - const r = thumb.getBoundingClientRect(); - if (r.height < 4 || r.width < 1) continue; - if (clientY < r.top - vPad || clientY > r.bottom + vPad) continue; - const thumbHitRight = Math.min(r.right + xSlop, mainRight); - if (clientX >= r.left - 6 && clientX <= thumbHitRight) return true; - } - return false; -} +import { + persistSidebarCollapsed, + readInitialSidebarCollapsed, +} from '../utils/appShellHelpers'; /** * The main webview's persistent layout: titlebar (Linux only) + sidebar + @@ -109,65 +63,17 @@ function shouldSuppressQueueResizerMouseDown(clientX: number, clientY: number, q * sync. Mounted under `` and shared across all routes. */ export function AppShell() { - const { t, i18n } = useTranslation(); + const { t } = useTranslation(); const isMobile = useIsMobile(); - const [isWindowFullscreen, setIsWindowFullscreen] = useState(false); - const [isTilingWm, setIsTilingWm] = useState(false); + const isWindowFullscreen = useWindowFullscreenState(); + const { isTilingWm } = usePlatformShellSetup(); // Orbit session hooks: idle until the local store marks a role. useOrbitHost(); useOrbitGuest(); - - // Body-level marker so global CSS can hide controls that don't make sense - // in an Orbit session (e.g. track preview — the preview engine and the - // shared playback would step on each other). Active for any role + any - // pre-`active` phase so the marker covers the whole join lifecycle. - const orbitRole = useOrbitStore(s => s.role); - const orbitPhase = useOrbitStore(s => s.phase); - useEffect(() => { - const inOrbit = (orbitRole === 'host' || orbitRole === 'guest') - && (orbitPhase === 'active' || orbitPhase === 'joining' || orbitPhase === 'starting'); - if (inOrbit) { - document.documentElement.setAttribute('data-orbit-active', 'true'); - // Also expose the role so CSS can target host-vs-guest UI states - // (e.g. guest seekbar is read-only — sync follows the host). - document.documentElement.setAttribute('data-orbit-role', orbitRole as string); - } else { - document.documentElement.removeAttribute('data-orbit-active'); - document.documentElement.removeAttribute('data-orbit-role'); - } - }, [orbitRole, orbitPhase]); - - useEffect(() => { - if (!IS_LINUX) return; - invoke('is_tiling_wm_cmd').then(setIsTilingWm).catch(() => {}); - }, []); - - useEffect(() => { - if (!IS_LINUX) return; - invoke('no_compositing_mode').then(noComp => { - if (noComp) document.documentElement.classList.add('no-compositing'); - }).catch(() => {}); - }, []); - - useEffect(() => { - const platform = IS_LINUX ? 'linux' : IS_MACOS ? 'macos' : IS_WINDOWS ? 'windows' : 'unknown'; - document.documentElement.setAttribute('data-platform', platform); - }, []); - - useEffect(() => { - const win = getCurrentWindow(); - // Check initial state (e.g. app launched maximised / already fullscreen). - win.isFullscreen().then(setIsWindowFullscreen).catch(() => {}); - let unlisten: (() => void) | undefined; - // onResized fires on every size change, including fullscreen enter/exit on - // all platforms. We re-query isFullscreen() rather than inferring from - // the size so the flag is always accurate regardless of platform quirks. - win.onResized(() => { - win.isFullscreen().then(setIsWindowFullscreen).catch(() => {}); - }).then(u => { unlisten = u; }); - return () => { unlisten?.(); }; - }, []); + useOrbitBodyAttrs(); + useTrayMenuI18n(); + useServerCapabilitiesProbe(); const isFullscreenOpen = usePlayerStore(s => s.isFullscreenOpen); const toggleFullscreen = usePlayerStore(s => s.toggleFullscreen); const isQueueVisible = usePlayerStore(s => s.isQueueVisible); @@ -180,13 +86,7 @@ export function AppShell() { const navigate = useNavigate(); const location = useLocation(); const serverId = useAuthStore(s => s.activeServerId ?? ''); - const isLoggedIn = useAuthStore(s => s.isLoggedIn); - const activeServerId = useAuthStore(s => s.activeServerId); - const setMusicFolders = useAuthStore(s => s.setMusicFolders); const useCustomTitlebar = useAuthStore(s => s.useCustomTitlebar); - const linuxWebkitKineticScroll = useAuthStore(s => s.linuxWebkitKineticScroll); - const loggingMode = useAuthStore(s => s.loggingMode); - const setEntityRatingSupport = useAuthStore(s => s.setEntityRatingSupport); const offlineAlbums = useOfflineStore(s => s.albums); const hasOfflineContent = Object.values(offlineAlbums).some(a => a.serverId === serverId); const floatingPlayerBar = useThemeStore(s => s.floatingPlayerBar); @@ -203,74 +103,12 @@ export function AppShell() { return () => window.removeEventListener('psy:navigate', onPsyNavigate); }, [navigate]); - // Sync custom titlebar preference with native decorations on Linux - // On tiling WMs decorations are always off (no native title bar to replace). - useEffect(() => { - if (!IS_LINUX) return; - const enabled = isTilingWm ? false : !useCustomTitlebar; - invoke('set_window_decorations', { enabled }).catch(() => {}); - }, [useCustomTitlebar, isTilingWm]); - - useEffect(() => { - if (!IS_LINUX) return; - invoke('set_linux_webkit_smooth_scrolling', { enabled: linuxWebkitKineticScroll }).catch(() => {}); - }, [linuxWebkitKineticScroll]); - - useEffect(() => { - invoke('set_logging_mode', { mode: loggingMode }).catch(() => {}); - }, [loggingMode]); - - useEffect(() => { - if (!isLoggedIn || !activeServerId) return; - const serverAtStart = activeServerId; - let cancelled = false; - (async () => { - const stillThisServer = () => !cancelled && useAuthStore.getState().activeServerId === serverAtStart; - try { - const folders = await getMusicFolders(); - if (stillThisServer()) setMusicFolders(folders); - } catch { - if (stillThisServer()) setMusicFolders([]); - } - try { - const level = await probeEntityRatingSupport(); - if (stillThisServer()) setEntityRatingSupport(serverAtStart, level); - } catch { - if (stillThisServer()) setEntityRatingSupport(serverAtStart, 'track_only'); - } - })(); - return () => { - cancelled = true; - }; - }, [isLoggedIn, activeServerId, setMusicFolders, setEntityRatingSupport]); - - // Orbit orphan sweep — delete our own leftover session / outbox playlists - // from crashed or force-closed sessions so they don't clutter the ND - // playlist view. Runs once per login; safe and best-effort. - useEffect(() => { - if (!isLoggedIn || !activeServerId) return; - void cleanupOrphanedOrbitPlaylists(); - }, [isLoggedIn, activeServerId]); - // Reset scroll position on route change (main viewport is overlay scroll) useEffect(() => { document.getElementById(APP_MAIN_SCROLL_VIEWPORT_ID)?.scrollTo({ top: 0 }); }, [location.pathname]); - // Auto-navigate to offline library when no connection but cached content exists - const prevConnStatus = useRef(connStatus); - useEffect(() => { - const prev = prevConnStatus.current; - prevConnStatus.current = connStatus; - - if (connStatus === 'disconnected' && hasOfflineContent && prev !== 'disconnected') { - navigate('/offline', { replace: true }); - } - // Return from offline page only when reconnecting (not when user navigates there manually while online) - if (connStatus === 'connected' && prev === 'disconnected' && location.pathname === '/offline') { - navigate('/', { replace: true }); - } - }, [connStatus, hasOfflineContent, location.pathname, navigate]); + useOfflineAutoNav(connStatus, hasOfflineContent, location.pathname, navigate); useEffect(() => { initializeFromServerQueue(); @@ -280,57 +118,14 @@ export function AppShell() { useEqStore.getState().syncToRust(); }, []); - useEffect(() => { - const fn = async () => { - try { - const appWindow = getCurrentWindow(); - if (currentTrack) { - const state = isPlaying ? '▶' : '⏸'; - const title = `${state} ${currentTrack.artist} - ${currentTrack.title} | Psysonic`; - document.title = title; - await appWindow.setTitle(title); - await invoke('set_tray_tooltip', { - tooltip: `${currentTrack.artist} – ${currentTrack.title}`, - playbackState: isPlaying ? 'play' : 'pause', - }).catch(() => {}); - } else { - document.title = 'Psysonic'; - await appWindow.setTitle('Psysonic'); - await invoke('set_tray_tooltip', { - tooltip: '', - playbackState: 'stop', - }).catch(() => {}); - } - } catch (err) {} - }; - fn(); - }, [currentTrack, isPlaying]); - - useEffect(() => { - const apply = () => { - invoke('set_tray_menu_labels', { - playPause: t('tray.playPause'), - next: t('tray.nextTrack'), - previous: t('tray.previousTrack'), - showHide: t('tray.showHide'), - quit: t('tray.exitPsysonic'), - nothingPlaying: t('tray.nothingPlaying'), - }).catch(() => {}); - }; - apply(); - i18n.on('languageChanged', apply); - return () => { i18n.off('languageChanged', apply); }; - }, [t, i18n]); + 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 [queueWidth, setQueueWidth] = useState(340); - const [isDraggingQueue, setIsDraggingQueue] = useState(false); - const [queueHandleTop, setQueueHandleTop] = useState(null); - const [isMainScrolling, setIsMainScrolling] = useState(false); + const isMainScrolling = useMainScrollingIndicator(location.pathname); const setSidebarCollapsed = useCallback((collapsed: boolean) => { persistSidebarCollapsed(collapsed); @@ -343,220 +138,16 @@ export function AppShell() { return () => window.removeEventListener('psy:toggle-sidebar', onToggleSidebar); }, [isSidebarCollapsed, setSidebarCollapsed]); - const handleMouseMove = useCallback((e: MouseEvent) => { - if (isDraggingQueue) { - const newWidth = Math.max(310, Math.min(window.innerWidth - e.clientX, 500)); - setQueueWidth(newWidth); - } - }, [isDraggingQueue]); + const { + queueWidth, + isDraggingQueue, + setIsDraggingQueue, + queueHandleTop, + handleQueueHandleMouseDown, + } = useQueueResizer({ isMobile, isSidebarCollapsed, isQueueVisible, toggleQueue }); - const handleMouseUp = useCallback(() => { - setIsDraggingQueue(false); - }, []); - - useEffect(() => { - if (isDraggingQueue) { - window.addEventListener('mousemove', handleMouseMove); - window.addEventListener('mouseup', handleMouseUp); - document.body.style.cursor = 'col-resize'; - document.body.classList.add('is-dragging'); - } else { - window.removeEventListener('mousemove', handleMouseMove); - window.removeEventListener('mouseup', handleMouseUp); - document.body.style.cursor = 'default'; - document.body.classList.remove('is-dragging'); - } - return () => { - window.removeEventListener('mousemove', handleMouseMove); - window.removeEventListener('mouseup', handleMouseUp); - document.body.classList.remove('is-dragging'); - }; - }, [isDraggingQueue, handleMouseMove, handleMouseUp]); - - useEffect(() => { - const viewports = new Set(); - const appViewport = document.getElementById(APP_MAIN_SCROLL_VIEWPORT_ID); - if (appViewport) viewports.add(appViewport); - const nowPlayingViewport = document.querySelector('.np-main__viewport'); - if (nowPlayingViewport) viewports.add(nowPlayingViewport); - if (viewports.size === 0) return; - - let scrollHideTimer: number | null = null; - - const onScroll = () => { - setIsMainScrolling(true); - if (scrollHideTimer != null) window.clearTimeout(scrollHideTimer); - scrollHideTimer = window.setTimeout(() => { - setIsMainScrolling(false); - scrollHideTimer = null; - }, 180); - }; - - viewports.forEach(viewport => { - viewport.addEventListener('scroll', onScroll, { passive: true }); - }); - return () => { - viewports.forEach(viewport => { - viewport.removeEventListener('scroll', onScroll); - }); - if (scrollHideTimer != null) window.clearTimeout(scrollHideTimer); - setIsMainScrolling(false); - }; - }, [location.pathname]); - - const syncQueueHandleTop = useCallback(() => { - const leftBtn = document.querySelector('.sidebar .collapse-btn') as HTMLElement | null; - if (!leftBtn) return; - const r = leftBtn.getBoundingClientRect(); - setQueueHandleTop(r.top + r.height / 2); - }, []); - - useEffect(() => { - if (isMobile) return; - const leftBtn = document.querySelector('.sidebar .collapse-btn') as HTMLElement | null; - if (!leftBtn) return; - - syncQueueHandleTop(); - const raf = requestAnimationFrame(syncQueueHandleTop); - - const onResize = () => syncQueueHandleTop(); - window.addEventListener('resize', onResize); - const observer = new ResizeObserver(onResize); - observer.observe(leftBtn); - - return () => { - cancelAnimationFrame(raf); - window.removeEventListener('resize', onResize); - observer.disconnect(); - }; - }, [isMobile, isSidebarCollapsed, syncQueueHandleTop]); - - const handleQueueHandleMouseDown = useCallback((e: React.MouseEvent) => { - if (e.button !== 0) return; - e.preventDefault(); - e.stopPropagation(); - - const DRAG_THRESHOLD_PX = 4; - const startX = e.clientX; - const startY = e.clientY; - let didDrag = false; - - const cleanup = () => { - document.removeEventListener('mousemove', onMove); - document.removeEventListener('mouseup', onUp, true); - document.body.style.cursor = ''; - document.body.classList.remove('is-dragging'); - }; - - const applyWidthFromClientX = (clientX: number) => { - const newWidth = Math.max(310, Math.min(window.innerWidth - clientX, 500)); - setQueueWidth(newWidth); - }; - - const onMove = (me: MouseEvent) => { - const movedEnough = Math.hypot(me.clientX - startX, me.clientY - startY) >= DRAG_THRESHOLD_PX; - if (!didDrag && movedEnough) { - didDrag = true; - if (!isQueueVisible) toggleQueue(); - document.body.style.cursor = 'col-resize'; - document.body.classList.add('is-dragging'); - } - if (!didDrag) return; - applyWidthFromClientX(me.clientX); - }; - - const onUp = () => { - cleanup(); - if (!didDrag) toggleQueue(); - }; - - document.addEventListener('mousemove', onMove); - document.addEventListener('mouseup', onUp, true); - }, [isQueueVisible, toggleQueue]); - - // ── Global DnD fix for Linux/WebKitGTK / Wayland ───────────────── - // dragover/dragenter: WebKitGTK needs preventDefault so external drops are not - // a permanent "forbidden" cursor. dragstart (capture): cancel native drags from - // the page (e.g. SVG grips); Wayland can otherwise leave a stuck GTK drag-proxy. - // In-app moves use psy-drag (mouse events). Harmless on Windows/macOS. - useEffect(() => { - const allow = (e: DragEvent) => { - e.preventDefault(); - if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy'; - }; - // Prevent the webview from navigating when something (e.g. a file - // from the OS file manager) is dropped on the document body. - const blockDrop = (e: DragEvent) => { e.preventDefault(); }; - - // Block Ctrl+A / Cmd+A "select all" — WebKit ignores user-select:none for keyboard shortcuts - const blockSelectAll = (e: KeyboardEvent) => { - if ((e.ctrlKey || e.metaKey) && e.key === 'a') { - const target = e.target as HTMLElement; - // Allow Ctrl+A inside actual text inputs and textareas - if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) return; - e.preventDefault(); - } - }; - - // Block mouse drag selection — WebKitGTK ignores user-select:none on * for drag selection - const blockSelectStart = (e: Event) => { - const target = e.target as HTMLElement; - if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) return; - if ((target as HTMLElement).closest('[data-selectable]')) return; - e.preventDefault(); - }; - - const blockDragStart = (e: DragEvent) => { - e.preventDefault(); - }; - - document.addEventListener('dragover', allow); - document.addEventListener('dragenter', allow); - document.addEventListener('drop', blockDrop); - document.addEventListener('dragstart', blockDragStart, true); - document.addEventListener('keydown', blockSelectAll, true); - document.addEventListener('selectstart', blockSelectStart); - - return () => { - document.removeEventListener('dragover', allow); - document.removeEventListener('dragenter', allow); - document.removeEventListener('drop', blockDrop); - document.removeEventListener('dragstart', blockDragStart, true); - document.removeEventListener('keydown', blockSelectAll, true); - document.removeEventListener('selectstart', blockSelectStart); - }; - }, []); - - // Pause CSS animations when the browser tab is hidden (`document.hidden`). - // Tauri `win.hide()` is mirrored separately via `data-psy-native-hidden` from - // Rust (see components.css). WebView2 can keep compositing without the former. - useEffect(() => { - const update = () => { - document.documentElement.dataset.appHidden = document.hidden ? 'true' : 'false'; - }; - document.addEventListener('visibilitychange', update); - update(); - return () => document.removeEventListener('visibilitychange', update); - }, []); - - // Pause cosmetic animations when the window loses OS focus but stays visible - // (alt-tab, click into another app). On low-VRAM laptops WebView2 keeps - // compositing mesh blobs / waveform / marquee at full rate even though the - // user isn't looking — measurable GPU drain reported in issue #334. - useEffect(() => { - const update = () => { - const blurred = !document.hasFocus(); - window.__psyBlurred = blurred; - document.documentElement.dataset.appBlurred = blurred ? 'true' : 'false'; - }; - window.addEventListener('focus', update); - window.addEventListener('blur', update); - update(); - return () => { - window.removeEventListener('focus', update); - window.removeEventListener('blur', update); - }; - }, []); + useGlobalDndAndSelectionBlockers(); + useAppActivityTracking(); const isMobilePlayer = isMobile && location.pathname === '/now-playing'; @@ -626,50 +217,16 @@ export function AppShell() { {!isMobile && ( -
{ - e.preventDefault(); - if (document.body.classList.contains('is-overlay-scrollbar-thumb-drag')) { - // Self-heal stale drag flag: if no thumb is actually dragging, - // unblock the queue resizer immediately. - const activeThumbDrag = document.querySelector('.overlay-scroll__thumb.is-thumb-dragging'); - if (!activeThumbDrag) { - document.body.classList.remove('is-overlay-scrollbar-thumb-drag'); - } else { - return; - } - } - if (shouldSuppressQueueResizerMouseDown(e.clientX, e.clientY, queueWidth)) return; - setIsDraggingQueue(true); - }} - style={{ - display: isQueueVisible ? 'block' : 'none', - right: `${Math.max(0, queueWidth - 3)}px`, - }} + )} - {!isMobile && isQueueVisible && ( - - )} {!isMobile && !perfFlags.disableQueuePanelMount && } {isMobile && !isMobilePlayer && } {!isMobilePlayer && } diff --git a/src/components/AppShellQueueResizerSeam.tsx b/src/components/AppShellQueueResizerSeam.tsx new file mode 100644 index 00000000..ba642c0c --- /dev/null +++ b/src/components/AppShellQueueResizerSeam.tsx @@ -0,0 +1,81 @@ +import React from 'react'; +import type { TFunction } from 'i18next'; +import { PanelRight, PanelRightClose } from 'lucide-react'; +import { shouldSuppressQueueResizerMouseDown } from '../utils/appShellHelpers'; + +interface Props { + isQueueVisible: boolean; + queueWidth: number; + queueHandleTop: number | null; + isMainScrolling: boolean; + setIsDraggingQueue: React.Dispatch>; + handleQueueHandleMouseDown: (e: React.MouseEvent) => void; + t: TFunction; +} + +/** + * The seam between the main column and the queue panel — a 6px resizer + * strip and the round resize/toggle handle floating over it. Desktop-only. + * + * The strip's mousedown is intentionally elaborate: it has to ignore + * clicks aimed at the main viewport's overlay scrollbar thumb (which sits + * inside the resizer's overlap region) and self-heal a stale + * `is-overlay-scrollbar-thumb-drag` body flag if no thumb is actually + * dragging. The handle is fixed-positioned and aligned with the sidebar's + * collapse button so the two visually pair across resizes. + */ +export function AppShellQueueResizerSeam({ + isQueueVisible, + queueWidth, + queueHandleTop, + isMainScrolling, + setIsDraggingQueue, + handleQueueHandleMouseDown, + t, +}: Props) { + return ( + <> +
{ + e.preventDefault(); + if (document.body.classList.contains('is-overlay-scrollbar-thumb-drag')) { + const activeThumbDrag = document.querySelector('.overlay-scroll__thumb.is-thumb-dragging'); + if (!activeThumbDrag) { + document.body.classList.remove('is-overlay-scrollbar-thumb-drag'); + } else { + return; + } + } + if (shouldSuppressQueueResizerMouseDown(e.clientX, e.clientY, queueWidth)) return; + setIsDraggingQueue(true); + }} + style={{ + display: isQueueVisible ? 'block' : 'none', + right: `${Math.max(0, queueWidth - 3)}px`, + }} + /> + {isQueueVisible && ( + + )} + + ); +} diff --git a/src/hooks/useAppActivityTracking.ts b/src/hooks/useAppActivityTracking.ts new file mode 100644 index 00000000..8cbc97ca --- /dev/null +++ b/src/hooks/useAppActivityTracking.ts @@ -0,0 +1,42 @@ +import { useEffect } from 'react'; + +/** + * Surface "is the app currently being looked at?" as two `` data + * attributes so global CSS can pause cosmetic animations: + * + * - `data-app-hidden` mirrors `document.hidden` (browser/tab hidden). + * Tauri `win.hide()` is mirrored separately by Rust via + * `data-psy-native-hidden`, since WebView2 may keep compositing. + * - `data-app-blurred` mirrors `document.hasFocus()` (window loses OS + * focus but stays visible — alt-tab, click into another app). On + * low-VRAM laptops WebView2 keeps compositing mesh blobs / waveform + * / marquee at full rate while unfocused — see issue #334. + * + * `window.__psyBlurred` is set as a JS-readable mirror for hot-path code + * that can't go through the DOM (e.g. RAF loops). + */ +export function useAppActivityTracking(): void { + useEffect(() => { + const update = () => { + document.documentElement.dataset.appHidden = document.hidden ? 'true' : 'false'; + }; + document.addEventListener('visibilitychange', update); + update(); + return () => document.removeEventListener('visibilitychange', update); + }, []); + + useEffect(() => { + const update = () => { + const blurred = !document.hasFocus(); + window.__psyBlurred = blurred; + document.documentElement.dataset.appBlurred = blurred ? 'true' : 'false'; + }; + window.addEventListener('focus', update); + window.addEventListener('blur', update); + update(); + return () => { + window.removeEventListener('focus', update); + window.removeEventListener('blur', update); + }; + }, []); +} diff --git a/src/hooks/useGlobalDndAndSelectionBlockers.ts b/src/hooks/useGlobalDndAndSelectionBlockers.ts new file mode 100644 index 00000000..56df62e9 --- /dev/null +++ b/src/hooks/useGlobalDndAndSelectionBlockers.ts @@ -0,0 +1,65 @@ +import { useEffect } from 'react'; + +/** + * Globally tame Linux/WebKitGTK + Wayland behaviour that the page-level + * CSS / Tauri config can't reach: + * + * - dragover/dragenter: WebKitGTK shows a permanent "forbidden" cursor + * for external drops unless we preventDefault and force dropEffect. + * - drop on document: block so an OS-file-manager drag doesn't navigate + * the webview away from the app. + * - dragstart (capture): cancel native drags from in-page elements (e.g. + * SVG grips) — on Wayland these can leave a stuck GTK drag-proxy. + * In-app moves go through psy-drag (mouse events), unaffected. + * - Ctrl/Cmd+A: WebKit ignores `user-select: none` for keyboard + * shortcuts. Still allow select-all inside real text fields. + * - selectstart: same story for mouse-drag selection. Allow inside + * inputs / textareas / contentEditable and explicit `[data-selectable]` + * regions (cover info, etc.). + * + * Harmless on Windows/macOS. + */ +export function useGlobalDndAndSelectionBlockers(): void { + useEffect(() => { + const allow = (e: DragEvent) => { + e.preventDefault(); + if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy'; + }; + const blockDrop = (e: DragEvent) => { e.preventDefault(); }; + + const blockSelectAll = (e: KeyboardEvent) => { + if ((e.ctrlKey || e.metaKey) && e.key === 'a') { + const target = e.target as HTMLElement; + if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) return; + e.preventDefault(); + } + }; + + const blockSelectStart = (e: Event) => { + const target = e.target as HTMLElement; + if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) return; + if ((target as HTMLElement).closest('[data-selectable]')) return; + e.preventDefault(); + }; + + const blockDragStart = (e: DragEvent) => { + e.preventDefault(); + }; + + document.addEventListener('dragover', allow); + document.addEventListener('dragenter', allow); + document.addEventListener('drop', blockDrop); + document.addEventListener('dragstart', blockDragStart, true); + document.addEventListener('keydown', blockSelectAll, true); + document.addEventListener('selectstart', blockSelectStart); + + return () => { + document.removeEventListener('dragover', allow); + document.removeEventListener('dragenter', allow); + document.removeEventListener('drop', blockDrop); + document.removeEventListener('dragstart', blockDragStart, true); + document.removeEventListener('keydown', blockSelectAll, true); + document.removeEventListener('selectstart', blockSelectStart); + }; + }, []); +} diff --git a/src/hooks/useMainScrollingIndicator.ts b/src/hooks/useMainScrollingIndicator.ts new file mode 100644 index 00000000..6b1b6ba7 --- /dev/null +++ b/src/hooks/useMainScrollingIndicator.ts @@ -0,0 +1,51 @@ +import { useEffect, useState } from 'react'; +import { APP_MAIN_SCROLL_VIEWPORT_ID } from '../constants/appScroll'; + +const SCROLL_IDLE_MS = 180; + +/** + * `true` while the main route viewport or the Now Playing viewport is + * actively scrolling, falling back to `false` after `SCROLL_IDLE_MS` of + * silence. Used to fade out the queue handle (and similar floating + * controls) while the user is scrolling, so they don't sit on top of the + * overlay scrollbar thumb. + * + * Re-binds on `pathname` change because Now Playing's viewport mounts + * lazily and isn't in the DOM on every route. + */ +export function useMainScrollingIndicator(pathname: string): boolean { + const [isMainScrolling, setIsMainScrolling] = useState(false); + + useEffect(() => { + const viewports = new Set(); + const appViewport = document.getElementById(APP_MAIN_SCROLL_VIEWPORT_ID); + if (appViewport) viewports.add(appViewport); + const nowPlayingViewport = document.querySelector('.np-main__viewport'); + if (nowPlayingViewport) viewports.add(nowPlayingViewport); + if (viewports.size === 0) return; + + let scrollHideTimer: number | null = null; + + const onScroll = () => { + setIsMainScrolling(true); + if (scrollHideTimer != null) window.clearTimeout(scrollHideTimer); + scrollHideTimer = window.setTimeout(() => { + setIsMainScrolling(false); + scrollHideTimer = null; + }, SCROLL_IDLE_MS); + }; + + viewports.forEach(viewport => { + viewport.addEventListener('scroll', onScroll, { passive: true }); + }); + return () => { + viewports.forEach(viewport => { + viewport.removeEventListener('scroll', onScroll); + }); + if (scrollHideTimer != null) window.clearTimeout(scrollHideTimer); + setIsMainScrolling(false); + }; + }, [pathname]); + + return isMainScrolling; +} diff --git a/src/hooks/useNowPlayingTrayTitle.ts b/src/hooks/useNowPlayingTrayTitle.ts new file mode 100644 index 00000000..eb0c2bc1 --- /dev/null +++ b/src/hooks/useNowPlayingTrayTitle.ts @@ -0,0 +1,39 @@ +import { useEffect } from 'react'; +import { invoke } from '@tauri-apps/api/core'; +import { getCurrentWindow } from '@tauri-apps/api/window'; +import type { Track } from '../store/playerStoreTypes'; + +/** + * Keep `document.title`, the OS window title, and the tray tooltip in sync + * with the currently playing track. Tray tooltip uses an en-dash separator + * (` – `) and tags playback state for the tray badge. + */ +export function useNowPlayingTrayTitle(currentTrack: Track | null, isPlaying: boolean): void { + useEffect(() => { + const fn = async () => { + try { + const appWindow = getCurrentWindow(); + if (currentTrack) { + const state = isPlaying ? '▶' : '⏸'; + const title = `${state} ${currentTrack.artist} - ${currentTrack.title} | Psysonic`; + document.title = title; + await appWindow.setTitle(title); + await invoke('set_tray_tooltip', { + tooltip: `${currentTrack.artist} – ${currentTrack.title}`, + playbackState: isPlaying ? 'play' : 'pause', + }).catch(() => {}); + } else { + document.title = 'Psysonic'; + await appWindow.setTitle('Psysonic'); + await invoke('set_tray_tooltip', { + tooltip: '', + playbackState: 'stop', + }).catch(() => {}); + } + } catch { + // Ignore Tauri IPC failures — title sync is best-effort. + } + }; + fn(); + }, [currentTrack, isPlaying]); +} diff --git a/src/hooks/useOfflineAutoNav.ts b/src/hooks/useOfflineAutoNav.ts new file mode 100644 index 00000000..2f562791 --- /dev/null +++ b/src/hooks/useOfflineAutoNav.ts @@ -0,0 +1,34 @@ +import { useEffect, useRef } from 'react'; +import type { NavigateFunction } from 'react-router-dom'; + +type ConnStatus = 'connected' | 'disconnected' | 'connecting' | 'unknown'; + +/** + * Auto-route the user between the offline library and main pages based on + * connection status: + * - Disconnect with cached content → push `/offline`. + * - Reconnect while sitting on `/offline` → push back to `/`. + * + * Only fires on transitions (not on every render). Reconnect-bounce is + * gated on `prev === 'disconnected'` so a user who navigates to `/offline` + * manually while online stays there. + */ +export function useOfflineAutoNav( + connStatus: ConnStatus | string, + hasOfflineContent: boolean, + pathname: string, + navigate: NavigateFunction, +): void { + const prevConnStatus = useRef(connStatus); + useEffect(() => { + const prev = prevConnStatus.current; + prevConnStatus.current = connStatus; + + if (connStatus === 'disconnected' && hasOfflineContent && prev !== 'disconnected') { + navigate('/offline', { replace: true }); + } + if (connStatus === 'connected' && prev === 'disconnected' && pathname === '/offline') { + navigate('/', { replace: true }); + } + }, [connStatus, hasOfflineContent, pathname, navigate]); +} diff --git a/src/hooks/useOrbitBodyAttrs.ts b/src/hooks/useOrbitBodyAttrs.ts new file mode 100644 index 00000000..af742262 --- /dev/null +++ b/src/hooks/useOrbitBodyAttrs.ts @@ -0,0 +1,25 @@ +import { useEffect } from 'react'; +import { useOrbitStore } from '../store/orbitStore'; + +/** + * Mirror the live Orbit role + phase onto `` and + * `` so global CSS can hide controls that conflict + * with an active Orbit session (e.g. track preview steps on shared + * playback) or style host-vs-guest UI states (read-only guest seekbar). + * Covers any pre-`active` phase so the marker spans the join lifecycle. + */ +export function useOrbitBodyAttrs(): void { + const orbitRole = useOrbitStore(s => s.role); + const orbitPhase = useOrbitStore(s => s.phase); + useEffect(() => { + const inOrbit = (orbitRole === 'host' || orbitRole === 'guest') + && (orbitPhase === 'active' || orbitPhase === 'joining' || orbitPhase === 'starting'); + if (inOrbit) { + document.documentElement.setAttribute('data-orbit-active', 'true'); + document.documentElement.setAttribute('data-orbit-role', orbitRole as string); + } else { + document.documentElement.removeAttribute('data-orbit-active'); + document.documentElement.removeAttribute('data-orbit-role'); + } + }, [orbitRole, orbitPhase]); +} diff --git a/src/hooks/usePlatformShellSetup.ts b/src/hooks/usePlatformShellSetup.ts new file mode 100644 index 00000000..90d30139 --- /dev/null +++ b/src/hooks/usePlatformShellSetup.ts @@ -0,0 +1,54 @@ +import { useEffect, useState } from 'react'; +import { invoke } from '@tauri-apps/api/core'; +import { useAuthStore } from '../store/authStore'; +import { IS_LINUX, IS_MACOS, IS_WINDOWS } from '../utils/platform'; + +/** + * One-shot platform + window-shell configuration. Reads tiling-WM state, + * applies platform-specific document attributes/classes, and pushes + * preference changes (custom titlebar, kinetic scroll, log level) into + * Rust as the user toggles them. Returns the live `isTilingWm` flag so + * AppShell can decide whether to mount the custom titlebar. + */ +export function usePlatformShellSetup(): { isTilingWm: boolean } { + const [isTilingWm, setIsTilingWm] = useState(false); + const useCustomTitlebar = useAuthStore(s => s.useCustomTitlebar); + const linuxWebkitKineticScroll = useAuthStore(s => s.linuxWebkitKineticScroll); + const loggingMode = useAuthStore(s => s.loggingMode); + + useEffect(() => { + if (!IS_LINUX) return; + invoke('is_tiling_wm_cmd').then(setIsTilingWm).catch(() => {}); + }, []); + + useEffect(() => { + if (!IS_LINUX) return; + invoke('no_compositing_mode').then(noComp => { + if (noComp) document.documentElement.classList.add('no-compositing'); + }).catch(() => {}); + }, []); + + useEffect(() => { + const platform = IS_LINUX ? 'linux' : IS_MACOS ? 'macos' : IS_WINDOWS ? 'windows' : 'unknown'; + document.documentElement.setAttribute('data-platform', platform); + }, []); + + // Sync custom titlebar preference with native decorations on Linux. + // On tiling WMs decorations are always off (no native title bar to replace). + useEffect(() => { + if (!IS_LINUX) return; + const enabled = isTilingWm ? false : !useCustomTitlebar; + invoke('set_window_decorations', { enabled }).catch(() => {}); + }, [useCustomTitlebar, isTilingWm]); + + useEffect(() => { + if (!IS_LINUX) return; + invoke('set_linux_webkit_smooth_scrolling', { enabled: linuxWebkitKineticScroll }).catch(() => {}); + }, [linuxWebkitKineticScroll]); + + useEffect(() => { + invoke('set_logging_mode', { mode: loggingMode }).catch(() => {}); + }, [loggingMode]); + + return { isTilingWm }; +} diff --git a/src/hooks/useQueueResizer.ts b/src/hooks/useQueueResizer.ts new file mode 100644 index 00000000..f46587d5 --- /dev/null +++ b/src/hooks/useQueueResizer.ts @@ -0,0 +1,142 @@ +import React, { useCallback, useEffect, useState } from 'react'; + +interface UseQueueResizerArgs { + isMobile: boolean; + isSidebarCollapsed: boolean; + isQueueVisible: boolean; + toggleQueue: () => void; +} + +interface UseQueueResizerResult { + queueWidth: number; + isDraggingQueue: boolean; + setIsDraggingQueue: React.Dispatch>; + queueHandleTop: number | null; + handleQueueHandleMouseDown: (e: React.MouseEvent) => void; +} + +const QUEUE_MIN_WIDTH = 310; +const QUEUE_MAX_WIDTH = 500; +const QUEUE_DEFAULT_WIDTH = 340; +const DRAG_THRESHOLD_PX = 4; + +/** + * State + handlers for the queue panel's vertical resizer: + * - `queueWidth` follows the resizer drag (clamped 310..500 px). + * - `queueHandleTop` tracks the y-center of the sidebar's collapse button + * so the queue handle visually aligns with it across resizes and + * sidebar collapse changes. + * - `handleQueueHandleMouseDown` distinguishes a click from a drag with a + * 4-pixel threshold so the handle can both toggle the queue and resize + * it without conflicting interaction modes. + */ +export function useQueueResizer({ + isMobile, + isSidebarCollapsed, + isQueueVisible, + toggleQueue, +}: UseQueueResizerArgs): UseQueueResizerResult { + const [queueWidth, setQueueWidth] = useState(QUEUE_DEFAULT_WIDTH); + const [isDraggingQueue, setIsDraggingQueue] = useState(false); + const [queueHandleTop, setQueueHandleTop] = useState(null); + + const handleMouseMove = useCallback((e: MouseEvent) => { + if (!isDraggingQueue) return; + const newWidth = Math.max(QUEUE_MIN_WIDTH, Math.min(window.innerWidth - e.clientX, QUEUE_MAX_WIDTH)); + setQueueWidth(newWidth); + }, [isDraggingQueue]); + + const handleMouseUp = useCallback(() => { + setIsDraggingQueue(false); + }, []); + + useEffect(() => { + if (isDraggingQueue) { + window.addEventListener('mousemove', handleMouseMove); + window.addEventListener('mouseup', handleMouseUp); + document.body.style.cursor = 'col-resize'; + document.body.classList.add('is-dragging'); + } else { + window.removeEventListener('mousemove', handleMouseMove); + window.removeEventListener('mouseup', handleMouseUp); + document.body.style.cursor = 'default'; + document.body.classList.remove('is-dragging'); + } + return () => { + window.removeEventListener('mousemove', handleMouseMove); + window.removeEventListener('mouseup', handleMouseUp); + document.body.classList.remove('is-dragging'); + }; + }, [isDraggingQueue, handleMouseMove, handleMouseUp]); + + const syncQueueHandleTop = useCallback(() => { + const leftBtn = document.querySelector('.sidebar .collapse-btn') as HTMLElement | null; + if (!leftBtn) return; + const r = leftBtn.getBoundingClientRect(); + setQueueHandleTop(r.top + r.height / 2); + }, []); + + useEffect(() => { + if (isMobile) return; + const leftBtn = document.querySelector('.sidebar .collapse-btn') as HTMLElement | null; + if (!leftBtn) return; + + syncQueueHandleTop(); + const raf = requestAnimationFrame(syncQueueHandleTop); + + const onResize = () => syncQueueHandleTop(); + window.addEventListener('resize', onResize); + const observer = new ResizeObserver(onResize); + observer.observe(leftBtn); + + return () => { + cancelAnimationFrame(raf); + window.removeEventListener('resize', onResize); + observer.disconnect(); + }; + }, [isMobile, isSidebarCollapsed, syncQueueHandleTop]); + + const handleQueueHandleMouseDown = useCallback((e: React.MouseEvent) => { + if (e.button !== 0) return; + e.preventDefault(); + e.stopPropagation(); + + const startX = e.clientX; + const startY = e.clientY; + let didDrag = false; + + const cleanup = () => { + document.removeEventListener('mousemove', onMove); + document.removeEventListener('mouseup', onUp, true); + document.body.style.cursor = ''; + document.body.classList.remove('is-dragging'); + }; + + const applyWidthFromClientX = (clientX: number) => { + const newWidth = Math.max(QUEUE_MIN_WIDTH, Math.min(window.innerWidth - clientX, QUEUE_MAX_WIDTH)); + setQueueWidth(newWidth); + }; + + const onMove = (me: MouseEvent) => { + const movedEnough = Math.hypot(me.clientX - startX, me.clientY - startY) >= DRAG_THRESHOLD_PX; + if (!didDrag && movedEnough) { + didDrag = true; + if (!isQueueVisible) toggleQueue(); + document.body.style.cursor = 'col-resize'; + document.body.classList.add('is-dragging'); + } + if (!didDrag) return; + applyWidthFromClientX(me.clientX); + }; + + const onUp = () => { + cleanup(); + if (!didDrag) toggleQueue(); + }; + + document.addEventListener('mousemove', onMove); + document.addEventListener('mouseup', onUp, true); + }, [isQueueVisible, toggleQueue]); + + return { queueWidth, isDraggingQueue, setIsDraggingQueue, queueHandleTop, handleQueueHandleMouseDown }; +} diff --git a/src/hooks/useServerCapabilitiesProbe.ts b/src/hooks/useServerCapabilitiesProbe.ts new file mode 100644 index 00000000..e14b2f14 --- /dev/null +++ b/src/hooks/useServerCapabilitiesProbe.ts @@ -0,0 +1,52 @@ +import { useEffect } from 'react'; +import { getMusicFolders } from '../api/subsonicLibrary'; +import { probeEntityRatingSupport } from '../api/subsonicStarRating'; +import { useAuthStore } from '../store/authStore'; +import { cleanupOrphanedOrbitPlaylists } from '../utils/orbit'; + +/** + * Per-server one-shot probe run after login: + * - Fetches the server's music folders (falls back to []). + * - Probes which entity types support star ratings (falls back to + * `track_only` for old/non-Navidrome servers). + * - Sweeps leftover Orbit session / outbox playlists from crashed or + * force-closed sessions so they don't pollute the playlist view. + * + * Each step is server-scoped — if the user switches servers mid-probe the + * stale result is dropped. + */ +export function useServerCapabilitiesProbe(): void { + const isLoggedIn = useAuthStore(s => s.isLoggedIn); + const activeServerId = useAuthStore(s => s.activeServerId); + const setMusicFolders = useAuthStore(s => s.setMusicFolders); + const setEntityRatingSupport = useAuthStore(s => s.setEntityRatingSupport); + + useEffect(() => { + if (!isLoggedIn || !activeServerId) return; + const serverAtStart = activeServerId; + let cancelled = false; + (async () => { + const stillThisServer = () => !cancelled && useAuthStore.getState().activeServerId === serverAtStart; + try { + const folders = await getMusicFolders(); + if (stillThisServer()) setMusicFolders(folders); + } catch { + if (stillThisServer()) setMusicFolders([]); + } + try { + const level = await probeEntityRatingSupport(); + if (stillThisServer()) setEntityRatingSupport(serverAtStart, level); + } catch { + if (stillThisServer()) setEntityRatingSupport(serverAtStart, 'track_only'); + } + })(); + return () => { + cancelled = true; + }; + }, [isLoggedIn, activeServerId, setMusicFolders, setEntityRatingSupport]); + + useEffect(() => { + if (!isLoggedIn || !activeServerId) return; + void cleanupOrphanedOrbitPlaylists(); + }, [isLoggedIn, activeServerId]); +} diff --git a/src/hooks/useTrayMenuI18n.ts b/src/hooks/useTrayMenuI18n.ts new file mode 100644 index 00000000..5414f728 --- /dev/null +++ b/src/hooks/useTrayMenuI18n.ts @@ -0,0 +1,27 @@ +import { useEffect } from 'react'; +import { invoke } from '@tauri-apps/api/core'; +import { useTranslation } from 'react-i18next'; + +/** + * Push the tray context menu's localized labels into Rust on mount and on + * every `languageChanged` event so the system tray follows the in-app + * language selection. + */ +export function useTrayMenuI18n(): void { + const { t, i18n } = useTranslation(); + useEffect(() => { + const apply = () => { + invoke('set_tray_menu_labels', { + playPause: t('tray.playPause'), + next: t('tray.nextTrack'), + previous: t('tray.previousTrack'), + showHide: t('tray.showHide'), + quit: t('tray.exitPsysonic'), + nothingPlaying: t('tray.nothingPlaying'), + }).catch(() => {}); + }; + apply(); + i18n.on('languageChanged', apply); + return () => { i18n.off('languageChanged', apply); }; + }, [t, i18n]); +} diff --git a/src/hooks/useWindowFullscreenState.ts b/src/hooks/useWindowFullscreenState.ts new file mode 100644 index 00000000..c08fca99 --- /dev/null +++ b/src/hooks/useWindowFullscreenState.ts @@ -0,0 +1,23 @@ +import { useEffect, useState } from 'react'; +import { getCurrentWindow } from '@tauri-apps/api/window'; + +/** + * Track the live Tauri window-fullscreen state. `onResized` fires for every + * fullscreen enter/exit transition on every platform, so we re-query + * `isFullscreen()` instead of inferring from the size (avoids platform + * quirks). Also covers the initial state for apps launched already + * fullscreen / maximized. + */ +export function useWindowFullscreenState(): boolean { + const [isWindowFullscreen, setIsWindowFullscreen] = useState(false); + useEffect(() => { + const win = getCurrentWindow(); + win.isFullscreen().then(setIsWindowFullscreen).catch(() => {}); + let unlisten: (() => void) | undefined; + win.onResized(() => { + win.isFullscreen().then(setIsWindowFullscreen).catch(() => {}); + }).then(u => { unlisten = u; }); + return () => { unlisten?.(); }; + }, []); + return isWindowFullscreen; +} diff --git a/src/utils/appShellHelpers.ts b/src/utils/appShellHelpers.ts new file mode 100644 index 00000000..956e6f5d --- /dev/null +++ b/src/utils/appShellHelpers.ts @@ -0,0 +1,58 @@ +import { APP_MAIN_SCROLL_VIEWPORT_ID } from '../constants/appScroll'; + +export const SIDEBAR_COLLAPSED_STORAGE_KEY = 'psysonic_sidebar_collapsed'; + +export function readInitialSidebarCollapsed(): boolean { + if (typeof window === 'undefined') return false; + try { + return window.localStorage.getItem(SIDEBAR_COLLAPSED_STORAGE_KEY) === 'true'; + } catch { + return false; + } +} + +export function persistSidebarCollapsed(collapsed: boolean): void { + if (typeof window === 'undefined') return; + try { + window.localStorage.setItem(SIDEBAR_COLLAPSED_STORAGE_KEY, String(collapsed)); + } catch { + // Ignore storage failures and keep in-memory UI state. + } +} + +/** + * Avoid grabbing the queue resizer when aiming at the main overlay scrollbar. + * Uses the real main viewport edge (not innerWidth − queueWidth — sidebar/zoom skew that). + * Only the main-route thumb counts (not queue/mini/sidebar thumbs — selector is scoped). + * + * The queue resizer is 6px and sits on the main|queue seam with ~3px overlapping the main + * column (layout.css `.resizer-queue`). Treating `clientX <= mainRight` as "main" suppressed + * that overlap and felt like a dead resize strip at certain widths. Thumb hit slop must not + * extend past `mainRight` or it steals grabs on the resizer. + */ +export function shouldSuppressQueueResizerMouseDown(clientX: number, clientY: number, queueWidth: number): boolean { + const vp = document.getElementById(APP_MAIN_SCROLL_VIEWPORT_ID) as HTMLElement | null; + const mainRight = vp ? vp.getBoundingClientRect().right : window.innerWidth - queueWidth; + /** Pixels of the resizer that lie left of the main column's right edge (see `.resizer-queue`). */ + const RESIZER_BLEED_INTO_MAIN = 4; + + if (clientX <= mainRight - RESIZER_BLEED_INTO_MAIN) return true; + + const thumbs = document.querySelectorAll('.app-shell-route-scroll .overlay-scroll__thumb'); + const xSlop = 22; + const vPad = 40; + for (let i = 0; i < thumbs.length; i++) { + const thumb = thumbs[i]; + const style = window.getComputedStyle(thumb); + const pointerActive = style.pointerEvents !== 'none'; + const visible = Number.parseFloat(style.opacity || '0') > 0.01; + if (!pointerActive && !visible) continue; + + const r = thumb.getBoundingClientRect(); + if (r.height < 4 || r.width < 1) continue; + if (clientY < r.top - vPad || clientY > r.bottom + vPad) continue; + const thumbHitRight = Math.min(r.right + xSlop, mainRight); + if (clientX >= r.left - 6 && clientX <= thumbHitRight) return true; + } + return false; +}