mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-22 14:35:41 +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:
@@ -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