mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-22 14:35:41 +00:00
feat: add long press to shuffle with a wave animation (#888)
* feat: add long press to shuffle with a wave animation to singnify how long to press * refactor: long-press shuffle cleanup Follow-up on the long-press shuffle PR: shared hook/overlay, playback parity, pointer events, broader surface coverage, locales, and tests. * docs: credit ImAsra for long-press album shuffle (PR #888) Add CHANGELOG entry and Settings credits for the hold-to-shuffle play interaction shipped in Psychotoxical/psysonic#888. * fix: restore playAlbumShuffled and long-press hook wiring The follow-up merge dropped playAlbumShuffled and reverted the shared long-press hook in album play buttons, breaking tsc and vitest on PR #888. --------- Co-authored-by: cucadmuh <49571317+cucadmuh@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,108 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { renderHook, act } from '@testing-library/react';
|
||||
import type { MouseEvent, PointerEvent } from 'react';
|
||||
import { useLongPressAction } from './useLongPressAction';
|
||||
|
||||
function makePointerDown(overrides: Partial<PointerEvent> = {}): PointerEvent {
|
||||
return {
|
||||
pointerType: 'mouse',
|
||||
button: 0,
|
||||
stopPropagation: vi.fn(),
|
||||
...overrides,
|
||||
} as unknown as PointerEvent;
|
||||
}
|
||||
|
||||
function makeClick(): MouseEvent {
|
||||
return { stopPropagation: vi.fn() } as unknown as MouseEvent;
|
||||
}
|
||||
|
||||
describe('useLongPressAction', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('calls onShortPress after a quick press and click', () => {
|
||||
const onShortPress = vi.fn();
|
||||
const onLongPress = vi.fn();
|
||||
const { result } = renderHook(() =>
|
||||
useLongPressAction({ onShortPress, onLongPress }),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.pressBind.onPointerDown(makePointerDown());
|
||||
});
|
||||
act(() => {
|
||||
document.dispatchEvent(new Event('pointerup'));
|
||||
});
|
||||
act(() => {
|
||||
result.current.pressBind.onClick(makeClick());
|
||||
});
|
||||
|
||||
expect(onShortPress).toHaveBeenCalledTimes(1);
|
||||
expect(onLongPress).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls onLongPress after the hold duration and suppresses the follow-up click', () => {
|
||||
const onShortPress = vi.fn();
|
||||
const onLongPress = vi.fn();
|
||||
const { result } = renderHook(() =>
|
||||
useLongPressAction({ onShortPress, onLongPress }),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.pressBind.onPointerDown(makePointerDown());
|
||||
});
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(1000);
|
||||
});
|
||||
expect(onLongPress).toHaveBeenCalledTimes(1);
|
||||
|
||||
act(() => {
|
||||
document.dispatchEvent(new Event('pointerup'));
|
||||
});
|
||||
act(() => {
|
||||
result.current.pressBind.onClick(makeClick());
|
||||
});
|
||||
|
||||
expect(onShortPress).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shows the hold animation shortly before the long-press fires', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useLongPressAction({
|
||||
onShortPress: vi.fn(),
|
||||
onLongPress: vi.fn(),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.current.isHolding).toBe(false);
|
||||
act(() => {
|
||||
result.current.pressBind.onPointerDown(makePointerDown());
|
||||
});
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(100);
|
||||
});
|
||||
expect(result.current.isHolding).toBe(true);
|
||||
});
|
||||
|
||||
it('ignores non-primary mouse buttons', () => {
|
||||
const onShortPress = vi.fn();
|
||||
const onLongPress = vi.fn();
|
||||
const { result } = renderHook(() =>
|
||||
useLongPressAction({ onShortPress, onLongPress }),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.pressBind.onPointerDown(makePointerDown({ button: 2 }));
|
||||
vi.advanceTimersByTime(1000);
|
||||
});
|
||||
|
||||
expect(onShortPress).not.toHaveBeenCalled();
|
||||
expect(onLongPress).not.toHaveBeenCalled();
|
||||
expect(result.current.isHolding).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
const DEFAULT_HOLD_MS = 1000;
|
||||
const DEFAULT_ANIM_DELAY_MS = 100;
|
||||
|
||||
interface UseLongPressActionOptions {
|
||||
onShortPress: () => void;
|
||||
onLongPress: () => void;
|
||||
holdMs?: number;
|
||||
animDelayMs?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Short click runs `onShortPress`; hold runs `onLongPress` and suppresses the
|
||||
* synthetic click that follows pointer release.
|
||||
*/
|
||||
export function useLongPressAction({
|
||||
onShortPress,
|
||||
onLongPress,
|
||||
holdMs = DEFAULT_HOLD_MS,
|
||||
animDelayMs = DEFAULT_ANIM_DELAY_MS,
|
||||
}: UseLongPressActionOptions) {
|
||||
const [isHolding, setIsHolding] = useState(false);
|
||||
const longPressTriggeredRef = useRef(false);
|
||||
const holdTimerRef = useRef<number | null>(null);
|
||||
const animTimerRef = useRef<number | null>(null);
|
||||
const endListenerRef = useRef<(() => void) | null>(null);
|
||||
|
||||
const clearTimers = useCallback(() => {
|
||||
if (holdTimerRef.current != null) {
|
||||
window.clearTimeout(holdTimerRef.current);
|
||||
holdTimerRef.current = null;
|
||||
}
|
||||
if (animTimerRef.current != null) {
|
||||
window.clearTimeout(animTimerRef.current);
|
||||
animTimerRef.current = null;
|
||||
}
|
||||
if (endListenerRef.current) {
|
||||
document.removeEventListener('pointerup', endListenerRef.current);
|
||||
document.removeEventListener('pointercancel', endListenerRef.current);
|
||||
endListenerRef.current = null;
|
||||
}
|
||||
setIsHolding(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => () => clearTimers(), [clearTimers]);
|
||||
|
||||
const onPointerDown = useCallback(
|
||||
(e: React.PointerEvent) => {
|
||||
if (e.pointerType === 'mouse' && e.button !== 0) return;
|
||||
e.stopPropagation();
|
||||
longPressTriggeredRef.current = false;
|
||||
clearTimers();
|
||||
|
||||
animTimerRef.current = window.setTimeout(() => {
|
||||
animTimerRef.current = null;
|
||||
setIsHolding(true);
|
||||
}, animDelayMs) as unknown as number;
|
||||
|
||||
holdTimerRef.current = window.setTimeout(() => {
|
||||
holdTimerRef.current = null;
|
||||
longPressTriggeredRef.current = true;
|
||||
onLongPress();
|
||||
setIsHolding(false);
|
||||
}, holdMs) as unknown as number;
|
||||
|
||||
const end = () => clearTimers();
|
||||
endListenerRef.current = end;
|
||||
document.addEventListener('pointerup', end);
|
||||
document.addEventListener('pointercancel', end);
|
||||
},
|
||||
[animDelayMs, clearTimers, holdMs, onLongPress],
|
||||
);
|
||||
|
||||
const onClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (longPressTriggeredRef.current) {
|
||||
longPressTriggeredRef.current = false;
|
||||
return;
|
||||
}
|
||||
onShortPress();
|
||||
},
|
||||
[onShortPress],
|
||||
);
|
||||
|
||||
return {
|
||||
isHolding,
|
||||
pressBind: { onClick, onPointerDown },
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user