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
5 changed files with 177 additions and 137 deletions
@@ -111,32 +111,13 @@ const gridStyles = css`
scale: 0.95;
}
/* Title and year on one line, and only the title truncates.
The year used to be part of the same run of text, so it was the
first thing an ellipsis ate: a card wide enough for a long album
name never showed its year, and browsing by year showed years
only for the albums with short names -- the sort said one thing
and the cards showed another.
A flex row rather than a second line, because the card's height
is what the virtualizer measures rows by. */
.album-name {
font-size: var(--album-name-font, 14px);
font-weight: 400;
color: var(--yj-text-primary, #fff);
display: flex;
justify-content: center;
align-items: baseline;
gap: 0.35em;
min-width: 0;
}
.album-title {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
}
.artist-name {
@@ -150,8 +131,6 @@ const gridStyles = css`
.album-year {
color: var(--yj-text-tertiary, #888);
flex: 0 0 auto;
white-space: nowrap;
}
/* ========================================
@@ -1898,10 +1898,10 @@ export class CoverGrid
class="album-name"
title="${album.Name}"
>
<span class="album-title">${album.Name}</span
>${album.Year
? html`<span class="album-year"
>(${album.Year})</span
${album.Name}${album.Year
? html`
<span class="album-year">
(${album.Year})</span
>`
: nothing}
</div>
@@ -662,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;
}
}
`,
];
@@ -720,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(() => {
@@ -1,87 +0,0 @@
/**
* The year on an album card survives a long album name.
*
* The year used to be part of the same run of text as the title, inside
* one `text-overflow: ellipsis` box — so it was the first thing the
* ellipsis ate. A card wide enough for a long name never showed its
* year at all, which means sorting the grid *by year* showed years only
* for the albums with short names: the sort said one thing and the
* cards showed another.
*
* The fix is a flex row in which only the title truncates, rather than
* a second line, because the card's height is what the virtualizer
* measures rows by.
*/
import { describe, expect, it, beforeEach } from 'vitest';
import type { LitElement } from 'lit';
import '@components/cover-grid/cover-grid';
import { emit, stub, flush, resetHarness } from '@test/support/harness';
import { Events } from '../../src/events';
import { fixture, shadowAll } from '@test/support/render';
const LONG =
'The Rise and Fall of a Midwest Princess in the Key of Everything';
/** Long names throughout: the fault only shows on a card under
* pressure, and a grid of "Album 3" proves nothing. */
const ALBUMS = Array.from({ length: 12 }, (_, i) => ({
ID: i + 1,
Name: `${LONG} ${i + 1}`,
ArtistName: 'Aurora Fields',
Year: 2019 + (i % 5),
}));
/** Give the virtualizer a viewport; a zero-height host renders nothing. */
function sized(el: HTMLElement): void {
el.style.display = 'block';
el.style.height = '600px';
el.style.width = '900px';
}
async function settle(el: LitElement): Promise<void> {
await flush();
await el.updateComplete;
await new Promise((r) => setTimeout(r, 80));
}
describe('the album cards year', () => {
beforeEach(() => {
resetHarness();
stub('library.Library.GetAlbums', ALBUMS);
stub('library.Library.GetTracks', []);
emit(Events.LibraryScanComplete);
});
it('is rendered on every card, however long the name', async () => {
const el = await fixture<LitElement>('cover-grid');
sized(el);
await settle(el);
const cards = shadowAll(el, '.album-card');
const years = shadowAll(el, '.album-year');
expect(cards.length).toBeGreaterThan(0);
expect(years).toHaveLength(cards.length);
expect(years.every((y) => /^\(\d{4}\)$/.test(y.textContent!.trim()))).toBe(
true,
);
});
it('is not what the ellipsis eats', async () => {
const el = await fixture<LitElement>('cover-grid');
sized(el);
await settle(el);
const year = shadowAll(el, '.album-year')[0]!;
const title = shadowAll(el, '.album-title')[0]!;
// The title is the box that gives way...
expect(title.scrollWidth).toBeGreaterThan(title.clientWidth);
// ...and the year keeps every pixel it asked for.
expect(year.clientWidth).toBeGreaterThan(0);
expect(year.scrollWidth).toBeLessThanOrEqual(year.clientWidth + 1);
});
});
@@ -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');
});
});