diff --git a/e2e/specs/header-action-overflow.spec.ts b/e2e/specs/header-action-overflow.spec.ts index d37b823..a2332d7 100644 --- a/e2e/specs/header-action-overflow.spec.ts +++ b/e2e/specs/header-action-overflow.spec.ts @@ -42,10 +42,10 @@ const ACTIONS = ['Import', 'New Playlist', 'New Smart Playlist']; * because the number this issue is about (a button 48px wider than the * box holding it) is not in the accessibility tree at all. */ -const headerFit = (page: import('@playwright/test').Page) => - page.evaluate(() => { +const headerFit = (page: import('@playwright/test').Page, view = 'playlist-view') => + page.evaluate((tag) => { const root = document - .querySelector('[data-testid="main-content"] playlist-view') + .querySelector(`[data-testid="main-content"] ${tag}`) ?.shadowRoot?.querySelector('page-header')?.shadowRoot; if (!root) return null; @@ -76,7 +76,7 @@ const headerFit = (page: import('@playwright/test').Page) => ...root.querySelectorAll('#page-header-overflow wa-dropdown-item'), ].map((i) => i.textContent?.trim() ?? ''), }; - }); + }, view); test.describe('the page header never clips an action', () => { test.beforeEach(async ({ app }) => { @@ -316,3 +316,58 @@ test.describe('the page header never clips an action', () => { await expect.poll(async () => (await headerFit(app))?.menu).toEqual([]); }); }); + +/** + * The Tracks header carries the play-all/shuffle-all pair (#31), so + * the promise above has to hold for it too — the same per-button + * measurement, one view over. Its two actions are the whole of the + * header's declared set, and the pair is what plays the list the row + * is in, so a button rendered 20px of its 90px is a queue of nothing. + */ +const TRACK_ACTIONS = ['Play all', 'Shuffle all']; + +test.describe('the Tracks header never clips an action', () => { + test.beforeEach(async ({ app }) => { + await app.getByTestId('nav-tracks').click(); + await expect(app.getByTestId('main-content')).toHaveAttribute( + 'data-active-view', + 'tracks', + ); + }); + + test.afterEach(async ({ app }) => { + await app.setViewportSize({ width: 1280, height: 800 }); + }); + + for (const vp of VIEWPORTS) { + test(`every action is reachable at ${vp.name}`, async ({ app }) => { + await app.setViewportSize({ width: vp.width, height: vp.height }); + + await expect + .poll(async () => (await headerFit(app, 'track-list'))?.clipped) + .toEqual([]); + + const fit = (await headerFit(app, 'track-list'))!; + + expect(fit.overflow).toBeLessThanOrEqual(0); + + // Between them, buttons and menu account for both actions — + // not "it fits" but "nothing was dropped to make it fit". + expect([...fit.buttons, ...fit.menu].sort()).toEqual( + [...TRACK_ACTIONS].sort(), + ); + }); + } + + /** + * The pair's names, through the accessibility tree — a shadow query + * measures, but it cannot say what a screen reader is offered. + */ + test('both actions are named controls', async ({ app }) => { + for (const label of TRACK_ACTIONS) { + await expect( + app.getByRole('button', { name: label, exact: true }), + ).toBeVisible(); + } + }); +}); diff --git a/e2e/specs/play-all.spec.ts b/e2e/specs/play-all.spec.ts new file mode 100644 index 0000000..3cd2acd --- /dev/null +++ b/e2e/specs/play-all.spec.ts @@ -0,0 +1,220 @@ +import { test, expect, callBinding, resetEvents, waitForEvent } from '../support/fixtures.js'; + +type Page = import('@playwright/test').Page; + +/** + * Play-all/Shuffle-all, asserted on what the backend queued rather than + * on playback pixels. + * + * `SetQueue` reports the queue through `QueueChanged`, and `GetState` + * says exactly what it holds: the tracks in order, whether shuffle is + * on, and the `Source` the "Playing from" link is built from. That is + * the honest contract here — the buttons are only as good as the queue + * they build, and the queue is only as good as the source it names. + */ + +interface QueueState { + tracks: { filePath: string; title: string }[]; + currentIndex: number; + shuffleMode: boolean; + source: { type: string; id: number; label: string }; +} + +const TRACKS_SOURCE = { type: 'tracks', id: 0, label: 'All Tracks' }; + +const getQueue = (app: Page) => + callBinding(app, 'queue.Queue.GetState'); + +/** The track paths a rendered track list shows, in row order. */ +function displayedPaths(app: Page, scope: string): Promise { + return app + .locator(`${scope} [data-testid="track-row"]`) + .evaluateAll((els) => + els.map((el) => el.getAttribute('data-file-path') ?? ''), + ); +} + +/** Leave shuffle in a known state. The mode persists across specs in + * one backend process, so a test that asserts on it has to set it. */ +async function setShuffleMode(app: Page, on: boolean): Promise { + const state = await getQueue(app); + + if (state.shuffleMode !== on) { + await resetEvents(app); + await callBinding(app, 'queue.Queue.ToggleShuffle'); + await waitForEvent(app, 'QueueModeChanged'); + } +} + +test.describe('play-all/shuffle-all on the track list', () => { + test.beforeEach(async ({ app }) => { + await callBinding(app, 'queue.Queue.Clear').catch(() => { + /* the queue is clearable on every build these specs run against */ + }); + await setShuffleMode(app, false); + }); + + test('Tracks Play all queues the displayed list with an honest source', async ({ + app, + }) => { + await app.getByTestId('nav-tracks').click(); + await expect(app.getByTestId('main-content')).toHaveAttribute( + 'data-active-view', + 'tracks', + ); + await expect( + app.locator('track-list [data-testid="track-row"]').first(), + ).toBeVisible(); + + const paths = await displayedPaths(app, 'track-list'); + + await resetEvents(app); + await app.getByTestId('page-action-play-all').click(); + await waitForEvent(app, 'QueueChanged'); + + const state = await getQueue(app); + + expect(state.tracks.map((t) => t.filePath)).toEqual(paths); + expect(state.currentIndex).toBe(0); + expect(state.shuffleMode).toBe(false); + expect(state.source).toEqual(TRACKS_SOURCE); + }); + + test('Tracks Shuffle all turns shuffle on and keeps the source', async ({ + app, + }) => { + await app.getByTestId('nav-tracks').click(); + await expect( + app.locator('track-list [data-testid="track-row"]').first(), + ).toBeVisible(); + + const paths = await displayedPaths(app, 'track-list'); + + await resetEvents(app); + await app.getByTestId('page-action-shuffle-all').click(); + await waitForEvent(app, 'QueueChanged'); + + const state = await getQueue(app); + + expect(state.tracks.map((t) => t.filePath)).toEqual(paths); + expect(state.shuffleMode).toBe(true); + expect(state.source).toEqual(TRACKS_SOURCE); + }); +}); + +test.describe('play-all on an embedded track list', () => { + test.beforeEach(async ({ app }) => { + await callBinding(app, 'queue.Queue.Clear').catch(() => {}); + await setShuffleMode(app, false); + }); + + test('a genre page queues the genre with its name as the source', async ({ + app, + }) => { + await app.getByTestId('nav-genres').click(); + await expect(app.getByTestId('main-content')).toHaveAttribute( + 'data-active-view', + 'genres', + ); + + const first = app.locator('genres-view .genre-card').first(); + + await expect(first).toBeVisible(); + await first.click(); + + await expect(app.getByTestId('main-content')).toHaveAttribute( + 'data-active-view', + 'genre-details', + ); + await expect( + app.locator('genre-details [data-testid="track-row"]').first(), + ).toBeVisible(); + + const genreName = (await app + .locator('genre-details .genre-title') + .textContent())?.trim(); + const paths = await displayedPaths(app, 'genre-details'); + + await resetEvents(app); + await app + .locator('genre-details [data-testid="page-action-play-all"]') + .click(); + await waitForEvent(app, 'QueueChanged'); + + const state = await getQueue(app); + + expect(state.tracks.map((t) => t.filePath)).toEqual(paths); + expect(state.currentIndex).toBe(0); + expect(state.source).toEqual({ type: 'genre', id: 0, label: genreName }); + }); +}); + +test.describe('play-all on the library artist page', () => { + test.beforeEach(async ({ app }) => { + await callBinding(app, 'queue.Queue.Clear').catch(() => {}); + await setShuffleMode(app, false); + }); + + test('an artist page queues album paths in album order with the artist source', async ({ + app, + }) => { + const artists = await callBinding<{ ID: number; Name: string }[]>( + app, + 'library.Library.GetArtists', + [0], + ); + const first = artists[0]!; + + await app.evaluate( + ([id, name]) => { + document.dispatchEvent( + new CustomEvent('navigate', { + detail: { + view: 'artist-details', + artistId: id, + artistName: name, + }, + bubbles: true, + composed: true, + }), + ); + }, + [first.ID, first.Name] as const, + ); + + await expect(app.getByTestId('main-content')).toHaveAttribute( + 'data-active-view', + 'artist-details', + ); + await expect(app.getByTestId('artist-play-all')).toBeEnabled(); + + const albums = await callBinding<{ ID: number }[]>( + app, + 'library.Library.GetAlbumsByArtist', + [first.Name, 0], + ); + const byAlbum = await callBinding>( + app, + 'library.Library.GetFilePathsByAlbums', + [albums.map((a) => a.ID), 0], + ); + const expected: string[] = []; + + for (const album of albums) { + expected.push(...(byAlbum[String(album.ID)] ?? [])); + } + + await resetEvents(app); + await app.getByTestId('artist-play-all').click(); + await waitForEvent(app, 'QueueChanged'); + + const state = await getQueue(app); + + expect(state.tracks.map((t) => t.filePath)).toEqual(expected); + expect(state.source).toEqual({ + type: 'artist', + id: first.ID, + label: first.Name, + }); + }); +}); diff --git a/e2e/specs/playback.spec.ts b/e2e/specs/playback.spec.ts index fe8aa62..1084d8e 100644 --- a/e2e/specs/playback.spec.ts +++ b/e2e/specs/playback.spec.ts @@ -137,7 +137,7 @@ test.describe('queue', () => { }); test('shuffle and repeat toggles report their state', async ({ app }) => { - const shuffle = app.getByRole('button', { name: 'Shuffle' }); + const shuffle = app.getByRole('button', { name: 'Shuffle', exact: true }); await resetEvents(app); await shuffle.click(); diff --git a/frontend/src/components/artist-details/artist-details.ts b/frontend/src/components/artist-details/artist-details.ts index 12e82d2..261aa4e 100644 --- a/frontend/src/components/artist-details/artist-details.ts +++ b/frontend/src/components/artist-details/artist-details.ts @@ -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 { + 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 { ` : ''} +
+ + +
+ `; } } diff --git a/frontend/src/components/explore-album-details/explore-album-details.ts b/frontend/src/components/explore-album-details/explore-album-details.ts index f534faf..0508a6b 100644 --- a/frontend/src/components/explore-album-details/explore-album-details.ts +++ b/frontend/src/components/explore-album-details/explore-album-details.ts @@ -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. */ diff --git a/frontend/src/components/playlist-details/playlist-details.ts b/frontend/src/components/playlist-details/playlist-details.ts index e110353..f38f359 100644 --- a/frontend/src/components/playlist-details/playlist-details.ts +++ b/frontend/src/components/playlist-details/playlist-details.ts @@ -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()} > - + Play All +
#
diff --git a/frontend/src/components/smart-playlist-details/smart-playlist-details.ts b/frontend/src/components/smart-playlist-details/smart-playlist-details.ts index d68706b..5dd008b 100644 --- a/frontend/src/components/smart-playlist-details/smart-playlist-details.ts +++ b/frontend/src/components/smart-playlist-details/smart-playlist-details.ts @@ -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() { diff --git a/frontend/src/components/track-list/track-list.ts b/frontend/src/components/track-list/track-list.ts index 9e1c31b..11c12f0 100644 --- a/frontend/src/components/track-list/track-list.ts +++ b/frontend/src/components/track-list/track-list.ts @@ -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` `; } + /** 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' }>, ) => { diff --git a/frontend/src/utils/play-all.ts b/frontend/src/utils/play-all.ts new file mode 100644 index 0000000..7f1ca97 --- /dev/null +++ b/frontend/src/utils/play-all.ts @@ -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); +} diff --git a/frontend/test/components/play-all.test.ts b/frontend/test/components/play-all.test.ts new file mode 100644 index 0000000..7961e9f --- /dev/null +++ b/frontend/test/components/play-all.test.ts @@ -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 ``, through the nested + * shadow roots (track-list → page-header). */ +function pageAction( + host: LitElement, + id: string, +): HTMLElement | null { + return deepShadow(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 { + resetHarness(); + localStorage.removeItem('track-list-column-widths'); + + const el = await fixture('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('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('artist-details', { + artistId: 7, + artistName: 'Aurora Fields', + artistMBID: '', + }); + + await flush(); + await el.updateComplete; + + const play = shadowAll(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(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('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('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(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' }, + }); + }); +});