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
+19 -10
View File
@@ -16,7 +16,9 @@ import { COVER_DENSE_GRID_MIN_CELL_CSS_PX } from '../cover/layoutSizes';
import { resolveCoverDisplayTier } from '../cover/tiers';
import { acquireUrl } from '../utils/imageCache/urlPool';
import { OpenArtistRefInline } from './OpenArtistRefInline';
import { playAlbum } from '../utils/playback/playAlbum';
import { playAlbum, playAlbumShuffled } from '../utils/playback/playAlbum';
import { useLongPressAction } from '../hooks/useLongPressAction';
import { LongPressWaveOverlay } from './LongPressWaveOverlay';
import { useDragDrop } from '../contexts/DragDropContext';
import { isAlbumRecentlyAdded } from '../utils/albumRecency';
import { deriveAlbumArtistRefs } from '../utils/album/deriveAlbumHeaderArtistRefs';
@@ -59,6 +61,10 @@ function AlbumCard({
libraryResolve = false,
}: AlbumCardProps) {
const { t } = useTranslation();
const { isHolding, pressBind } = useLongPressAction({
onShortPress: () => playAlbum(album.id),
onLongPress: () => playAlbumShuffled(album.id),
});
const navigate = useNavigate();
const openContextMenu = usePlayerStore(s => s.openContextMenu);
const enqueue = usePlayerStore(s => s.enqueue);
@@ -157,15 +163,18 @@ function AlbumCard({
)}
{!selectionMode && (
<div className="album-card-play-overlay">
<button
className="album-card-details-btn"
onClick={e => { e.stopPropagation(); playAlbum(album.id); }}
aria-label={`${album.name} abspielen`}
data-tooltip={t('hero.playAlbum')}
data-tooltip-pos="top"
>
<Play size={15} fill="currentColor" />
</button>
<button
className="album-card-details-btn long-press-play-btn"
{...pressBind}
aria-label={`${t('hero.playAlbumTooltip')}${album.name}`}
data-tooltip={t('hero.playAlbumTooltip')}
data-tooltip-pos="top"
>
<LongPressWaveOverlay active={isHolding} />
<span className="long-press-play-btn__icon">
<Play size={15} fill="currentColor" />
</span>
</button>
<button
className="album-card-details-btn"
onClick={async e => {
+14 -9
View File
@@ -19,7 +19,9 @@ import {
} from '../store/becauseYouLikeCache';
import { usePlayerStore } from '../store/playerStore';
import { useAuthStore } from '../store/authStore';
import { playAlbum } from '../utils/playback/playAlbum';
import { playAlbum, playAlbumShuffled } from '../utils/playback/playAlbum';
import { useLongPressAction } from '../hooks/useLongPressAction';
import { LongPressWaveOverlay } from './LongPressWaveOverlay';
import { formatHumanHoursMinutes } from '../utils/format/formatHumanDuration';
import AlbumRow from './AlbumRow';
@@ -559,6 +561,10 @@ interface CardProps {
const BecauseCard = memo(function BecauseCard({ album, anchor, disableArtwork, enter }: CardProps) {
const { t } = useTranslation();
const { isHolding, pressBind } = useLongPressAction({
onShortPress: () => playAlbum(album.id),
onLongPress: () => playAlbumShuffled(album.id),
});
const navigate = useNavigate();
const enqueue = usePlayerStore(s => s.enqueue);
const coverRef = useAlbumCoverRef(album.id, album.coverArt, undefined, { libraryResolve: false });
@@ -569,10 +575,6 @@ const BecauseCard = memo(function BecauseCard({ album, anchor, disableArtwork, e
const imgSrc = coverImgSrc(coverHandle.src);
const bgResolved = coverHandle.src;
const handleOpen = () => navigate(`/album/${album.id}`);
const handlePlay = (e: React.MouseEvent) => {
e.stopPropagation();
playAlbum(album.id);
};
const handleEnqueue = async (e: React.MouseEvent) => {
e.stopPropagation();
try {
@@ -624,13 +626,16 @@ const BecauseCard = memo(function BecauseCard({ album, anchor, disableArtwork, e
<div className="album-card-play-overlay">
<button
type="button"
className="album-card-details-btn"
onClick={handlePlay}
className="album-card-details-btn long-press-play-btn"
{...pressBind}
aria-label={t('hero.playAlbum')}
data-tooltip={t('hero.playAlbum')}
data-tooltip={t('hero.playAlbumTooltip')}
data-tooltip-pos="top"
>
<Play size={15} fill="currentColor" />
<LongPressWaveOverlay active={isHolding} size="compact" />
<span className="long-press-play-btn__icon">
<Play size={15} fill="currentColor" />
</span>
</button>
<button
type="button"
+27 -13
View File
@@ -9,13 +9,15 @@ import { useCoverArt } from '../cover/useCoverArt';
import { useAlbumCoverRef } from '../cover/useLibraryCoverRef';
import { usePlayerStore } from '../store/playerStore';
import { useTranslation } from 'react-i18next';
import { playAlbum } from '../utils/playback/playAlbum';
import { useIsMobile } from '../hooks/useIsMobile';
import { useWindowVisibility } from '../hooks/useWindowVisibility';
import { useAuthStore } from '../store/authStore';
import { useThemeStore } from '../store/themeStore';
import { filterAlbumsByMixRatings, getMixMinRatingsConfigFromAuth } from '../utils/mix/mixRatingFilter';
import { usePerfProbeFlags } from '../utils/perf/perfFlags';
import { playAlbum, playAlbumShuffled } from '../utils/playback/playAlbum';
import { useLongPressAction } from '../hooks/useLongPressAction';
import { LongPressWaveOverlay } from './LongPressWaveOverlay';
const INTERVAL_MS = 10000;
const HERO_ALBUM_COUNT = 8;
@@ -275,6 +277,10 @@ export default function Hero({ albums: albumsProp }: HeroProps = {}) {
// transition (which would cause the background to flash empty before fading in).
const stableBgUrl = useRef('');
const albumId = album?.id;
const { isHolding, pressBind } = useLongPressAction({
onShortPress: () => { if (albumId) playAlbum(albumId); },
onLongPress: () => { if (albumId) playAlbumShuffled(albumId); },
});
useEffect(() => {
if (bgHandle.src) stableBgUrl.current = bgHandle.src;
}, [bgHandle.src, albumId]);
@@ -318,11 +324,15 @@ export default function Hero({ albums: albumsProp }: HeroProps = {}) {
{isMobile ? (
<div className="hero-actions-mobile" onClick={e => e.stopPropagation()}>
<button
className="album-icon-btn album-icon-btn--play"
onClick={e => { e.stopPropagation(); playAlbum(album.id); }}
className="album-icon-btn album-icon-btn--play long-press-play-btn"
{...pressBind}
aria-label={`${t('hero.playAlbum')} ${album.name}`}
data-tooltip={t('hero.playAlbumTooltip')}
>
<Play size={22} fill="currentColor" />
<LongPressWaveOverlay active={isHolding} size="compact" />
<span className="long-press-play-btn__icon">
<Play size={22} fill="currentColor" />
</span>
</button>
<button
className="album-icon-btn album-icon-btn--queue"
@@ -341,15 +351,19 @@ export default function Hero({ albums: albumsProp }: HeroProps = {}) {
</div>
) : (
<div style={{ display: 'flex', gap: '1rem', flexWrap: 'wrap' }}>
<button
className="hero-play-btn"
id="hero-play-btn"
onClick={e => { e.stopPropagation(); playAlbum(album.id); }}
aria-label={`${t('hero.playAlbum')} ${album.name}`}
>
<Play size={18} fill="currentColor" />
{t('hero.playAlbum')}
</button>
<button
className="hero-play-btn long-press-play-btn"
id="hero-play-btn"
{...pressBind}
aria-label={`${t('hero.playAlbum')} ${album.name}`}
data-tooltip={t('hero.playAlbumTooltip')}
>
<LongPressWaveOverlay active={isHolding} />
<span className="long-press-play-btn__icon" style={{ gap: '8px' }}>
<Play size={18} fill="currentColor" />
{t('hero.playAlbum')}
</span>
</button>
<button
className="btn btn-surface"
onClick={async (e) => {
+20
View File
@@ -0,0 +1,20 @@
interface LongPressWaveOverlayProps {
active: boolean;
/** Smaller crest/fill tuned for dense album-card play buttons. */
size?: 'default' | 'compact';
}
export function LongPressWaveOverlay({ active, size = 'default' }: LongPressWaveOverlayProps) {
const compact = size === 'compact';
return (
<div
className={`long-press-wave${active ? ' long-press-wave--active' : ''}${compact ? ' long-press-wave--compact' : ''}`}
aria-hidden="true"
>
<svg className="long-press-wave__crest" viewBox="0 0 200 20" preserveAspectRatio="none">
<path d="M0,10 Q25,18 50,10 T100,10 Q125,18 150,10 T200,10 L200,20 L0,20 Z" fill="currentColor" />
</svg>
<div className="long-press-wave__fill" />
</div>
);
}