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:
ImAsra
2026-05-28 22:59:49 +02:00
committed by GitHub
parent 8443b3d4be
commit ae2e123a14
21 changed files with 585 additions and 44 deletions
+108
View File
@@ -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);
});
});
+91
View File
@@ -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 },
};
}