Compare commits

..
Author SHA1 Message Date
logan b1ef9d63d4 fix(explore): repaint the tracklist when a track is requested
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 2m34s
CI / e2e (pull_request) Successful in 6m19s
The album page's downloadStore subscription assigned canDownload and
re-synced isRequested, both of which are about the *release group* --
so requesting a **track** changed no reactive property, Lit had no
reason to re-render, and every row's badge kept the plus it was drawn
with. The request was filed and visible in Downloads; the control that
filed it appeared to do nothing, which is what making it a button was
meant to stop.

libraryStatusFor() reads the store at render time, so the fix is to
say so: one explicit requestUpdate on the subscription.

The badge is also no longer revealed on hover. That rule was inherited
from the green ticks it replaced, and it does not survive the reason
those went: a tick marked the common case, this marks the rows that are
*not* here. A mark on the exception is the information, and one that
exists only under the pointer cannot be seen, counted, or reached by a
finger or a keyboard.

Refs #33
2026-08-18 11:02:31 -04:00
3 changed files with 177 additions and 130 deletions
@@ -2,7 +2,6 @@ import { LitElement, html, css, nothing } from 'lit';
import { customElement, property, state, query } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import { designTokens } from '../../styles/tokens.css';
import { srOnly } from '../../styles/sr-only.css';
import {
LookupReleaseGroup,
BrowseReleases,
@@ -290,7 +289,6 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost {
designTokens,
exploreLinkStyles,
contextMenuStyles,
srOnly,
css`
:host {
display: flex;
@@ -664,34 +662,20 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost {
font-weight: 400;
}
/* The request control is only offered where there is
* something to request, and only when the row is being
* attended to — a column of plus signs down a mostly-owned
* album is the clutter the green ticks were.
/* The request control is offered on every row that has
* something to request, and is not revealed on hover.
*
* Hidden with opacity, never display:none or visibility,
* so it keeps its place in the layout (rows do not reflow
* as the pointer moves) and stays in the tab order and the
* accessibility tree. focus-within is what makes it
* reachable without a mouse: tabbing to the button reveals
* it, and the row's own focus reveals it before you get
* there. */
* It used to be transparent until the row was hovered or
* focused, on the reasoning that a column of plus signs is
* clutter. That reasoning was inherited from the green
* ticks it replaced and does not survive the rule those
* were removed for: a tick marked the *common* case, while
* this marks the rows that are **not** here. A mark on
* the exception is the information, and one that appears
* only under the pointer cannot be seen, counted, or found
* by anyone driving this with a finger or a keyboard. */
.track-row .track-request {
flex-shrink: 0;
opacity: 0;
transition: opacity 0.12s ease;
}
.track-row:hover .track-request,
.track-row:focus-within .track-request,
.track-row .track-request:focus-visible {
opacity: 1;
}
@media (prefers-reduced-motion: reduce) {
.track-row .track-request {
transition: none;
}
}
`,
];
@@ -722,9 +706,18 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost {
// The download button only appears once a client is connected,
// so this tracks the provider list rather than assuming.
//
// The explicit `requestUpdate` is what makes a *track*'s badge
// move. `canDownload` and `syncRequested` are both about the
// release group, so neither changes when a row is requested —
// and every row's badge reads `libraryStatusFor(...)` out of
// the store at render time, so with no reactive property
// changed Lit had no reason to re-render and the badge sat on
// a plus for a request that had already been filed.
this.downloadUnsub = downloadStore.subscribe(() => {
this.canDownload = downloadStore.available;
this.syncRequested();
this.requestUpdate();
});
void downloadStore.init().then(() => {
@@ -3035,21 +3028,11 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost {
/* ── Tracklist ── */
/**
* The heading is there and is not drawn.
*
* A list of numbered titles with durations under an album's cover
* does not need a word above it saying what it is — it was the
* only thing on this page labelling something already obvious. But
* the section is a landmark and the page's heading structure runs
* through it, so what goes is the *ink*, not the element: a reader
* jumping by heading still finds the tracklist.
*/
private renderTracklist() {
if (this.loadingReleases) {
return html`
<section>
<h3 class="sr-only">Tracklist</h3>
<h3 class="section-header">Tracklist</h3>
<div class="section-loading">Loading tracks\u2026</div>
</section>
`;
@@ -3062,7 +3045,7 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost {
if (!current) {
return html`
<section>
<h3 class="sr-only">Tracklist</h3>
<h3 class="section-header">Tracklist</h3>
<div class="section-error">
<wa-icon name="triangle-exclamation"></wa-icon>
No release data available.
@@ -3075,7 +3058,7 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost {
if (tracks.length === 0) {
return html`
<section>
<h3 class="sr-only">Tracklist</h3>
<h3 class="section-header">Tracklist</h3>
<div
style="color: var(--yj-text-tertiary, #888); font-size: var(--yj-text-md)"
>
@@ -3091,7 +3074,7 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost {
return html`
<section>
<h3 class="sr-only">Tracklist</h3>
<h3 class="section-header">Tracklist</h3>
<div class="tracklist">
${discNumbers.map((discNum) => {
const discTracks = discMap.get(discNum) ?? [];
@@ -0,0 +1,153 @@
/**
* Asking for a track from an album's tracklist.
*
* The badge on an unowned row is a `<button>` that files a durable
* request, and its state is computed at render time from the download
* store — `libraryStatusFor(owned, mbid)` reads `requestFor(mbid)` out
* of the cached list.
*
* That is what made this fail. The page's `downloadStore` subscription
* assigned `canDownload` and re-synced `isRequested` for the *release
* group*, and neither of those changes when a **track** is requested,
* so Lit saw no reactive property change and never re-rendered. The
* request was filed, the Downloads tab showed it, and the badge stayed
* on a plus reading "not in your library" — a control that appears to
* do nothing, which is exactly what the badge was made a button to stop
* being.
*
* The second assertion is the row's own: the badge is not revealed on
* hover. A mark on the rows you do *not* have is the information on
* this page, and one that only exists under the pointer cannot be seen,
* counted, or reached by a finger.
*/
import { describe, expect, it, beforeEach } from 'vitest';
import type { LitElement } from 'lit';
import '@components/explore-album-details/explore-album-details';
import { stub, flush, resetHarness } from '@test/support/harness';
import { fixture, shadow, shadowAll } from '@test/support/render';
type StoredRequest = {
id: number;
mbid: string;
entity: string;
libraryId: number;
artist: string;
title: string;
state: string;
attempts: number;
};
/** The request list the store reads, mutated by the stubbed AddRequest
* exactly as the backend's own list would be. */
let requests: StoredRequest[] = [];
function track(n: number, owned: boolean) {
return {
position: n,
discNumber: 1,
title: `Track ${n}`,
length: 200000,
mbid: `mbid-${n}`,
inLibrary: owned,
};
}
/** An album with one owned track and one that is not here. */
async function albumWithAnUnownedTrack(): Promise<LitElement> {
const el = await fixture<LitElement>('explore-album-details', {
albumName: 'Glass Harbour',
releaseGroupMBID: 'rg-1',
});
const tracks = [track(1, true), track(2, false)];
stub('library.Library.GetFilePathsByRecordingMBIDs', {
'mbid-1': ['/music/mbid-1.mp3'],
});
Object.assign(el, {
versionEntries: [
{
key: 'v1',
label: '2019',
sublabel: '2 tracks',
tracks,
},
],
selectedVersionKey: 'v1',
loadingReleases: false,
loadingInfo: false,
});
el.requestUpdate();
await flush();
await el.updateComplete;
return el;
}
const badges = (el: LitElement) =>
shadowAll(el, 'library-status-indicator.track-request');
describe('requesting a track from the album tracklist', () => {
beforeEach(async () => {
resetHarness();
requests = [];
stub('library.Library.GetFilePathsByRecordingMBIDs', {});
stub('library.Library.GetFilePathsByAlbums', {});
stub('library.Library.GetAlbumTracks', []);
stub('library.Library.GetAllLibrariesWithTrackCounts', [
{ id: 1, name: 'Music', path: '/music', trackCount: 1 },
]);
stub('download.Service.ProviderKinds', []);
stub('download.Service.ListProviders', []);
stub('download.Service.ListDownloads', []);
stub('download.Service.ListRequests', () => requests);
stub('download.Service.AddRequest', (input: Record<string, unknown>) => {
const id = requests.length + 1;
requests.push({
id,
mbid: String(input.mbid),
entity: String(input.entity),
libraryId: Number(input.libraryId),
artist: String(input.artist ?? ''),
title: String(input.title ?? ''),
state: 'wanted',
attempts: 0,
});
return id;
});
});
it('marks the row as queued without a reload', async () => {
const el = await albumWithAnUnownedTrack();
// Only the unowned row offers one: there is nothing left to ask for
// on a track there is a file for.
const [badge] = badges(el);
expect(badge).toBeTruthy();
expect(badge?.getAttribute('status')).toBe('not-in-library');
shadow<HTMLElement>(badge!, 'button')?.click();
await flush();
await el.updateComplete;
expect(requests.map((r) => r.mbid)).toEqual(['mbid-2']);
expect(badges(el)[0]?.getAttribute('status')).toBe('queued');
});
it('shows the badge without being hovered', async () => {
const el = await albumWithAnUnownedTrack();
const [badge] = badges(el);
// The old rule hid it at `opacity: 0` until `:hover`. Computed
// opacity is the assertion rather than the absence of a rule,
// because the rule could come back under another selector.
expect(getComputedStyle(badge!).opacity).toBe('1');
});
});
@@ -1,89 +0,0 @@
/**
* The tracklist's own heading.
*
* A list of numbered titles with durations, under the album's cover, is
* the one thing on this page that did not need a word above it saying
* what it was — "TRACKLIST" labelled the only thing already obvious.
*
* What goes is the *ink*, not the element. The section is a landmark
* and the page's heading structure runs through it, so a reader moving
* by heading still has to be able to find it, and it is hidden the way
* `sr-only` hides things: `clip-path`, never `display: none`, which
* would take it out of the accessibility tree along with the layout.
*/
import { describe, expect, it, beforeEach } from 'vitest';
import type { LitElement } from 'lit';
import '@components/explore-album-details/explore-album-details';
import { stub, flush, resetHarness } from '@test/support/harness';
import { fixture, shadowAll } from '@test/support/render';
function track(n: number) {
return {
position: n,
discNumber: 1,
title: `Track ${n}`,
length: 200000,
mbid: `mbid-${n}`,
inLibrary: true,
};
}
async function albumPage(): Promise<LitElement> {
const el = await fixture<LitElement>('explore-album-details', {
albumName: 'Glass Harbour',
releaseGroupMBID: 'rg-1',
});
Object.assign(el, {
versionEntries: [
{
key: 'v1',
label: '2019',
sublabel: '2 tracks',
tracks: [track(1), track(2)],
},
],
selectedVersionKey: 'v1',
loadingReleases: false,
loadingInfo: false,
});
el.requestUpdate();
await flush();
await el.updateComplete;
return el;
}
const tracklistHeading = (el: LitElement) =>
shadowAll(el, 'h3').find((h) => h.textContent?.trim() === 'Tracklist');
describe('the album tracklist heading', () => {
beforeEach(() => {
resetHarness();
stub('library.Library.GetFilePathsByRecordingMBIDs', {});
stub('library.Library.GetFilePathsByAlbums', {});
stub('library.Library.GetAlbumTracks', []);
stub('library.Library.GetAllLibrariesWithTrackCounts', []);
stub('download.Service.ProviderKinds', []);
stub('download.Service.ListProviders', []);
stub('download.Service.ListDownloads', []);
stub('download.Service.ListRequests', []);
});
it('is still in the tree', async () => {
expect(tracklistHeading(await albumPage())).toBeTruthy();
});
it('takes up no room on the page', async () => {
const heading = tracklistHeading(await albumPage())!;
const box = heading.getBoundingClientRect();
expect(box.width).toBeLessThanOrEqual(1);
expect(box.height).toBeLessThanOrEqual(1);
// Hidden by clipping, not by removal: display:none and
// visibility:hidden both take it out of the accessibility tree.
expect(getComputedStyle(heading).display).not.toBe('none');
expect(getComputedStyle(heading).visibility).not.toBe('hidden');
});
});