mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 23:05:46 +00:00
refactor(search): extract LiveSearch header-collapse logic into a hook
This commit is contained in:
@@ -34,6 +34,7 @@ import {
|
|||||||
LiveSearchSongThumb,
|
LiveSearchSongThumb,
|
||||||
LiveSearchArtistThumb,
|
LiveSearchArtistThumb,
|
||||||
} from '@/features/search/components/liveSearchResultThumbs';
|
} from '@/features/search/components/liveSearchResultThumbs';
|
||||||
|
import { useLiveSearchHeaderCollapse } from '@/features/search/hooks/useLiveSearchHeaderCollapse';
|
||||||
import { showToast } from '@/lib/dom/toast';
|
import { showToast } from '@/lib/dom/toast';
|
||||||
import { useShareSearch } from '@/features/search/hooks/useShareSearch';
|
import { useShareSearch } from '@/features/search/hooks/useShareSearch';
|
||||||
import ShareSearchResults from '@/features/search/components/ShareSearchResults';
|
import ShareSearchResults from '@/features/search/components/ShareSearchResults';
|
||||||
@@ -72,7 +73,6 @@ export default function LiveSearch() {
|
|||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [activeIndex, setActiveIndex] = useState(-1);
|
const [activeIndex, setActiveIndex] = useState(-1);
|
||||||
const [isFocused, setIsFocused] = useState(false);
|
const [isFocused, setIsFocused] = useState(false);
|
||||||
const [isCollapsed, setIsCollapsed] = useState(false);
|
|
||||||
const [searchSource, setSearchSource] = useState<LiveSearchSource | null>(null);
|
const [searchSource, setSearchSource] = useState<LiveSearchSource | null>(null);
|
||||||
const localReadyRef = useRef(false);
|
const localReadyRef = useRef(false);
|
||||||
const liveSearchGenRef = useRef(0);
|
const liveSearchGenRef = useRef(0);
|
||||||
@@ -86,8 +86,7 @@ export default function LiveSearch() {
|
|||||||
const ref = useRef<HTMLDivElement>(null);
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
const collapsedRef = useRef(false);
|
const isCollapsed = useLiveSearchHeaderCollapse(ref);
|
||||||
const compactHeaderControlsRef = useRef(false);
|
|
||||||
const serverId = useAuthStore(s => s.activeServerId);
|
const serverId = useAuthStore(s => s.activeServerId);
|
||||||
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
|
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
|
||||||
const indexEnabled = useLibraryIndexStore(s => s.isIndexEnabled(serverId));
|
const indexEnabled = useLibraryIndexStore(s => s.isIndexEnabled(serverId));
|
||||||
@@ -353,92 +352,6 @@ export default function LiveSearch() {
|
|||||||
};
|
};
|
||||||
}, [isCollapsed, isSearchActive]);
|
}, [isCollapsed, isSearchActive]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const root = ref.current;
|
|
||||||
if (!root) return;
|
|
||||||
const header = root.closest('.content-header') as HTMLElement | null;
|
|
||||||
if (!header) return;
|
|
||||||
const spacer = header.querySelector('.spacer') as HTMLElement | null;
|
|
||||||
if (!spacer) return;
|
|
||||||
|
|
||||||
const MIN_EXPANDED_WIDTH = 260;
|
|
||||||
const SPACER_RESERVE = 24;
|
|
||||||
const HYSTERESIS_PX = 20;
|
|
||||||
// Live/Orbit compact-mode is intentionally stickier than search collapse,
|
|
||||||
// otherwise both systems can feed each other and oscillate.
|
|
||||||
const HEADER_CONTROLS_COMPACT_ON_SPACER = 36;
|
|
||||||
const HEADER_CONTROLS_COMPACT_OFF_SPACER = 108;
|
|
||||||
const SWITCH_COOLDOWN_MS = 180;
|
|
||||||
const collapseThreshold = MIN_EXPANDED_WIDTH + SPACER_RESERVE;
|
|
||||||
const expandThreshold = collapseThreshold + HYSTERESIS_PX;
|
|
||||||
let lastSwitchAt = 0;
|
|
||||||
let cooldownTimer: number | null = null;
|
|
||||||
|
|
||||||
const updateCollapsed = () => {
|
|
||||||
const searchWidth = root.getBoundingClientRect().width;
|
|
||||||
const spacerWidth = spacer.getBoundingClientRect().width;
|
|
||||||
const budget = searchWidth + spacerWidth;
|
|
||||||
const headerOverflowing = header.scrollWidth - header.clientWidth > 1;
|
|
||||||
let nextCollapsed = collapsedRef.current
|
|
||||||
? budget < expandThreshold
|
|
||||||
: budget < collapseThreshold;
|
|
||||||
// Priority rule: if we are already compacting Live/Orbit labels, search
|
|
||||||
// must stay collapsed until compact mode can be released.
|
|
||||||
if (compactHeaderControlsRef.current) {
|
|
||||||
nextCollapsed = true;
|
|
||||||
}
|
|
||||||
if (nextCollapsed !== collapsedRef.current) {
|
|
||||||
const now = performance.now();
|
|
||||||
const remaining = SWITCH_COOLDOWN_MS - (now - lastSwitchAt);
|
|
||||||
if (remaining > 0) {
|
|
||||||
if (cooldownTimer == null) {
|
|
||||||
cooldownTimer = window.setTimeout(() => {
|
|
||||||
cooldownTimer = null;
|
|
||||||
updateCollapsed();
|
|
||||||
}, remaining);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
lastSwitchAt = now;
|
|
||||||
collapsedRef.current = nextCollapsed;
|
|
||||||
setIsCollapsed(nextCollapsed);
|
|
||||||
}
|
|
||||||
|
|
||||||
const nextCompactControls = nextCollapsed
|
|
||||||
? (
|
|
||||||
compactHeaderControlsRef.current
|
|
||||||
// Stay compact until we clearly have room and no overflow.
|
|
||||||
? (headerOverflowing || spacerWidth < HEADER_CONTROLS_COMPACT_OFF_SPACER)
|
|
||||||
// Enter compact only when both tight spacer and real overflow exist.
|
|
||||||
: (headerOverflowing && spacerWidth < HEADER_CONTROLS_COMPACT_ON_SPACER)
|
|
||||||
)
|
|
||||||
: false;
|
|
||||||
if (nextCompactControls !== compactHeaderControlsRef.current) {
|
|
||||||
compactHeaderControlsRef.current = nextCompactControls;
|
|
||||||
if (nextCompactControls) {
|
|
||||||
header.dataset.liveHeaderCompact = 'true';
|
|
||||||
} else {
|
|
||||||
delete header.dataset.liveHeaderCompact;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
updateCollapsed();
|
|
||||||
const ro = new ResizeObserver(updateCollapsed);
|
|
||||||
ro.observe(header);
|
|
||||||
ro.observe(spacer);
|
|
||||||
ro.observe(root);
|
|
||||||
window.addEventListener('resize', updateCollapsed);
|
|
||||||
return () => {
|
|
||||||
ro.disconnect();
|
|
||||||
window.removeEventListener('resize', updateCollapsed);
|
|
||||||
delete header.dataset.liveHeaderCompact;
|
|
||||||
if (cooldownTimer != null) {
|
|
||||||
window.clearTimeout(cooldownTimer);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Close on click outside — but stay open while a song context menu is up.
|
// Close on click outside — but stay open while a song context menu is up.
|
||||||
// The CM renders a fullscreen transparent backdrop (z-index 998) above the
|
// The CM renders a fullscreen transparent backdrop (z-index 998) above the
|
||||||
// dropdown, so any mousedown — including a second right-click on another
|
// dropdown, so any mousedown — including a second right-click on another
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import { useState, useRef, useEffect, type RefObject } from 'react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collapses the live-search field into an icon-overlay when the header runs out
|
||||||
|
* of horizontal room, with hysteresis and a stickier compact mode for the
|
||||||
|
* Live/Orbit header controls (the two must not feed each other and oscillate).
|
||||||
|
* Returns the collapsed flag for the field render + overlay state.
|
||||||
|
*/
|
||||||
|
export function useLiveSearchHeaderCollapse(rootRef: RefObject<HTMLDivElement | null>): boolean {
|
||||||
|
const [isCollapsed, setIsCollapsed] = useState(false);
|
||||||
|
const collapsedRef = useRef(false);
|
||||||
|
const compactHeaderControlsRef = useRef(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const root = rootRef.current;
|
||||||
|
if (!root) return;
|
||||||
|
const header = root.closest('.content-header') as HTMLElement | null;
|
||||||
|
if (!header) return;
|
||||||
|
const spacer = header.querySelector('.spacer') as HTMLElement | null;
|
||||||
|
if (!spacer) return;
|
||||||
|
|
||||||
|
const MIN_EXPANDED_WIDTH = 260;
|
||||||
|
const SPACER_RESERVE = 24;
|
||||||
|
const HYSTERESIS_PX = 20;
|
||||||
|
// Live/Orbit compact-mode is intentionally stickier than search collapse,
|
||||||
|
// otherwise both systems can feed each other and oscillate.
|
||||||
|
const HEADER_CONTROLS_COMPACT_ON_SPACER = 36;
|
||||||
|
const HEADER_CONTROLS_COMPACT_OFF_SPACER = 108;
|
||||||
|
const SWITCH_COOLDOWN_MS = 180;
|
||||||
|
const collapseThreshold = MIN_EXPANDED_WIDTH + SPACER_RESERVE;
|
||||||
|
const expandThreshold = collapseThreshold + HYSTERESIS_PX;
|
||||||
|
let lastSwitchAt = 0;
|
||||||
|
let cooldownTimer: number | null = null;
|
||||||
|
|
||||||
|
const updateCollapsed = () => {
|
||||||
|
const searchWidth = root.getBoundingClientRect().width;
|
||||||
|
const spacerWidth = spacer.getBoundingClientRect().width;
|
||||||
|
const budget = searchWidth + spacerWidth;
|
||||||
|
const headerOverflowing = header.scrollWidth - header.clientWidth > 1;
|
||||||
|
let nextCollapsed = collapsedRef.current
|
||||||
|
? budget < expandThreshold
|
||||||
|
: budget < collapseThreshold;
|
||||||
|
// Priority rule: if we are already compacting Live/Orbit labels, search
|
||||||
|
// must stay collapsed until compact mode can be released.
|
||||||
|
if (compactHeaderControlsRef.current) {
|
||||||
|
nextCollapsed = true;
|
||||||
|
}
|
||||||
|
if (nextCollapsed !== collapsedRef.current) {
|
||||||
|
const now = performance.now();
|
||||||
|
const remaining = SWITCH_COOLDOWN_MS - (now - lastSwitchAt);
|
||||||
|
if (remaining > 0) {
|
||||||
|
if (cooldownTimer == null) {
|
||||||
|
cooldownTimer = window.setTimeout(() => {
|
||||||
|
cooldownTimer = null;
|
||||||
|
updateCollapsed();
|
||||||
|
}, remaining);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
lastSwitchAt = now;
|
||||||
|
collapsedRef.current = nextCollapsed;
|
||||||
|
setIsCollapsed(nextCollapsed);
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextCompactControls = nextCollapsed
|
||||||
|
? (
|
||||||
|
compactHeaderControlsRef.current
|
||||||
|
// Stay compact until we clearly have room and no overflow.
|
||||||
|
? (headerOverflowing || spacerWidth < HEADER_CONTROLS_COMPACT_OFF_SPACER)
|
||||||
|
// Enter compact only when both tight spacer and real overflow exist.
|
||||||
|
: (headerOverflowing && spacerWidth < HEADER_CONTROLS_COMPACT_ON_SPACER)
|
||||||
|
)
|
||||||
|
: false;
|
||||||
|
if (nextCompactControls !== compactHeaderControlsRef.current) {
|
||||||
|
compactHeaderControlsRef.current = nextCompactControls;
|
||||||
|
if (nextCompactControls) {
|
||||||
|
header.dataset.liveHeaderCompact = 'true';
|
||||||
|
} else {
|
||||||
|
delete header.dataset.liveHeaderCompact;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
updateCollapsed();
|
||||||
|
const ro = new ResizeObserver(updateCollapsed);
|
||||||
|
ro.observe(header);
|
||||||
|
ro.observe(spacer);
|
||||||
|
ro.observe(root);
|
||||||
|
window.addEventListener('resize', updateCollapsed);
|
||||||
|
return () => {
|
||||||
|
ro.disconnect();
|
||||||
|
window.removeEventListener('resize', updateCollapsed);
|
||||||
|
delete header.dataset.liveHeaderCompact;
|
||||||
|
if (cooldownTimer != null) {
|
||||||
|
window.clearTimeout(cooldownTimer);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [rootRef]);
|
||||||
|
|
||||||
|
return isCollapsed;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user