mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 23:05:46 +00:00
refactor(playback,playlist): move player-bar overflow/popover/delay + bulk-picker hooks into their features
This commit is contained in:
@@ -8,7 +8,7 @@ import FavoritesSongsTracklist from '@/features/favorites/components/FavoritesSo
|
||||
import { useFavoritesData } from '@/features/favorites/hooks/useFavoritesData';
|
||||
import { useFavoritesSongFiltering } from '@/features/favorites/hooks/useFavoritesSongFiltering';
|
||||
import { useFavoritesSelection } from '@/features/favorites/hooks/useFavoritesSelection';
|
||||
import { useBulkPlPickerOutsideClick } from '@/hooks/useBulkPlPickerOutsideClick';
|
||||
import { useBulkPlPickerOutsideClick } from '@/features/playlist/hooks/useBulkPlPickerOutsideClick';
|
||||
import { AlbumRow } from '@/features/album';
|
||||
import { ArtistRow } from '@/features/artist';
|
||||
import { usePlayerStore } from '@/features/playback/store/playerStore';
|
||||
|
||||
@@ -2,7 +2,7 @@ import React, { memo, useRef } from 'react';
|
||||
import { Moon, Pause, Play, Sunrise } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { usePlayerStore } from '@/features/playback/store/playerStore';
|
||||
import { usePlaybackDelayPress } from '@/hooks/usePlaybackDelayPress';
|
||||
import { usePlaybackDelayPress } from '@/features/playback/hooks/usePlaybackDelayPress';
|
||||
import { usePlaybackScheduleRemaining } from '@/utils/format/playbackScheduleFormat';
|
||||
import PlaybackDelayModal from '@/features/playback/components/PlaybackDelayModal';
|
||||
import PlaybackScheduleBadge from '@/features/playback/components/PlaybackScheduleBadge';
|
||||
|
||||
@@ -23,7 +23,7 @@ import {
|
||||
subscribeQueueResolver,
|
||||
} from '@/features/playback/store/queueTrackResolver';
|
||||
import { LyricsPane } from '@/features/lyrics';
|
||||
import { usePlaybackDelayPress } from '@/hooks/usePlaybackDelayPress';
|
||||
import { usePlaybackDelayPress } from '@/features/playback/hooks/usePlaybackDelayPress';
|
||||
import PlaybackDelayModal from '@/features/playback/components/PlaybackDelayModal';
|
||||
import PlaybackScheduleBadge from '@/features/playback/components/PlaybackScheduleBadge';
|
||||
import { usePlaybackScheduleRemaining } from '@/utils/format/playbackScheduleFormat';
|
||||
|
||||
@@ -17,7 +17,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import { usePlaybackLibraryNavigate } from '@/features/playback/hooks/usePlaybackLibraryNavigate';
|
||||
import { useRadioMetadata } from '@/features/radio';
|
||||
import { useRadioMprisSync } from '@/features/radio';
|
||||
import { usePlaybackDelayPress } from '@/hooks/usePlaybackDelayPress';
|
||||
import { usePlaybackDelayPress } from '@/features/playback/hooks/usePlaybackDelayPress';
|
||||
import PlaybackDelayModal from '@/features/playback/components/PlaybackDelayModal';
|
||||
import { usePlaybackScheduleRemaining } from '@/utils/format/playbackScheduleFormat';
|
||||
import { usePreviewStore } from '@/features/playback/store/previewStore';
|
||||
@@ -31,7 +31,7 @@ import { PlayerPlaybackRate } from '@/features/playback/components/playerBar/Pla
|
||||
import { PlayerVolume } from '@/features/playback/components/playerBar/PlayerVolume';
|
||||
import { PlayerOverflowMenu } from '@/features/playback/components/playerBar/PlayerOverflowMenu';
|
||||
import { useFloatingPlayerBar } from '@/features/playback/hooks/useFloatingPlayerBar';
|
||||
import { useUtilityOverflowMenu } from '@/hooks/useUtilityOverflowMenu';
|
||||
import { useUtilityOverflowMenu } from '@/features/playback/hooks/useUtilityOverflowMenu';
|
||||
import {
|
||||
usePlayerBarLayoutStore,
|
||||
type PlayerBarLayoutItemId,
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
varispeedSpeedFromSemitones,
|
||||
} from '@/features/playback/utils/audio/playbackRateHelpers';
|
||||
import { isOrbitPlaybackSyncActive } from '@/features/orbit';
|
||||
import { usePlayerBarAnchoredPopover } from '@/hooks/usePlayerBarAnchoredPopover';
|
||||
import { usePlayerBarAnchoredPopover } from '@/features/playback/hooks/usePlayerBarAnchoredPopover';
|
||||
|
||||
const POPOVER_WIDTH = 320;
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import type { PlayerState } from '@/features/playback/store/playerStoreTypes';
|
||||
import { useAutodjTransitionUi } from '@/features/playback/store/autodjTransitionUi';
|
||||
import { usePreviewStore } from '@/features/playback/store/previewStore';
|
||||
import PlaybackScheduleBadge from '@/features/playback/components/PlaybackScheduleBadge';
|
||||
import { usePlaybackDelayPress } from '@/hooks/usePlaybackDelayPress';
|
||||
import { usePlaybackDelayPress } from '@/features/playback/hooks/usePlaybackDelayPress';
|
||||
import { usePlaybackScheduleRemaining } from '@/utils/format/playbackScheduleFormat';
|
||||
|
||||
type RepeatMode = PlayerState['repeatMode'];
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
const LONG_PRESS_MS = 550;
|
||||
const MOVE_CANCEL_PX = 10;
|
||||
|
||||
/**
|
||||
* Long-press on play/pause opens the delay modal; short click toggles playback.
|
||||
*/
|
||||
export function usePlaybackDelayPress(togglePlay: () => void) {
|
||||
const [delayModalOpen, setDelayModalOpen] = useState(false);
|
||||
const ignoreNextClickRef = useRef(false);
|
||||
const timerRef = useRef<number | null>(null);
|
||||
const startRef = useRef({ x: 0, y: 0 });
|
||||
|
||||
// Long-press sets ignoreNextClickRef so the synthetic click after opening does not toggle play.
|
||||
// Closing the modal from chips / Apply / overlay never hits this button's onClick, so clear the flag here.
|
||||
useEffect(() => {
|
||||
if (!delayModalOpen) ignoreNextClickRef.current = false;
|
||||
}, [delayModalOpen]);
|
||||
|
||||
const clearTimer = useCallback(() => {
|
||||
if (timerRef.current != null) {
|
||||
window.clearTimeout(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const onPointerDown = useCallback(
|
||||
(e: React.PointerEvent) => {
|
||||
if (e.pointerType === 'mouse' && e.button !== 0) return;
|
||||
startRef.current = { x: e.clientX, y: e.clientY };
|
||||
clearTimer();
|
||||
timerRef.current = window.setTimeout(() => {
|
||||
timerRef.current = null;
|
||||
ignoreNextClickRef.current = true;
|
||||
setDelayModalOpen(true);
|
||||
}, LONG_PRESS_MS) as unknown as number;
|
||||
},
|
||||
[clearTimer],
|
||||
);
|
||||
|
||||
const onPointerMove = useCallback(
|
||||
(e: React.PointerEvent) => {
|
||||
if (timerRef.current == null) return;
|
||||
if (
|
||||
Math.hypot(e.clientX - startRef.current.x, e.clientY - startRef.current.y) > MOVE_CANCEL_PX
|
||||
) {
|
||||
clearTimer();
|
||||
}
|
||||
},
|
||||
[clearTimer],
|
||||
);
|
||||
|
||||
const endPointer = useCallback(() => {
|
||||
clearTimer();
|
||||
}, [clearTimer]);
|
||||
|
||||
const onClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
if (ignoreNextClickRef.current) {
|
||||
ignoreNextClickRef.current = false;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
return;
|
||||
}
|
||||
togglePlay();
|
||||
},
|
||||
[togglePlay],
|
||||
);
|
||||
|
||||
const playPauseBind = {
|
||||
onPointerDown,
|
||||
onPointerMove,
|
||||
onPointerUp: endPointer,
|
||||
onPointerLeave: endPointer,
|
||||
onPointerCancel: endPointer,
|
||||
onClick,
|
||||
};
|
||||
|
||||
return { delayModalOpen, setDelayModalOpen, playPauseBind };
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||
|
||||
/** Fixed popover anchored above a player-bar trigger (overflow menu / speed btn). */
|
||||
export function usePlayerBarAnchoredPopover(width: number, zIndex = 10050) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [popStyle, setPopStyle] = useState<React.CSSProperties>({});
|
||||
const btnRef = useRef<HTMLButtonElement>(null);
|
||||
const popRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const updatePopStyle = useCallback(() => {
|
||||
const btn = btnRef.current;
|
||||
if (!btn) return;
|
||||
const MARGIN = 8;
|
||||
const r = btn.getBoundingClientRect();
|
||||
const left = Math.min(
|
||||
Math.max(r.right - width, MARGIN),
|
||||
window.innerWidth - width - MARGIN,
|
||||
);
|
||||
setPopStyle({
|
||||
position: 'fixed',
|
||||
left,
|
||||
width,
|
||||
bottom: window.innerHeight - r.top + MARGIN,
|
||||
zIndex,
|
||||
});
|
||||
}, [width, zIndex]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!open) return;
|
||||
updatePopStyle();
|
||||
}, [open, updatePopStyle]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onReposition = () => updatePopStyle();
|
||||
window.addEventListener('resize', onReposition);
|
||||
window.addEventListener('scroll', onReposition, true);
|
||||
return () => {
|
||||
window.removeEventListener('resize', onReposition);
|
||||
window.removeEventListener('scroll', onReposition, true);
|
||||
};
|
||||
}, [open, updatePopStyle]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onDown = (e: MouseEvent) => {
|
||||
const target = e.target as Node;
|
||||
if (btnRef.current?.contains(target) || popRef.current?.contains(target)) return;
|
||||
setOpen(false);
|
||||
};
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') setOpen(false);
|
||||
};
|
||||
document.addEventListener('mousedown', onDown);
|
||||
document.addEventListener('keydown', onKey);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', onDown);
|
||||
document.removeEventListener('keydown', onKey);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
return { open, setOpen, popStyle, btnRef, popRef };
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
|
||||
/** Wires the utility-overflow Ellipsis button + its portaled menu:
|
||||
* - watches the player-bar width via ResizeObserver and flips `utilityOverflow`
|
||||
* when it crosses the threshold (980 px floating / 1140 px docked)
|
||||
* - owns the menu open state and the 'full' vs. 'volume' display mode
|
||||
* - closes the menu on outside click or Escape
|
||||
* - re-positions the menu (fixed, above the trigger) on resize/scroll
|
||||
* - exposes a volumeWheelMenuTimerRef so the volume-wheel auto-hide handler
|
||||
* can reuse the same timer. */
|
||||
export function useUtilityOverflowMenu(
|
||||
playerBarRef: React.RefObject<HTMLElement | null>,
|
||||
floatingPlayerBar: boolean,
|
||||
) {
|
||||
const [utilityOverflow, setUtilityOverflow] = useState(false);
|
||||
const [utilityMenuOpen, setUtilityMenuOpen] = useState(false);
|
||||
const [utilityMenuMode, setUtilityMenuMode] = useState<'full' | 'volume'>('full');
|
||||
const [utilityMenuStyle, setUtilityMenuStyle] = useState<React.CSSProperties>({});
|
||||
const [suppressOverflowTooltip, setSuppressOverflowTooltip] = useState(false);
|
||||
const utilityMenuRef = useRef<HTMLDivElement>(null);
|
||||
const utilityBtnRef = useRef<HTMLButtonElement>(null);
|
||||
const volumeWheelMenuTimerRef = useRef<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const updateOverflow = () => {
|
||||
const width = playerBarRef.current?.clientWidth ?? window.innerWidth;
|
||||
const threshold = floatingPlayerBar ? 980 : 1140;
|
||||
setUtilityOverflow(width < threshold);
|
||||
};
|
||||
|
||||
updateOverflow();
|
||||
const ro = typeof ResizeObserver !== 'undefined'
|
||||
? new ResizeObserver(updateOverflow)
|
||||
: null;
|
||||
const el = playerBarRef.current;
|
||||
if (ro && el) ro.observe(el);
|
||||
window.addEventListener('resize', updateOverflow);
|
||||
return () => {
|
||||
ro?.disconnect();
|
||||
window.removeEventListener('resize', updateOverflow);
|
||||
};
|
||||
}, [floatingPlayerBar, playerBarRef]);
|
||||
|
||||
useEffect(() => {
|
||||
// React Compiler set-state-in-effect rule: state set from a DOM/layout measurement.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
if (!utilityOverflow) setUtilityMenuOpen(false);
|
||||
if (!utilityOverflow && volumeWheelMenuTimerRef.current != null) {
|
||||
window.clearTimeout(volumeWheelMenuTimerRef.current);
|
||||
volumeWheelMenuTimerRef.current = null;
|
||||
}
|
||||
}, [utilityOverflow]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!utilityMenuOpen) return;
|
||||
const onDown = (e: MouseEvent) => {
|
||||
const target = e.target as Node;
|
||||
if (utilityBtnRef.current?.contains(target)) return;
|
||||
if (utilityMenuRef.current?.contains(target)) return;
|
||||
setUtilityMenuOpen(false);
|
||||
};
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') setUtilityMenuOpen(false);
|
||||
};
|
||||
document.addEventListener('mousedown', onDown);
|
||||
document.addEventListener('keydown', onKey);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', onDown);
|
||||
document.removeEventListener('keydown', onKey);
|
||||
};
|
||||
}, [utilityMenuOpen]);
|
||||
|
||||
useEffect(() => () => {
|
||||
if (volumeWheelMenuTimerRef.current != null) {
|
||||
window.clearTimeout(volumeWheelMenuTimerRef.current);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!utilityMenuOpen) return;
|
||||
const MENU_WIDTH = utilityMenuMode === 'volume' ? 238 : 320;
|
||||
const MARGIN = 8;
|
||||
const updateMenuPos = () => {
|
||||
const btn = utilityBtnRef.current;
|
||||
if (!btn) return;
|
||||
const r = btn.getBoundingClientRect();
|
||||
const left = Math.min(
|
||||
Math.max(r.right - MENU_WIDTH, MARGIN),
|
||||
window.innerWidth - MENU_WIDTH - MARGIN,
|
||||
);
|
||||
setUtilityMenuStyle({
|
||||
position: 'fixed',
|
||||
left,
|
||||
width: MENU_WIDTH,
|
||||
bottom: window.innerHeight - r.top + 8,
|
||||
zIndex: 10050,
|
||||
});
|
||||
};
|
||||
updateMenuPos();
|
||||
window.addEventListener('resize', updateMenuPos);
|
||||
window.addEventListener('scroll', updateMenuPos, true);
|
||||
return () => {
|
||||
window.removeEventListener('resize', updateMenuPos);
|
||||
window.removeEventListener('scroll', updateMenuPos, true);
|
||||
};
|
||||
}, [utilityMenuOpen, utilityMenuMode]);
|
||||
|
||||
return {
|
||||
utilityOverflow,
|
||||
utilityMenuOpen,
|
||||
setUtilityMenuOpen,
|
||||
utilityMenuMode,
|
||||
setUtilityMenuMode,
|
||||
utilityMenuStyle,
|
||||
utilityMenuRef,
|
||||
utilityBtnRef,
|
||||
volumeWheelMenuTimerRef,
|
||||
suppressOverflowTooltip,
|
||||
setSuppressOverflowTooltip,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import React, { useEffect } from 'react';
|
||||
|
||||
export function useBulkPlPickerOutsideClick(
|
||||
showBulkPlPicker: boolean,
|
||||
setShowBulkPlPicker: React.Dispatch<React.SetStateAction<boolean>>,
|
||||
): void {
|
||||
useEffect(() => {
|
||||
if (!showBulkPlPicker) return;
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (!(e.target as HTMLElement).closest('.bulk-pl-picker-wrap')) setShowBulkPlPicker(false);
|
||||
};
|
||||
document.addEventListener('mousedown', handler);
|
||||
return () => document.removeEventListener('mousedown', handler);
|
||||
}, [showBulkPlPicker, setShowBulkPlPicker]);
|
||||
}
|
||||
@@ -38,7 +38,7 @@ import { usePlaylistPreview } from '@/features/playlist/hooks/usePlaylistPreview
|
||||
import { usePlaylistBulkPlayCallbacks } from '@/features/playlist/hooks/usePlaylistBulkPlayCallbacks';
|
||||
import { usePlaylistDerived } from '@/features/playlist/hooks/usePlaylistDerived';
|
||||
import { usePlaylistRouteEffects } from '@/features/playlist/hooks/usePlaylistRouteEffects';
|
||||
import { useBulkPlPickerOutsideClick } from '@/hooks/useBulkPlPickerOutsideClick';
|
||||
import { useBulkPlPickerOutsideClick } from '@/features/playlist/hooks/useBulkPlPickerOutsideClick';
|
||||
import { usePlaylistDnDReorder } from '@/features/playlist/hooks/usePlaylistDnDReorder';
|
||||
import { useOfflineBrowseContext } from '@/features/offline';
|
||||
import { offlineActionPolicy } from '@/features/offline';
|
||||
|
||||
Reference in New Issue
Block a user