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
+9
View File
@@ -142,6 +142,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Album play — hold to shuffle
**By [@ImAsra](https://github.com/ImAsra), PR [#888](https://github.com/Psychotoxical/psysonic/pull/888)**
* Hold an album **Play** button (~1 s) for a filling wave animation, then the album starts in shuffled order; a short click still plays in track order.
* Album cards, hero, Because-you-like rail, and Most Played; tooltip in all locales.
## Changed ## Changed
### Linux — session GDK, WebKitGTK mitigations, and Wayland text ### Linux — session GDK, WebKitGTK mitigations, and Wayland text
+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 { resolveCoverDisplayTier } from '../cover/tiers';
import { acquireUrl } from '../utils/imageCache/urlPool'; import { acquireUrl } from '../utils/imageCache/urlPool';
import { OpenArtistRefInline } from './OpenArtistRefInline'; 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 { useDragDrop } from '../contexts/DragDropContext';
import { isAlbumRecentlyAdded } from '../utils/albumRecency'; import { isAlbumRecentlyAdded } from '../utils/albumRecency';
import { deriveAlbumArtistRefs } from '../utils/album/deriveAlbumHeaderArtistRefs'; import { deriveAlbumArtistRefs } from '../utils/album/deriveAlbumHeaderArtistRefs';
@@ -59,6 +61,10 @@ function AlbumCard({
libraryResolve = false, libraryResolve = false,
}: AlbumCardProps) { }: AlbumCardProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const { isHolding, pressBind } = useLongPressAction({
onShortPress: () => playAlbum(album.id),
onLongPress: () => playAlbumShuffled(album.id),
});
const navigate = useNavigate(); const navigate = useNavigate();
const openContextMenu = usePlayerStore(s => s.openContextMenu); const openContextMenu = usePlayerStore(s => s.openContextMenu);
const enqueue = usePlayerStore(s => s.enqueue); const enqueue = usePlayerStore(s => s.enqueue);
@@ -157,15 +163,18 @@ function AlbumCard({
)} )}
{!selectionMode && ( {!selectionMode && (
<div className="album-card-play-overlay"> <div className="album-card-play-overlay">
<button <button
className="album-card-details-btn" className="album-card-details-btn long-press-play-btn"
onClick={e => { e.stopPropagation(); playAlbum(album.id); }} {...pressBind}
aria-label={`${album.name} abspielen`} aria-label={`${t('hero.playAlbumTooltip')}${album.name}`}
data-tooltip={t('hero.playAlbum')} data-tooltip={t('hero.playAlbumTooltip')}
data-tooltip-pos="top" data-tooltip-pos="top"
> >
<Play size={15} fill="currentColor" /> <LongPressWaveOverlay active={isHolding} />
</button> <span className="long-press-play-btn__icon">
<Play size={15} fill="currentColor" />
</span>
</button>
<button <button
className="album-card-details-btn" className="album-card-details-btn"
onClick={async e => { onClick={async e => {
+14 -9
View File
@@ -19,7 +19,9 @@ import {
} from '../store/becauseYouLikeCache'; } from '../store/becauseYouLikeCache';
import { usePlayerStore } from '../store/playerStore'; import { usePlayerStore } from '../store/playerStore';
import { useAuthStore } from '../store/authStore'; 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 { formatHumanHoursMinutes } from '../utils/format/formatHumanDuration';
import AlbumRow from './AlbumRow'; import AlbumRow from './AlbumRow';
@@ -559,6 +561,10 @@ interface CardProps {
const BecauseCard = memo(function BecauseCard({ album, anchor, disableArtwork, enter }: CardProps) { const BecauseCard = memo(function BecauseCard({ album, anchor, disableArtwork, enter }: CardProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const { isHolding, pressBind } = useLongPressAction({
onShortPress: () => playAlbum(album.id),
onLongPress: () => playAlbumShuffled(album.id),
});
const navigate = useNavigate(); const navigate = useNavigate();
const enqueue = usePlayerStore(s => s.enqueue); const enqueue = usePlayerStore(s => s.enqueue);
const coverRef = useAlbumCoverRef(album.id, album.coverArt, undefined, { libraryResolve: false }); 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 imgSrc = coverImgSrc(coverHandle.src);
const bgResolved = coverHandle.src; const bgResolved = coverHandle.src;
const handleOpen = () => navigate(`/album/${album.id}`); const handleOpen = () => navigate(`/album/${album.id}`);
const handlePlay = (e: React.MouseEvent) => {
e.stopPropagation();
playAlbum(album.id);
};
const handleEnqueue = async (e: React.MouseEvent) => { const handleEnqueue = async (e: React.MouseEvent) => {
e.stopPropagation(); e.stopPropagation();
try { try {
@@ -624,13 +626,16 @@ const BecauseCard = memo(function BecauseCard({ album, anchor, disableArtwork, e
<div className="album-card-play-overlay"> <div className="album-card-play-overlay">
<button <button
type="button" type="button"
className="album-card-details-btn" className="album-card-details-btn long-press-play-btn"
onClick={handlePlay} {...pressBind}
aria-label={t('hero.playAlbum')} aria-label={t('hero.playAlbum')}
data-tooltip={t('hero.playAlbum')} data-tooltip={t('hero.playAlbumTooltip')}
data-tooltip-pos="top" 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>
<button <button
type="button" type="button"
+27 -13
View File
@@ -9,13 +9,15 @@ import { useCoverArt } from '../cover/useCoverArt';
import { useAlbumCoverRef } from '../cover/useLibraryCoverRef'; import { useAlbumCoverRef } from '../cover/useLibraryCoverRef';
import { usePlayerStore } from '../store/playerStore'; import { usePlayerStore } from '../store/playerStore';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { playAlbum } from '../utils/playback/playAlbum';
import { useIsMobile } from '../hooks/useIsMobile'; import { useIsMobile } from '../hooks/useIsMobile';
import { useWindowVisibility } from '../hooks/useWindowVisibility'; import { useWindowVisibility } from '../hooks/useWindowVisibility';
import { useAuthStore } from '../store/authStore'; import { useAuthStore } from '../store/authStore';
import { useThemeStore } from '../store/themeStore'; import { useThemeStore } from '../store/themeStore';
import { filterAlbumsByMixRatings, getMixMinRatingsConfigFromAuth } from '../utils/mix/mixRatingFilter'; import { filterAlbumsByMixRatings, getMixMinRatingsConfigFromAuth } from '../utils/mix/mixRatingFilter';
import { usePerfProbeFlags } from '../utils/perf/perfFlags'; 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 INTERVAL_MS = 10000;
const HERO_ALBUM_COUNT = 8; 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). // transition (which would cause the background to flash empty before fading in).
const stableBgUrl = useRef(''); const stableBgUrl = useRef('');
const albumId = album?.id; const albumId = album?.id;
const { isHolding, pressBind } = useLongPressAction({
onShortPress: () => { if (albumId) playAlbum(albumId); },
onLongPress: () => { if (albumId) playAlbumShuffled(albumId); },
});
useEffect(() => { useEffect(() => {
if (bgHandle.src) stableBgUrl.current = bgHandle.src; if (bgHandle.src) stableBgUrl.current = bgHandle.src;
}, [bgHandle.src, albumId]); }, [bgHandle.src, albumId]);
@@ -318,11 +324,15 @@ export default function Hero({ albums: albumsProp }: HeroProps = {}) {
{isMobile ? ( {isMobile ? (
<div className="hero-actions-mobile" onClick={e => e.stopPropagation()}> <div className="hero-actions-mobile" onClick={e => e.stopPropagation()}>
<button <button
className="album-icon-btn album-icon-btn--play" className="album-icon-btn album-icon-btn--play long-press-play-btn"
onClick={e => { e.stopPropagation(); playAlbum(album.id); }} {...pressBind}
aria-label={`${t('hero.playAlbum')} ${album.name}`} 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>
<button <button
className="album-icon-btn album-icon-btn--queue" className="album-icon-btn album-icon-btn--queue"
@@ -341,15 +351,19 @@ export default function Hero({ albums: albumsProp }: HeroProps = {}) {
</div> </div>
) : ( ) : (
<div style={{ display: 'flex', gap: '1rem', flexWrap: 'wrap' }}> <div style={{ display: 'flex', gap: '1rem', flexWrap: 'wrap' }}>
<button <button
className="hero-play-btn" className="hero-play-btn long-press-play-btn"
id="hero-play-btn" id="hero-play-btn"
onClick={e => { e.stopPropagation(); playAlbum(album.id); }} {...pressBind}
aria-label={`${t('hero.playAlbum')} ${album.name}`} aria-label={`${t('hero.playAlbum')} ${album.name}`}
> data-tooltip={t('hero.playAlbumTooltip')}
<Play size={18} fill="currentColor" /> >
{t('hero.playAlbum')} <LongPressWaveOverlay active={isHolding} />
</button> <span className="long-press-play-btn__icon" style={{ gap: '8px' }}>
<Play size={18} fill="currentColor" />
{t('hero.playAlbum')}
</span>
</button>
<button <button
className="btn btn-surface" className="btn btn-surface"
onClick={async (e) => { 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>
);
}
+7
View File
@@ -341,6 +341,13 @@ const CONTRIBUTOR_ENTRIES = [
'Discord Rich Presence: configurable activity-name template — member list shows the playing track instead of "Psysonic" (PR #885)', 'Discord Rich Presence: configurable activity-name template — member list shows the playing track instead of "Psysonic" (PR #885)',
], ],
}, },
{
github: 'ImAsra',
since: '1.47.0',
contributions: [
'Long-press album Play to shuffle with hold progress animation (PR #888)',
],
},
] as const; ] as const;
// PR number of a contributor's first listed contribution, used as the // PR number of a contributor's first listed contribution, used as the
+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 },
};
}
+1
View File
@@ -1,6 +1,7 @@
export const hero = { export const hero = {
eyebrow: 'Album des Augenblicks', eyebrow: 'Album des Augenblicks',
playAlbum: 'Album abspielen', playAlbum: 'Album abspielen',
playAlbumTooltip: 'Album abspielen (halten zum Mischen)',
enqueue: 'Einreihen', enqueue: 'Einreihen',
enqueueTooltip: 'Ganzes Album zur Warteschlange hinzufügen', enqueueTooltip: 'Ganzes Album zur Warteschlange hinzufügen',
previousAlbum: 'Vorheriges Album', previousAlbum: 'Vorheriges Album',
+1
View File
@@ -1,6 +1,7 @@
export const hero = { export const hero = {
eyebrow: 'Featured Album', eyebrow: 'Featured Album',
playAlbum: 'Play Album', playAlbum: 'Play Album',
playAlbumTooltip: 'Play Album (hold to shuffle)',
enqueue: 'Enqueue', enqueue: 'Enqueue',
enqueueTooltip: 'Add entire album to queue', enqueueTooltip: 'Add entire album to queue',
previousAlbum: 'Previous album', previousAlbum: 'Previous album',
+1
View File
@@ -1,6 +1,7 @@
export const hero = { export const hero = {
eyebrow: 'Álbum Destacado', eyebrow: 'Álbum Destacado',
playAlbum: 'Reproducir Álbum', playAlbum: 'Reproducir Álbum',
playAlbumTooltip: 'Reproducir álbum (mantener para aleatorio)',
enqueue: 'Agregar a la Cola', enqueue: 'Agregar a la Cola',
enqueueTooltip: 'Agregar álbum completo a la cola', enqueueTooltip: 'Agregar álbum completo a la cola',
previousAlbum: 'Álbum anterior', previousAlbum: 'Álbum anterior',
+1
View File
@@ -1,6 +1,7 @@
export const hero = { export const hero = {
eyebrow: 'Album en vedette', eyebrow: 'Album en vedette',
playAlbum: 'Lire l\'album', playAlbum: 'Lire l\'album',
playAlbumTooltip: 'Lire l\'album (maintenir pour mélanger)',
enqueue: 'Mettre en file', enqueue: 'Mettre en file',
enqueueTooltip: 'Ajouter l\'album entier à la file d\'attente', enqueueTooltip: 'Ajouter l\'album entier à la file d\'attente',
previousAlbum: 'Album précédent', previousAlbum: 'Album précédent',
+1
View File
@@ -1,6 +1,7 @@
export const hero = { export const hero = {
eyebrow: 'Utvalgt album', eyebrow: 'Utvalgt album',
playAlbum: 'Spill av album', playAlbum: 'Spill av album',
playAlbumTooltip: 'Spill av album (hold for tilfeldig rekkefølge)',
enqueue: 'Legg til i kø', enqueue: 'Legg til i kø',
enqueueTooltip: 'Legg til hele albumet i køen', enqueueTooltip: 'Legg til hele albumet i køen',
previousAlbum: 'Forrige album', previousAlbum: 'Forrige album',
+1
View File
@@ -1,6 +1,7 @@
export const hero = { export const hero = {
eyebrow: 'Uitgelicht album', eyebrow: 'Uitgelicht album',
playAlbum: 'Album afspelen', playAlbum: 'Album afspelen',
playAlbumTooltip: 'Album afspelen (ingedrukt houden om te shufflen)',
enqueue: 'In wachtrij', enqueue: 'In wachtrij',
enqueueTooltip: 'Volledig album aan wachtrij toevoegen', enqueueTooltip: 'Volledig album aan wachtrij toevoegen',
previousAlbum: 'Vorig album', previousAlbum: 'Vorig album',
+1
View File
@@ -1,6 +1,7 @@
export const hero = { export const hero = {
eyebrow: 'Album Promovat', eyebrow: 'Album Promovat',
playAlbum: 'Redă Album', playAlbum: 'Redă Album',
playAlbumTooltip: 'Redă album (ține apăsat pentru amestecare)',
enqueue: 'Pune în coadă', enqueue: 'Pune în coadă',
enqueueTooltip: 'Adaugă întregul album în coadă', enqueueTooltip: 'Adaugă întregul album în coadă',
previousAlbum: 'Albumul anterior', previousAlbum: 'Albumul anterior',
+1
View File
@@ -1,6 +1,7 @@
export const hero = { export const hero = {
eyebrow: 'Альбом дня', eyebrow: 'Альбом дня',
playAlbum: 'Воспроизвести альбом', playAlbum: 'Воспроизвести альбом',
playAlbumTooltip: 'Воспроизвести альбом (удерживать для перемешивания)',
enqueue: 'В очередь', enqueue: 'В очередь',
enqueueTooltip: 'Добавить весь альбом в очередь', enqueueTooltip: 'Добавить весь альбом в очередь',
previousAlbum: 'Предыдущий альбом', previousAlbum: 'Предыдущий альбом',
+1
View File
@@ -1,6 +1,7 @@
export const hero = { export const hero = {
eyebrow: '精选专辑', eyebrow: '精选专辑',
playAlbum: '播放专辑', playAlbum: '播放专辑',
playAlbumTooltip: '播放专辑(长按随机播放)',
enqueue: '加入队列', enqueue: '加入队列',
enqueueTooltip: '将整张专辑添加到播放队列', enqueueTooltip: '将整张专辑添加到播放队列',
previousAlbum: '上一张专辑', previousAlbum: '上一张专辑',
+28 -10
View File
@@ -8,7 +8,9 @@ import { useAuthStore } from '../store/authStore';
import { usePlayerStore } from '../store/playerStore'; import { usePlayerStore } from '../store/playerStore';
import { AlbumCoverArtImage } from '../cover/AlbumCoverArtImage'; import { AlbumCoverArtImage } from '../cover/AlbumCoverArtImage';
import { ArtistCoverArtImage } from '../cover/ArtistCoverArtImage'; import { ArtistCoverArtImage } from '../cover/ArtistCoverArtImage';
import { playAlbum } from '../utils/playback/playAlbum'; import { playAlbum, playAlbumShuffled } from '../utils/playback/playAlbum';
import { useLongPressAction } from '../hooks/useLongPressAction';
import { LongPressWaveOverlay } from '../components/LongPressWaveOverlay';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
const PAGE_SIZE = 50; const PAGE_SIZE = 50;
@@ -56,6 +58,30 @@ function formatPlays(n: number, t: ReturnType<typeof import('react-i18next').use
/** Most-played list row cover layout px. */ /** Most-played list row cover layout px. */
const MOST_PLAYED_COVER_CSS_PX = 80; const MOST_PLAYED_COVER_CSS_PX = 80;
function MostPlayedPlayButton({ albumId }: { albumId: string }) {
const { t } = useTranslation();
const { isHolding, pressBind } = useLongPressAction({
onShortPress: () => playAlbum(albumId),
onLongPress: () => playAlbumShuffled(albumId),
});
return (
<button
type="button"
className="mp-album-action-btn long-press-play-btn"
{...pressBind}
data-tooltip={t('hero.playAlbumTooltip')}
data-tooltip-pos="top"
aria-label={t('hero.playAlbumTooltip')}
>
<LongPressWaveOverlay active={isHolding} size="compact" />
<span className="long-press-play-btn__icon">
<Play size={14} fill="currentColor" />
</span>
</button>
);
}
export default function MostPlayed() { export default function MostPlayed() {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -230,15 +256,7 @@ export default function MostPlayed() {
</span> </span>
</div> </div>
<div className="mp-album-actions"> <div className="mp-album-actions">
<button <MostPlayedPlayButton albumId={album.id} />
className="mp-album-action-btn"
onClick={e => { e.stopPropagation(); playAlbum(album.id); }}
data-tooltip={t('hero.playAlbum')}
data-tooltip-pos="top"
aria-label={t('hero.playAlbum')}
>
<Play size={14} fill="currentColor" />
</button>
<button <button
className="mp-album-action-btn" className="mp-album-action-btn"
onClick={e => { e.stopPropagation(); void handleEnqueueAlbum(album.id); }} onClick={e => { e.stopPropagation(); void handleEnqueueAlbum(album.id); }}
@@ -260,6 +260,79 @@
background: var(--accent); background: var(--accent);
} }
.long-press-play-btn {
position: relative;
overflow: hidden;
}
.long-press-play-btn__icon {
position: relative;
z-index: 1;
display: inline-flex;
align-items: center;
justify-content: center;
}
@keyframes long-press-slosh {
0% { transform: translateX(0); }
100% { transform: translateX(-50%); }
}
.long-press-wave {
position: absolute;
bottom: 0;
left: 0;
width: 100%;
height: 100%;
color: currentColor;
opacity: 0;
transform: translateY(calc(100% + 15px));
transition: none;
pointer-events: none;
z-index: 0;
}
.long-press-wave--compact {
transform: translateY(calc(100% + 10px));
}
.long-press-wave--active {
opacity: 0.25;
transform: translateY(0);
transition: transform 900ms linear;
}
.long-press-wave--compact.long-press-wave--active {
opacity: 0.35;
}
.long-press-wave__crest {
position: absolute;
top: -10px;
left: 0;
width: 200%;
height: 12px;
animation: none;
}
.long-press-wave--compact .long-press-wave__crest {
top: -6px;
height: 8px;
}
.long-press-wave--active .long-press-wave__crest {
animation: long-press-slosh 1.2s linear infinite;
}
.long-press-wave__fill {
position: absolute;
top: 2px;
left: 0;
width: 100%;
height: 100%;
background-color: currentColor;
}
/* ── Album card selection ── */ /* ── Album card selection ── */
.album-card--selectable { .album-card--selectable {
+165
View File
@@ -0,0 +1,165 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { makeSubsonicSong } from '@/test/helpers/factories';
import { onInvoke } from '@/test/mocks/tauri';
import { resetOrbitStore, resetPlayerStore } from '@/test/helpers/storeReset';
import type { Track } from '../../store/playerStoreTypes';
vi.mock('../../api/subsonicLibrary', () => ({
getAlbum: vi.fn(),
}));
vi.mock('./fadeOut', () => ({
fadeOut: vi.fn(async () => undefined),
}));
import { getAlbum } from '../../api/subsonicLibrary';
import { useOrbitStore } from '../../store/orbitStore';
import { usePlayerStore } from '../../store/playerStore';
import { playAlbum, playAlbumShuffled } from './playAlbum';
import * as shuffleModule from './shuffleArray';
function stubPlaybackActions() {
onInvoke('audio_play', () => undefined);
onInvoke('audio_pause', () => undefined);
onInvoke('audio_stop', () => undefined);
onInvoke('audio_seek', () => undefined);
onInvoke('audio_get_state', () => ({ playing: false }));
onInvoke('audio_update_replay_gain', () => undefined);
onInvoke('audio_set_normalization', () => undefined);
onInvoke('discord_update_presence', () => undefined);
onInvoke('frontend_debug_log', () => undefined);
const playTrack = vi.fn();
const enqueue = vi.fn();
usePlayerStore.setState({ playTrack, enqueue });
return { playTrack, enqueue };
}
describe('playAlbum', () => {
beforeEach(() => {
resetPlayerStore();
resetOrbitStore();
stubPlaybackActions();
vi.mocked(getAlbum).mockResolvedValue({
album: {
id: 'al-1',
name: 'Test Album',
artist: 'Test Artist',
artistId: 'artist-1',
songCount: 3,
duration: 540,
genre: 'Rock',
},
songs: [
makeSubsonicSong({ id: 't1', title: 'One' }),
makeSubsonicSong({ id: 't2', title: 'Two' }),
makeSubsonicSong({ id: 't3', title: 'Three' }),
],
});
});
afterEach(() => {
vi.restoreAllMocks();
});
it('plays album tracks in list order', async () => {
const { playTrack } = stubPlaybackActions();
await playAlbum('al-1');
expect(playTrack).toHaveBeenCalledTimes(1);
const [, queue] = playTrack.mock.calls[0]!;
expect(queue.map((track: Track) => track.id)).toEqual(['t1', 't2', 't3']);
expect(queue.every((track: Track) => track.genre === 'Rock')).toBe(true);
});
it('enqueues instead of replacing during Orbit sessions', async () => {
useOrbitStore.setState({ role: 'guest' });
const { playTrack, enqueue } = stubPlaybackActions();
await playAlbum('al-1');
expect(enqueue).toHaveBeenCalledTimes(1);
expect(playTrack).not.toHaveBeenCalled();
expect(enqueue.mock.calls[0]![0].map((track: Track) => track.id)).toEqual(['t1', 't2', 't3']);
});
});
describe('playAlbumShuffled', () => {
beforeEach(() => {
resetPlayerStore();
resetOrbitStore();
stubPlaybackActions();
vi.mocked(getAlbum).mockResolvedValue({
album: {
id: 'al-1',
name: 'Test Album',
artist: 'Test Artist',
artistId: 'artist-1',
songCount: 3,
duration: 540,
genre: 'Rock',
},
songs: [
makeSubsonicSong({ id: 't1', title: 'One' }),
makeSubsonicSong({ id: 't2', title: 'Two' }),
makeSubsonicSong({ id: 't3', title: 'Three' }),
],
});
});
afterEach(() => {
vi.restoreAllMocks();
});
it('shuffles tracks before starting playback', async () => {
const { playTrack } = stubPlaybackActions();
const shuffled = [
{ id: 't3' },
{ id: 't1' },
{ id: 't2' },
] as Track[];
const shuffleSpy = vi.spyOn(shuffleModule, 'shuffleArray').mockReturnValue(shuffled as never);
await playAlbumShuffled('al-1');
expect(shuffleSpy).toHaveBeenCalledTimes(1);
expect(playTrack).toHaveBeenCalledWith(shuffled[0], shuffled);
});
it('enqueues a shuffled album during Orbit sessions', async () => {
useOrbitStore.setState({ role: 'host' });
const { enqueue } = stubPlaybackActions();
const shuffled = [
{ id: 't2' },
{ id: 't3' },
{ id: 't1' },
] as Track[];
vi.spyOn(shuffleModule, 'shuffleArray').mockReturnValue(shuffled as never);
await playAlbumShuffled('al-1');
expect(enqueue).toHaveBeenCalledWith(shuffled);
});
it('does not start playback for an empty album', async () => {
vi.mocked(getAlbum).mockResolvedValue({
album: {
id: 'al-empty',
name: 'Empty',
artist: 'Test Artist',
artistId: 'artist-1',
songCount: 0,
duration: 0,
},
songs: [],
});
const { playTrack } = stubPlaybackActions();
const shuffleSpy = vi.spyOn(shuffleModule, 'shuffleArray');
await playAlbumShuffled('al-empty');
expect(shuffleSpy).toHaveBeenCalledWith([]);
expect(playTrack).not.toHaveBeenCalled();
});
});
+15 -2
View File
@@ -3,15 +3,20 @@ import { usePlayerStore } from '../../store/playerStore';
import { songToTrack } from './songToTrack'; import { songToTrack } from './songToTrack';
import { useOrbitStore } from '../../store/orbitStore'; import { useOrbitStore } from '../../store/orbitStore';
import { fadeOut } from './fadeOut'; import { fadeOut } from './fadeOut';
import type { Track } from '../../store/playerStoreTypes';
import { shuffleArray } from './shuffleArray';
export async function playAlbum(albumId: string): Promise<void> { async function fetchAlbumTracks(albumId: string): Promise<Track[]> {
const albumData = await getAlbum(albumId); const albumData = await getAlbum(albumId);
const albumGenre = albumData.album.genre; const albumGenre = albumData.album.genre;
const tracks = albumData.songs.map(s => { return albumData.songs.map(s => {
const track = songToTrack(s); const track = songToTrack(s);
if (!track.genre && albumGenre) track.genre = albumGenre; if (!track.genre && albumGenre) track.genre = albumGenre;
return track; return track;
}); });
}
async function startAlbumPlayback(tracks: Track[]): Promise<void> {
if (!tracks.length) return; if (!tracks.length) return;
// In Orbit sessions, playAlbum is effectively an append operation (the // In Orbit sessions, playAlbum is effectively an append operation (the
@@ -38,3 +43,11 @@ export async function playAlbum(albumId: string): Promise<void> {
usePlayerStore.getState().playTrack(tracks[0], tracks); usePlayerStore.getState().playTrack(tracks[0], tracks);
} }
export async function playAlbum(albumId: string): Promise<void> {
await startAlbumPlayback(await fetchAlbumTracks(albumId));
}
export async function playAlbumShuffled(albumId: string): Promise<void> {
await startAlbumPlayback(shuffleArray(await fetchAlbumTracks(albumId)));
}