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
+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 { 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)));
}