mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 23:05:46 +00:00
refactor(app-shell): I.1 — split AppShell.tsx 691 → 248 LOC across 12 files (#671)
* refactor(app-shell): extract appShellHelpers.ts
Move SIDEBAR_COLLAPSED_STORAGE_KEY + read/persist helpers and the
shouldSuppressQueueResizerMouseDown geometry helper out of AppShell.tsx
into utils/appShellHelpers.ts.
AppShell.tsx: 691 → 639 LOC.
* refactor(app-shell): extract usePlatformShellSetup hook
Bundle the one-shot platform/window-shell effects (tiling-WM detection,
no-compositing class, data-platform attr, custom titlebar sync, kinetic
scroll toggle, logging mode push) into hooks/usePlatformShellSetup.ts.
Returns isTilingWm so AppShell can still gate the custom titlebar.
AppShell.tsx: 639 → 604 LOC.
* refactor(app-shell): extract 5 lifecycle hooks
Pull these effect islands into hooks/:
- useOrbitBodyAttrs — orbit role/phase → <html data-orbit-*> attrs
- useWindowFullscreenState — Tauri isFullscreen() tracker
- useNowPlayingTrayTitle — title + tray tooltip sync
- useTrayMenuI18n — tray menu labels via i18n
- useServerCapabilitiesProbe — music folders + rating support + orbit
orphan sweep on login
AppShell.tsx: 604 → 497 LOC.
* refactor(app-shell): extract useQueueResizer hook
Bundle queueWidth state, drag listeners, sidebar-aligned handle position,
and the click-vs-drag mousedown handler into hooks/useQueueResizer.ts.
AppShell drops shouldSuppressQueueResizerMouseDown wiring and 4 local
state pieces.
AppShell.tsx: 497 → 403 LOC.
* refactor(app-shell): extract 4 misc lifecycle hooks
- useGlobalDndAndSelectionBlockers — document-level DnD/select-all/
selectstart blockers (Linux/WebKitGTK + Wayland workarounds).
- useAppActivityTracking — <html data-app-hidden> + <html data-app-blurred>
so CSS can pause cosmetic animation when the app isn't being looked at.
- useMainScrollingIndicator — scroll-idle tracker for main + np viewports.
- useOfflineAutoNav — connStatus transitions push to /offline or back.
AppShell.tsx: 403 → 282 LOC.
* refactor(app-shell): extract AppShellQueueResizerSeam subcomponent
The 6px resizer strip and the round resize/toggle handle (~50 LOC of JSX
with embedded scrollbar-collision suppression + self-heal logic) move
into components/AppShellQueueResizerSeam.tsx. Desktop-only.
AppShell.tsx: 282 → 248 LOC.
This commit is contained in:
committed by
GitHub
parent
988806e6b1
commit
b591a1cb5f
+45
-488
@@ -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<HTMLElement>('.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 `<RequireAuth>` 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<boolean>('is_tiling_wm_cmd').then(setIsTilingWm).catch(() => {});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!IS_LINUX) return;
|
||||
invoke<boolean>('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<number | null>(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<HTMLElement>();
|
||||
const appViewport = document.getElementById(APP_MAIN_SCROLL_VIEWPORT_ID);
|
||||
if (appViewport) viewports.add(appViewport);
|
||||
const nowPlayingViewport = document.querySelector<HTMLElement>('.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<HTMLButtonElement>) => {
|
||||
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() {
|
||||
</div>
|
||||
</main>
|
||||
{!isMobile && (
|
||||
<div
|
||||
className="resizer resizer-queue"
|
||||
onMouseDown={(e) => {
|
||||
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`,
|
||||
}}
|
||||
<AppShellQueueResizerSeam
|
||||
isQueueVisible={isQueueVisible}
|
||||
queueWidth={queueWidth}
|
||||
queueHandleTop={queueHandleTop}
|
||||
isMainScrolling={isMainScrolling}
|
||||
setIsDraggingQueue={setIsDraggingQueue}
|
||||
handleQueueHandleMouseDown={handleQueueHandleMouseDown}
|
||||
t={t}
|
||||
/>
|
||||
)}
|
||||
{!isMobile && isQueueVisible && (
|
||||
<button
|
||||
type="button"
|
||||
className="resizer-queue-handle"
|
||||
onMouseDown={handleQueueHandleMouseDown}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: queueHandleTop != null ? `${queueHandleTop}px` : '50%',
|
||||
right: `${Math.max(0, queueWidth - 11)}px`,
|
||||
transform: 'translateY(-50%)',
|
||||
zIndex: 101,
|
||||
opacity: isMainScrolling ? 0 : 1,
|
||||
pointerEvents: isMainScrolling ? 'none' : 'auto',
|
||||
}}
|
||||
data-tooltip={t('player.collapseQueueResize')}
|
||||
data-tooltip-pos="left"
|
||||
aria-label={t('player.collapseQueueResize')}
|
||||
>
|
||||
{isQueueVisible ? <PanelRightClose size={14} /> : <PanelRight size={14} />}
|
||||
</button>
|
||||
)}
|
||||
{!isMobile && !perfFlags.disableQueuePanelMount && <QueuePanel />}
|
||||
{isMobile && !isMobilePlayer && <BottomNav />}
|
||||
{!isMobilePlayer && <PlayerBar />}
|
||||
|
||||
@@ -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<React.SetStateAction<boolean>>;
|
||||
handleQueueHandleMouseDown: (e: React.MouseEvent<HTMLButtonElement>) => 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 (
|
||||
<>
|
||||
<div
|
||||
className="resizer resizer-queue"
|
||||
onMouseDown={(e) => {
|
||||
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 && (
|
||||
<button
|
||||
type="button"
|
||||
className="resizer-queue-handle"
|
||||
onMouseDown={handleQueueHandleMouseDown}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: queueHandleTop != null ? `${queueHandleTop}px` : '50%',
|
||||
right: `${Math.max(0, queueWidth - 11)}px`,
|
||||
transform: 'translateY(-50%)',
|
||||
zIndex: 101,
|
||||
opacity: isMainScrolling ? 0 : 1,
|
||||
pointerEvents: isMainScrolling ? 'none' : 'auto',
|
||||
}}
|
||||
data-tooltip={t('player.collapseQueueResize')}
|
||||
data-tooltip-pos="left"
|
||||
aria-label={t('player.collapseQueueResize')}
|
||||
>
|
||||
{isQueueVisible ? <PanelRightClose size={14} /> : <PanelRight size={14} />}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
/**
|
||||
* Surface "is the app currently being looked at?" as two `<html>` 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);
|
||||
};
|
||||
}, []);
|
||||
}
|
||||
@@ -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);
|
||||
};
|
||||
}, []);
|
||||
}
|
||||
@@ -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<HTMLElement>();
|
||||
const appViewport = document.getElementById(APP_MAIN_SCROLL_VIEWPORT_ID);
|
||||
if (appViewport) viewports.add(appViewport);
|
||||
const nowPlayingViewport = document.querySelector<HTMLElement>('.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;
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useOrbitStore } from '../store/orbitStore';
|
||||
|
||||
/**
|
||||
* Mirror the live Orbit role + phase onto `<html data-orbit-active>` and
|
||||
* `<html data-orbit-role>` 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]);
|
||||
}
|
||||
@@ -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<boolean>('is_tiling_wm_cmd').then(setIsTilingWm).catch(() => {});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!IS_LINUX) return;
|
||||
invoke<boolean>('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 };
|
||||
}
|
||||
@@ -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<React.SetStateAction<boolean>>;
|
||||
queueHandleTop: number | null;
|
||||
handleQueueHandleMouseDown: (e: React.MouseEvent<HTMLButtonElement>) => 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<number | null>(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<HTMLButtonElement>) => {
|
||||
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 };
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<HTMLElement>('.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;
|
||||
}
|
||||
Reference in New Issue
Block a user