mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 23:05:46 +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:
@@ -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
|
||||
|
||||
### Linux — session GDK, WebKitGTK mitigations, and Wayland text
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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
@@ -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) => {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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)',
|
||||
],
|
||||
},
|
||||
{
|
||||
github: 'ImAsra',
|
||||
since: '1.47.0',
|
||||
contributions: [
|
||||
'Long-press album Play to shuffle with hold progress animation (PR #888)',
|
||||
],
|
||||
},
|
||||
] as const;
|
||||
|
||||
// PR number of a contributor's first listed contribution, used as the
|
||||
|
||||
@@ -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 },
|
||||
};
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
export const hero = {
|
||||
eyebrow: 'Album des Augenblicks',
|
||||
playAlbum: 'Album abspielen',
|
||||
playAlbumTooltip: 'Album abspielen (halten zum Mischen)',
|
||||
enqueue: 'Einreihen',
|
||||
enqueueTooltip: 'Ganzes Album zur Warteschlange hinzufügen',
|
||||
previousAlbum: 'Vorheriges Album',
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export const hero = {
|
||||
eyebrow: 'Featured Album',
|
||||
playAlbum: 'Play Album',
|
||||
playAlbumTooltip: 'Play Album (hold to shuffle)',
|
||||
enqueue: 'Enqueue',
|
||||
enqueueTooltip: 'Add entire album to queue',
|
||||
previousAlbum: 'Previous album',
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export const hero = {
|
||||
eyebrow: 'Álbum Destacado',
|
||||
playAlbum: 'Reproducir Álbum',
|
||||
playAlbumTooltip: 'Reproducir álbum (mantener para aleatorio)',
|
||||
enqueue: 'Agregar a la Cola',
|
||||
enqueueTooltip: 'Agregar álbum completo a la cola',
|
||||
previousAlbum: 'Álbum anterior',
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export const hero = {
|
||||
eyebrow: 'Album en vedette',
|
||||
playAlbum: 'Lire l\'album',
|
||||
playAlbumTooltip: 'Lire l\'album (maintenir pour mélanger)',
|
||||
enqueue: 'Mettre en file',
|
||||
enqueueTooltip: 'Ajouter l\'album entier à la file d\'attente',
|
||||
previousAlbum: 'Album précédent',
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export const hero = {
|
||||
eyebrow: 'Utvalgt album',
|
||||
playAlbum: 'Spill av album',
|
||||
playAlbumTooltip: 'Spill av album (hold for tilfeldig rekkefølge)',
|
||||
enqueue: 'Legg til i kø',
|
||||
enqueueTooltip: 'Legg til hele albumet i køen',
|
||||
previousAlbum: 'Forrige album',
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export const hero = {
|
||||
eyebrow: 'Uitgelicht album',
|
||||
playAlbum: 'Album afspelen',
|
||||
playAlbumTooltip: 'Album afspelen (ingedrukt houden om te shufflen)',
|
||||
enqueue: 'In wachtrij',
|
||||
enqueueTooltip: 'Volledig album aan wachtrij toevoegen',
|
||||
previousAlbum: 'Vorig album',
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export const hero = {
|
||||
eyebrow: 'Album Promovat',
|
||||
playAlbum: 'Redă Album',
|
||||
playAlbumTooltip: 'Redă album (ține apăsat pentru amestecare)',
|
||||
enqueue: 'Pune în coadă',
|
||||
enqueueTooltip: 'Adaugă întregul album în coadă',
|
||||
previousAlbum: 'Albumul anterior',
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export const hero = {
|
||||
eyebrow: 'Альбом дня',
|
||||
playAlbum: 'Воспроизвести альбом',
|
||||
playAlbumTooltip: 'Воспроизвести альбом (удерживать для перемешивания)',
|
||||
enqueue: 'В очередь',
|
||||
enqueueTooltip: 'Добавить весь альбом в очередь',
|
||||
previousAlbum: 'Предыдущий альбом',
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export const hero = {
|
||||
eyebrow: '精选专辑',
|
||||
playAlbum: '播放专辑',
|
||||
playAlbumTooltip: '播放专辑(长按随机播放)',
|
||||
enqueue: '加入队列',
|
||||
enqueueTooltip: '将整张专辑添加到播放队列',
|
||||
previousAlbum: '上一张专辑',
|
||||
|
||||
+28
-10
@@ -8,7 +8,9 @@ import { useAuthStore } from '../store/authStore';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
import { AlbumCoverArtImage } from '../cover/AlbumCoverArtImage';
|
||||
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';
|
||||
|
||||
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. */
|
||||
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() {
|
||||
const { t } = useTranslation();
|
||||
@@ -230,15 +256,7 @@ export default function MostPlayed() {
|
||||
</span>
|
||||
</div>
|
||||
<div className="mp-album-actions">
|
||||
<button
|
||||
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>
|
||||
<MostPlayedPlayButton albumId={album.id} />
|
||||
<button
|
||||
className="mp-album-action-btn"
|
||||
onClick={e => { e.stopPropagation(); void handleEnqueueAlbum(album.id); }}
|
||||
|
||||
@@ -260,6 +260,79 @@
|
||||
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--selectable {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -3,15 +3,20 @@ import { usePlayerStore } from '../../store/playerStore';
|
||||
import { songToTrack } from './songToTrack';
|
||||
import { useOrbitStore } from '../../store/orbitStore';
|
||||
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 albumGenre = albumData.album.genre;
|
||||
const tracks = albumData.songs.map(s => {
|
||||
return albumData.songs.map(s => {
|
||||
const track = songToTrack(s);
|
||||
if (!track.genre && albumGenre) track.genre = albumGenre;
|
||||
return track;
|
||||
});
|
||||
}
|
||||
|
||||
async function startAlbumPlayback(tracks: Track[]): Promise<void> {
|
||||
if (!tracks.length) return;
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
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)));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user