feat(library): play all and shuffle all on every track list
Four pages that list tracks — Tracks, genre, artist, and both playlist views — had no way to start the whole list, or had a broken one. One shared helper (utils/play-all.ts) now owns what "shuffle this collection" means: SetQueue's shuffleStart only picks a random first track when shuffle mode is already on, it does not turn it on, so the mode is toggled before the queue is set. Each host passes an honest queue Source (#14): anything that builds a queue names what it built it from, so "Playing from" stops lying. Two behaviour changes ride along, both flagged: smart-playlist-details' Shuffle was a live no-op (shuffleStart without enabling mode played track 1 in order) and is fixed; playlist-details' Play all drops its shuffleStart:true, so with shuffle mode already on it now starts at the first row instead of a random one — the album page's existing semantics. Verified: make ui-test (1147, incl. a case that fails when the smart-playlist fix is reverted), npx tsc --noEmit, make e2e (255, incl. new play-all and header-fit specs), make lint, make test, make bindings-check, make css-check; artist header read from screenshots at 424/320/900 (the pair wraps below the name on a phone). Closes #31
This commit is contained in:
@@ -11,11 +11,22 @@ import {
|
||||
GetArtistImageCachedPath,
|
||||
GetArtistMBID,
|
||||
} from '@go/explore/service.js';
|
||||
import { GetFilePathsByAlbums } from '@go/library/library.js';
|
||||
import { libraryStore } from '@store/library-store';
|
||||
import { notificationStore } from '@store/notification-store';
|
||||
import { dict } from '@utils/binding';
|
||||
import { playAll } from '@utils/play-all';
|
||||
import { describeError } from '@utils/describe-error';
|
||||
import { ICON_PLAY, ICON_SHUFFLE } from '@utils/icon-language';
|
||||
import '@awesome.me/webawesome/dist/components/icon/icon.js';
|
||||
import '@components/cover-grid/cover-grid.js';
|
||||
import '../notifications/inline-notice';
|
||||
import { designTokens } from '../../styles/tokens.css';
|
||||
import { backButton } from '../../styles/back-button.css';
|
||||
|
||||
/** The region the artist header's own failures are rendered in. */
|
||||
const ArtistRegion = 'library-artist';
|
||||
|
||||
@customElement('artist-details')
|
||||
export class ArtistDetails extends LitElement {
|
||||
@property({ type: Number, attribute: 'artist-id' })
|
||||
@@ -131,6 +142,39 @@ export class ArtistDetails extends LitElement {
|
||||
);
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.header-action {
|
||||
background: none;
|
||||
border: 1px solid var(--yj-border-subtle, #555);
|
||||
border-radius: 4px;
|
||||
color: var(--yj-text-primary, #fff);
|
||||
padding: 6px 12px;
|
||||
font-size: var(--yj-text-md, 13px);
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.header-action:hover {
|
||||
border-color: var(--yj-accent, #ffd43b);
|
||||
color: var(--yj-accent-text, #ffd43b);
|
||||
}
|
||||
|
||||
.header-action:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* ====================================
|
||||
* Content
|
||||
* ==================================== */
|
||||
@@ -145,6 +189,23 @@ export class ArtistDetails extends LitElement {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* Phone widths: the header's flex row squeezed .artist-info to
|
||||
* nothing, so the title ellipsised away entirely and the
|
||||
* actions clipped against the host's own overflow — the album
|
||||
* page's fault one detail view over (#66). The pair takes its
|
||||
* own row instead. Written last, because a media query adds no
|
||||
* specificity and a rule placed above the plain ones it
|
||||
* overrides is silently dead. */
|
||||
@media (max-width: 599px) {
|
||||
.artist-header {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
flex-basis: 100%;
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
`];
|
||||
|
||||
override connectedCallback() {
|
||||
@@ -302,6 +363,45 @@ export class ArtistDetails extends LitElement {
|
||||
return name.charAt(0).toUpperCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Play every track on this artist's albums, in album order.
|
||||
*
|
||||
* One `GetFilePathsByAlbums` call returns the paths grouped by
|
||||
* album id; the caller owns the ordering, so they are flattened in
|
||||
* `this.albums` order rather than by id.
|
||||
*/
|
||||
private async playAllTracks(shuffle: boolean): Promise<void> {
|
||||
if (this.albums.length === 0) return;
|
||||
|
||||
try {
|
||||
const libId = libraryStore.getSelectedLibraryId() ?? 0;
|
||||
const ids = this.albums.map((a) => a.ID);
|
||||
const byAlbum = await dict(
|
||||
GetFilePathsByAlbums(ids, libId),
|
||||
);
|
||||
const paths: string[] = [];
|
||||
|
||||
for (const id of ids) {
|
||||
paths.push(...(byAlbum[id] ?? []));
|
||||
}
|
||||
|
||||
playAll(
|
||||
paths,
|
||||
{
|
||||
type: 'artist',
|
||||
id: this.artistId,
|
||||
label: this.artistName,
|
||||
},
|
||||
shuffle,
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Could not play artist:', error);
|
||||
notificationStore.inline(ArtistRegion, {
|
||||
text: describeError(error, 'Could not play this artist’s tracks.'),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/* ================================================================
|
||||
* Rendering
|
||||
* ================================================================ */
|
||||
@@ -351,12 +451,38 @@ export class ArtistDetails extends LitElement {
|
||||
`
|
||||
: ''}
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<button
|
||||
class="header-action"
|
||||
data-testid="artist-play-all"
|
||||
?disabled=${this.albums.length === 0}
|
||||
@click=${() =>
|
||||
void this.playAllTracks(false)}
|
||||
>
|
||||
<wa-icon name=${ICON_PLAY}></wa-icon>
|
||||
Play all
|
||||
</button>
|
||||
<button
|
||||
class="header-action"
|
||||
data-testid="artist-shuffle-all"
|
||||
?disabled=${this.albums.length === 0}
|
||||
@click=${() =>
|
||||
void this.playAllTracks(true)}
|
||||
>
|
||||
<wa-icon name=${ICON_SHUFFLE}></wa-icon>
|
||||
Shuffle all
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="content">
|
||||
<cover-grid
|
||||
.externalAlbums=${this.albums}
|
||||
></cover-grid>
|
||||
</div>
|
||||
<inline-notice
|
||||
region=${ArtistRegion}
|
||||
testid="artist-play-message"
|
||||
></inline-notice>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,6 +43,7 @@ import type * as autotagservice from '@go/autotagservice/models.js';
|
||||
import { confirmAction } from '../confirm-dialog/confirm-dialog';
|
||||
import { queueStore } from '../../store/queue-store';
|
||||
import type { QueueSource } from '../../store/queue-store';
|
||||
import { playAll } from '@utils/play-all';
|
||||
import { notificationStore } from '../../store/notification-store';
|
||||
import '../notifications/inline-notice';
|
||||
import {
|
||||
@@ -2771,20 +2772,11 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost {
|
||||
|
||||
/** Play what the user owns of this release, optionally shuffled. */
|
||||
private playOwned(shuffle: boolean): void {
|
||||
const paths = this.ownedFilePaths();
|
||||
|
||||
// The button is only rendered when there is something to play,
|
||||
// so an empty set here is not a state the user can reach.
|
||||
if (paths.length === 0) return;
|
||||
|
||||
// `shuffleStart` only picks a random first track when shuffle
|
||||
// mode is *already* on — it does not turn it on — so the mode
|
||||
// has to be set before the queue, not after.
|
||||
if (shuffle && !queueStore.getState().shuffleMode) {
|
||||
queueStore.toggleShuffle();
|
||||
}
|
||||
|
||||
queueStore.setQueue(paths, 0, shuffle, this.queueSource());
|
||||
// so an empty set here is not a state the user can reach. The
|
||||
// shuffle-mode semantics live in `playAll`, shared with the
|
||||
// play-all/shuffle-all pair on every track list.
|
||||
playAll(this.ownedFilePaths(), this.queueSource(), shuffle);
|
||||
}
|
||||
|
||||
/** Append what the user owns of this release to the queue. */
|
||||
|
||||
@@ -85,7 +85,9 @@ import {
|
||||
ICON_PLAYLIST,
|
||||
ICON_QUEUE,
|
||||
ICON_REMOVE,
|
||||
ICON_SHUFFLE,
|
||||
} from '@utils/icon-language';
|
||||
import { playAll } from '@utils/play-all';
|
||||
|
||||
/** One playlist row: the track and its position in the *playlist*,
|
||||
* which is not its position in the filtered view. */
|
||||
@@ -355,14 +357,32 @@ export class PlaylistDetails
|
||||
// Track interactions
|
||||
// =================================================================
|
||||
|
||||
private handlePlayAll() {
|
||||
const filePaths = this.tracks
|
||||
private playableFilePaths(): string[] {
|
||||
return this.tracks
|
||||
.filter((t) => !t.Phantom)
|
||||
.map((t) => t.FilePath);
|
||||
}
|
||||
|
||||
if (filePaths.length === 0) return;
|
||||
private handlePlayAll() {
|
||||
// Start at the first row, not at a random one: the old `true`
|
||||
// was `shuffleStart`, which only picks a random first track
|
||||
// when shuffle mode is already on — so "Play All" quietly did
|
||||
// "play from the top" while leaving the mode as it was. The
|
||||
// mode semantics now live in `playAll`, shared with the other
|
||||
// track lists.
|
||||
playAll(
|
||||
this.playableFilePaths(),
|
||||
{ type: 'playlist', id: this.playlistId, label: this.playlistName },
|
||||
false,
|
||||
);
|
||||
}
|
||||
|
||||
queueStore.setQueue(filePaths, 0, true, { type: 'playlist', id: this.playlistId, label: this.playlistName });
|
||||
private handleShuffleAll() {
|
||||
playAll(
|
||||
this.playableFilePaths(),
|
||||
{ type: 'playlist', id: this.playlistId, label: this.playlistName },
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
private handleTrackClick(
|
||||
@@ -1599,9 +1619,16 @@ export class PlaylistDetails
|
||||
class="play-all-button"
|
||||
@click=${() => this.handlePlayAll()}
|
||||
>
|
||||
<wa-icon name="play"></wa-icon>
|
||||
<wa-icon name=${ICON_PLAY}></wa-icon>
|
||||
Play All
|
||||
</button>
|
||||
<button
|
||||
class="play-all-button"
|
||||
@click=${() => this.handleShuffleAll()}
|
||||
>
|
||||
<wa-icon name=${ICON_SHUFFLE}></wa-icon>
|
||||
Shuffle All
|
||||
</button>
|
||||
</div>
|
||||
<div class="track-header">
|
||||
<div class="header-cell col-number">#</div>
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
import { EventsOn } from '@runtime/runtime';
|
||||
import { Events } from '../../events';
|
||||
import { queueStore } from '@store/queue-store';
|
||||
import { playAll } from '@utils/play-all';
|
||||
import { creditStore } from '@store/credit-store';
|
||||
import { PlayerController } from '@store/controllers/player-controller';
|
||||
import { SearchController } from '@store/controllers/search-controller';
|
||||
@@ -771,24 +772,29 @@ export class SmartPlaylistDetails
|
||||
// Actions
|
||||
// =================================================================
|
||||
|
||||
private handlePlay() {
|
||||
const filePaths = this.tracks
|
||||
private playableFilePaths(): string[] {
|
||||
return this.tracks
|
||||
.filter((t) => !t.Phantom)
|
||||
.map((t) => t.FilePath);
|
||||
}
|
||||
|
||||
if (filePaths.length === 0) return;
|
||||
|
||||
queueStore.setQueue(filePaths, 0, false, { type: 'smartPlaylist', id: this.playlistId, label: this.playlistName });
|
||||
private handlePlay() {
|
||||
playAll(
|
||||
this.playableFilePaths(),
|
||||
{ type: 'smartPlaylist', id: this.playlistId, label: this.playlistName },
|
||||
false,
|
||||
);
|
||||
}
|
||||
|
||||
private handleShuffle() {
|
||||
const filePaths = this.tracks
|
||||
.filter((t) => !t.Phantom)
|
||||
.map((t) => t.FilePath);
|
||||
|
||||
if (filePaths.length === 0) return;
|
||||
|
||||
queueStore.setQueue(filePaths, 0, true, { type: 'smartPlaylist', id: this.playlistId, label: this.playlistName });
|
||||
// This used to be a no-op when shuffle mode was off: it passed
|
||||
// `shuffleStart` without turning the mode on, so the queue
|
||||
// started at track 1 in order. `playAll` sets the mode first.
|
||||
playAll(
|
||||
this.playableFilePaths(),
|
||||
{ type: 'smartPlaylist', id: this.playlistId, label: this.playlistName },
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
private async handleRefresh() {
|
||||
|
||||
@@ -26,7 +26,10 @@ import type { ContextMenuHost, MenuTarget } from '@utils/context-menu-controller
|
||||
import { PlayerController } from '@store/controllers/player-controller';
|
||||
import { SearchController } from '@store/controllers/search-controller';
|
||||
import '@components/page-header/page-header';
|
||||
import type { SortOption } from '@components/page-header/page-header';
|
||||
import type {
|
||||
SortOption,
|
||||
PageAction,
|
||||
} from '@components/page-header/page-header';
|
||||
import { TrackListController } from '@store/controllers/tracklist-controller';
|
||||
import { FavoritesController } from '@store/controllers/favorites-controller';
|
||||
import { queueStore } from '@store/queue-store';
|
||||
@@ -87,7 +90,9 @@ import {
|
||||
ICON_PLAYLIST,
|
||||
ICON_PLAY_NEXT,
|
||||
ICON_QUEUE,
|
||||
ICON_SHUFFLE,
|
||||
} from '@utils/icon-language';
|
||||
import { playAll } from '@utils/play-all';
|
||||
|
||||
const COLUMN_STORAGE_KEY = 'track-list-column-widths';
|
||||
const SORT_FIELD_KEY = 'track-list-sort-field';
|
||||
@@ -2393,6 +2398,27 @@ export class TrackList
|
||||
.map((c) => ({ id: c.id, label: c.label })),
|
||||
];
|
||||
|
||||
const hasTracks = this.cachedSortedTracks.length > 0;
|
||||
|
||||
const actions: PageAction[] = [
|
||||
{
|
||||
id: 'play-all',
|
||||
label: 'Play all',
|
||||
icon: ICON_PLAY,
|
||||
priority: 1,
|
||||
disabled: !hasTracks,
|
||||
onSelect: this.handlePlayAll,
|
||||
},
|
||||
{
|
||||
id: 'shuffle-all',
|
||||
label: 'Shuffle all',
|
||||
icon: ICON_SHUFFLE,
|
||||
priority: 0,
|
||||
disabled: !hasTracks,
|
||||
onSelect: this.handleShuffleAll,
|
||||
},
|
||||
];
|
||||
|
||||
return html`
|
||||
<page-header
|
||||
heading=${this.externalTracks === undefined ? 'Tracks' : ''}
|
||||
@@ -2404,11 +2430,29 @@ export class TrackList
|
||||
sort-field=${this.sortField ?? ''}
|
||||
sort-direction=${this.sortDirection}
|
||||
search-term=${this.searchCtrl.term}
|
||||
.actions=${actions}
|
||||
@sort-change=${this.onPageHeaderSort}
|
||||
></page-header>
|
||||
`;
|
||||
}
|
||||
|
||||
/** The queue is the list as displayed, in the order the user sees. */
|
||||
private handlePlayAll = (): void => {
|
||||
playAll(
|
||||
this.cachedSortedTracks.map((t) => t.FilePath),
|
||||
this.effectiveQueueSource,
|
||||
false,
|
||||
);
|
||||
};
|
||||
|
||||
private handleShuffleAll = (): void => {
|
||||
playAll(
|
||||
this.cachedSortedTracks.map((t) => t.FilePath),
|
||||
this.effectiveQueueSource,
|
||||
true,
|
||||
);
|
||||
};
|
||||
|
||||
private onPageHeaderSort = (
|
||||
e: CustomEvent<{ field: string; direction: 'asc' | 'desc' }>,
|
||||
) => {
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { queueStore } from '@store/queue-store';
|
||||
import type { QueueSource } from '@store/queue-store';
|
||||
|
||||
/**
|
||||
* Queue a list and start it, optionally shuffled.
|
||||
*
|
||||
* This is the one place that owns what "shuffle this collection" means.
|
||||
* `SetQueue`'s `shuffleStart` only picks a random first track when
|
||||
* shuffle mode is *already* on — it does not turn it on — so the mode
|
||||
* has to be set before the queue, not after. The album page used to
|
||||
* carry that rule privately; the play-all/shuffle-all pair on every
|
||||
* track list now shares it.
|
||||
*/
|
||||
export function playAll(
|
||||
paths: string[],
|
||||
source: QueueSource | undefined,
|
||||
shuffle: boolean,
|
||||
): void {
|
||||
if (paths.length === 0) return;
|
||||
|
||||
if (shuffle && !queueStore.getState().shuffleMode) {
|
||||
queueStore.toggleShuffle();
|
||||
}
|
||||
|
||||
queueStore.setQueue(paths, 0, shuffle, source);
|
||||
}
|
||||
@@ -0,0 +1,373 @@
|
||||
/**
|
||||
* The play-all/shuffle-all pair on every page that lists tracks.
|
||||
*
|
||||
* The pair is driven by one helper (`utils/play-all`) that owns the
|
||||
* one rule the album page already carried: `shuffleStart` does not turn
|
||||
* shuffle on, it only picks a random first track once the mode is on —
|
||||
* so the mode has to be set *before* the queue, not after. The hosts
|
||||
* differ only in where their paths come from and what `Source` they
|
||||
* hand over.
|
||||
*/
|
||||
import { describe, expect, it, beforeEach } from 'vitest';
|
||||
import type { LitElement } from 'lit';
|
||||
|
||||
import '@components/track-list/track-list';
|
||||
import '@components/artist-details/artist-details';
|
||||
import '@components/playlist-details/playlist-details';
|
||||
import '@components/smart-playlist-details/smart-playlist-details';
|
||||
import {
|
||||
stub,
|
||||
flush,
|
||||
resetHarness,
|
||||
calls,
|
||||
lastArgs,
|
||||
emit,
|
||||
} from '@test/support/harness';
|
||||
import {
|
||||
fixture,
|
||||
shadowAll,
|
||||
deepShadow,
|
||||
} from '@test/support/render';
|
||||
|
||||
/** The action button rendered by `<page-header>`, through the nested
|
||||
* shadow roots (track-list → page-header). */
|
||||
function pageAction(
|
||||
host: LitElement,
|
||||
id: string,
|
||||
): HTMLElement | null {
|
||||
return deepShadow<HTMLElement>(host, `[data-testid="page-action-${id}"]`);
|
||||
}
|
||||
|
||||
function setShuffleMode(on: boolean): void {
|
||||
emit('QueueModeChanged', { shuffleMode: on, repeatMode: 'off' });
|
||||
}
|
||||
|
||||
/** The queue's `SetQueue` args, with the shuffle flag and source. */
|
||||
function queued(): {
|
||||
paths: string[];
|
||||
startIndex: number;
|
||||
shuffleStart: boolean;
|
||||
source: unknown;
|
||||
} {
|
||||
const args = lastArgs('queue.Queue.SetQueue');
|
||||
|
||||
if (!args) throw new Error('nothing was queued');
|
||||
|
||||
return {
|
||||
paths: args[0] as string[],
|
||||
startIndex: args[1] as number,
|
||||
shuffleStart: args[2] as boolean,
|
||||
source: args[3],
|
||||
};
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// The track list (Tracks, and every embedding detail view)
|
||||
// =====================================================================
|
||||
|
||||
const PATHS = Array.from({ length: 12 }, (_, i) => `/music/track-${i}.mp3`);
|
||||
|
||||
const LIST = PATHS.map((FilePath, i) => ({
|
||||
FilePath,
|
||||
TrackName: `Track ${i}`,
|
||||
ArtistName: 'An Artist',
|
||||
Album: 'An Album',
|
||||
Duration: 180,
|
||||
}));
|
||||
|
||||
const GENRE_SOURCE = { type: 'genre', id: 0, label: 'Dream Pop' };
|
||||
|
||||
async function embeddedTrackList(): Promise<LitElement> {
|
||||
resetHarness();
|
||||
localStorage.removeItem('track-list-column-widths');
|
||||
|
||||
const el = await fixture<LitElement>('track-list', {
|
||||
externalTracks: LIST,
|
||||
queueSource: GENRE_SOURCE,
|
||||
});
|
||||
|
||||
// Say which order is being asserted rather than inheriting a
|
||||
// persisted sort. See play-in-context.test.ts for the same trap.
|
||||
(el as unknown as { sortField: string | null }).sortField = null;
|
||||
|
||||
el.style.display = 'block';
|
||||
el.style.height = '600px';
|
||||
await flush();
|
||||
await el.updateComplete;
|
||||
await new Promise((r) => setTimeout(r, 60));
|
||||
|
||||
return el;
|
||||
}
|
||||
|
||||
describe('the track-list play-all/shuffle-all pair', () => {
|
||||
beforeEach(() => {
|
||||
resetHarness();
|
||||
localStorage.removeItem('track-list-column-widths');
|
||||
});
|
||||
|
||||
it('renders in the primary Tracks header and is disabled while empty', async () => {
|
||||
const el = await fixture<LitElement>('track-list', {});
|
||||
|
||||
const play = pageAction(el, 'play-all');
|
||||
const shuffle = pageAction(el, 'shuffle-all');
|
||||
|
||||
expect(play).not.toBeNull();
|
||||
expect(shuffle).not.toBeNull();
|
||||
expect(play?.hasAttribute('disabled')).toBe(true);
|
||||
expect(shuffle?.hasAttribute('disabled')).toBe(true);
|
||||
});
|
||||
|
||||
it('renders in an embedded track-list header too', async () => {
|
||||
const el = await embeddedTrackList();
|
||||
|
||||
expect(pageAction(el, 'play-all')).not.toBeNull();
|
||||
expect(pageAction(el, 'shuffle-all')).not.toBeNull();
|
||||
expect(pageAction(el, 'play-all')?.hasAttribute('disabled')).toBe(false);
|
||||
expect(pageAction(el, 'shuffle-all')?.hasAttribute('disabled')).toBe(false);
|
||||
});
|
||||
|
||||
it('Play all queues the displayed list in order, unshuffled', async () => {
|
||||
const el = await embeddedTrackList();
|
||||
|
||||
pageAction(el, 'play-all')!.click();
|
||||
await flush();
|
||||
|
||||
expect(queued()).toEqual({
|
||||
paths: PATHS,
|
||||
startIndex: 0,
|
||||
shuffleStart: false,
|
||||
source: GENRE_SOURCE,
|
||||
});
|
||||
});
|
||||
|
||||
it('Shuffle all turns shuffle on before queueing when the mode is off', async () => {
|
||||
const el = await embeddedTrackList();
|
||||
|
||||
setShuffleMode(false);
|
||||
await flush();
|
||||
|
||||
pageAction(el, 'shuffle-all')!.click();
|
||||
await flush();
|
||||
|
||||
const all = calls();
|
||||
const toggle = all.findLastIndex(
|
||||
(c) => c.path === 'queue.Queue.ToggleShuffle',
|
||||
);
|
||||
const setQueue = all.findLastIndex(
|
||||
(c) => c.path === 'queue.Queue.SetQueue',
|
||||
);
|
||||
|
||||
expect(toggle).toBeGreaterThanOrEqual(0);
|
||||
expect(toggle).toBeLessThan(setQueue);
|
||||
|
||||
expect(queued()).toEqual({
|
||||
paths: PATHS,
|
||||
startIndex: 0,
|
||||
shuffleStart: true,
|
||||
source: GENRE_SOURCE,
|
||||
});
|
||||
});
|
||||
|
||||
it('Shuffle all does not toggle when the mode is already on', async () => {
|
||||
const el = await embeddedTrackList();
|
||||
|
||||
setShuffleMode(true);
|
||||
await flush();
|
||||
|
||||
pageAction(el, 'shuffle-all')!.click();
|
||||
await flush();
|
||||
|
||||
expect(calls('queue.Queue.ToggleShuffle')).toHaveLength(0);
|
||||
|
||||
expect(queued()).toEqual({
|
||||
paths: PATHS,
|
||||
startIndex: 0,
|
||||
shuffleStart: true,
|
||||
source: GENRE_SOURCE,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// =====================================================================
|
||||
// The library artist page
|
||||
// =====================================================================
|
||||
|
||||
const ALBUMS = [
|
||||
{ ID: 3, Name: 'Third', ArtistName: 'Aurora Fields', ArtistMBID: '', MBID: '', CoverArtPath: '', CoverArtSmall: '', CoverArtMedium: '', CoverArtLarge: '', Year: 0, ReleaseYear: 0 },
|
||||
{ ID: 1, Name: 'First', ArtistName: 'Aurora Fields', ArtistMBID: '', MBID: '', CoverArtPath: '', CoverArtSmall: '', CoverArtMedium: '', CoverArtLarge: '', Year: 0, ReleaseYear: 0 },
|
||||
{ ID: 2, Name: 'Second', ArtistName: 'Aurora Fields', ArtistMBID: '', MBID: '', CoverArtPath: '', CoverArtSmall: '', CoverArtMedium: '', CoverArtLarge: '', Year: 0, ReleaseYear: 0 },
|
||||
];
|
||||
|
||||
describe('the artist page play-all pair', () => {
|
||||
beforeEach(() => {
|
||||
resetHarness();
|
||||
stub('explore.Service.GetArtistMBID', '');
|
||||
stub('library.Library.GetAlbumsByArtist', ALBUMS);
|
||||
stub('library.Library.GetFilePathsByAlbums', {
|
||||
'3': ['/a3-1', '/a3-2'],
|
||||
'1': ['/a1'],
|
||||
'2': ['/a2-1', '/a2-2', '/a2-3'],
|
||||
});
|
||||
});
|
||||
|
||||
it('flattens album paths in the album list order', async () => {
|
||||
const el = await fixture<LitElement>('artist-details', {
|
||||
artistId: 7,
|
||||
artistName: 'Aurora Fields',
|
||||
artistMBID: '',
|
||||
});
|
||||
|
||||
await flush();
|
||||
await el.updateComplete;
|
||||
|
||||
const play = shadowAll<HTMLElement>(el, '[data-testid="artist-play-all"]')[0];
|
||||
|
||||
play!.click();
|
||||
await flush();
|
||||
|
||||
expect(queued()).toEqual({
|
||||
paths: ['/a3-1', '/a3-2', '/a1', '/a2-1', '/a2-2', '/a2-3'],
|
||||
startIndex: 0,
|
||||
shuffleStart: false,
|
||||
source: { type: 'artist', id: 7, label: 'Aurora Fields' },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// =====================================================================
|
||||
// A smart playlist
|
||||
// =====================================================================
|
||||
|
||||
function smartPlaylistTracks(n: number) {
|
||||
return Array.from({ length: n }, (_, i) => ({
|
||||
ID: i + 1,
|
||||
FilePath: `/music/track-${i}.mp3`,
|
||||
Title: `Track ${i}`,
|
||||
Artist: 'An Artist',
|
||||
Album: 'An Album',
|
||||
Duration: 180000,
|
||||
CoverArtSmall: `/covers/${i}_sm.jpg`,
|
||||
CoverArtMedium: `/covers/${i}_md.jpg`,
|
||||
CoverArtPath: `/covers/${i}.jpg`,
|
||||
Phantom: false,
|
||||
}));
|
||||
}
|
||||
|
||||
describe('the smart-playlist play-all pair', () => {
|
||||
beforeEach(() => {
|
||||
resetHarness();
|
||||
stub('playlist.Service.GetSmartPlaylistTracks', smartPlaylistTracks(8));
|
||||
stub('playlist.Service.GetSmartPlaylistRules', '{"rules":[]}');
|
||||
stub('playlist.Service.GetAllPlaylists', []);
|
||||
});
|
||||
|
||||
/** The details header's own action row, not a page-header action. */
|
||||
function actionButton(el: LitElement, label: string): HTMLElement {
|
||||
const button = shadowAll<HTMLElement>(el, '.action-button').find(
|
||||
(b) => b.textContent?.trim() === label,
|
||||
);
|
||||
|
||||
if (!button) throw new Error(`no "${label}" action button rendered`);
|
||||
|
||||
return button;
|
||||
}
|
||||
|
||||
it('Shuffle turns the mode on before queueing, so the queue starts shuffled', async () => {
|
||||
const el = await fixture<LitElement>('smart-playlist-details', {
|
||||
playlistId: 1,
|
||||
playlistName: 'A smart playlist',
|
||||
});
|
||||
|
||||
el.style.display = 'block';
|
||||
el.style.height = '600px';
|
||||
await flush();
|
||||
await el.updateComplete;
|
||||
await new Promise((r) => setTimeout(r, 60));
|
||||
|
||||
setShuffleMode(false);
|
||||
await flush();
|
||||
|
||||
actionButton(el, 'Shuffle').click();
|
||||
await flush();
|
||||
|
||||
// The issue's headline fix. `SetQueue`'s `shuffleStart` only picks
|
||||
// a random first track when shuffle mode is already on — it does
|
||||
// not turn it on — so reverting the mode toggle puts the queue back
|
||||
// to track 1 in order while every assertion about the queue's
|
||||
// contents still passes. Order of the calls is the assertion.
|
||||
const all = calls();
|
||||
const toggle = all.findLastIndex(
|
||||
(c) => c.path === 'queue.Queue.ToggleShuffle',
|
||||
);
|
||||
const setQueue = all.findLastIndex(
|
||||
(c) => c.path === 'queue.Queue.SetQueue',
|
||||
);
|
||||
|
||||
expect(toggle).toBeGreaterThanOrEqual(0);
|
||||
expect(toggle).toBeLessThan(setQueue);
|
||||
|
||||
expect(queued()).toEqual({
|
||||
paths: Array.from({ length: 8 }, (_, i) => `/music/track-${i}.mp3`),
|
||||
startIndex: 0,
|
||||
shuffleStart: true,
|
||||
source: { type: 'smartPlaylist', id: 1, label: 'A smart playlist' },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// =====================================================================
|
||||
// A regular playlist
|
||||
// =====================================================================
|
||||
|
||||
function playlistTracks(n: number) {
|
||||
return Array.from({ length: n }, (_, i) => ({
|
||||
ID: i + 1,
|
||||
FilePath: `/music/track-${i}.mp3`,
|
||||
Title: `Track ${i}`,
|
||||
Artist: 'An Artist',
|
||||
Album: 'An Album',
|
||||
Duration: 180000,
|
||||
Phantom: false,
|
||||
}));
|
||||
}
|
||||
|
||||
describe('the playlist play-all pair', () => {
|
||||
beforeEach(() => {
|
||||
resetHarness();
|
||||
stub('playlist.Service.GetPlaylistTracks', playlistTracks(8));
|
||||
stub('playlist.Service.GetAllPlaylists', []);
|
||||
});
|
||||
|
||||
it('offers Shuffle All beside Play All, both through the helper', async () => {
|
||||
const el = await fixture<LitElement>('playlist-details', {
|
||||
playlistId: 1,
|
||||
playlistName: 'A playlist',
|
||||
});
|
||||
|
||||
el.style.display = 'block';
|
||||
el.style.height = '600px';
|
||||
await flush();
|
||||
await el.updateComplete;
|
||||
await new Promise((r) => setTimeout(r, 60));
|
||||
|
||||
const buttons = shadowAll<HTMLElement>(el, '.play-all-button');
|
||||
|
||||
expect(buttons.map((b) => b.textContent?.trim())).toEqual([
|
||||
'Play All',
|
||||
'Shuffle All',
|
||||
]);
|
||||
|
||||
setShuffleMode(false);
|
||||
await flush();
|
||||
|
||||
buttons[1]!.click();
|
||||
await flush();
|
||||
|
||||
expect(queued()).toEqual({
|
||||
paths: Array.from({ length: 8 }, (_, i) => `/music/track-${i}.mp3`),
|
||||
startIndex: 0,
|
||||
shuffleStart: true,
|
||||
source: { type: 'playlist', id: 1, label: 'A playlist' },
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user