refactor(playback,playlist): move player-bar overflow/popover/delay + bulk-picker hooks into their features

This commit is contained in:
Psychotoxical
2026-06-30 19:59:38 +02:00
parent c2461c88d5
commit 312fa78240
11 changed files with 8 additions and 8 deletions
+1 -1
View File
@@ -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';