The rest of #186's second table, and one thing it could not have said. .section-toggle 187x15 autotag .folders-menu-trigger 32x18 autotag .back-button 32x32 artist-details Requests / Downloads tabs 85x34, 96x34 .search-mode-tab 89x26, 79x26 explore explore search input 325x18 in a 36px box **back-button was six controls, not one.** The issue names it in artist-details because that is the view the sweep opened; the same declaration is byte-identical in artist-details, genre-details, playlist-details, smart-playlist-details, explore-artist-details and explore-album-details, 32px in all six. So it is styles/back-button. css.ts now, adopted by each, and a source sweep fails on a seventh copy -- because the failure this invites is not a size changing, it is somebody adding a detail view and writing `.back-button` out again, which no device sweep would catch for the same reason this one did not. That is icon-language.test.ts's shape, and the argument for it here is the inverse of the column arrows': one declaration covering 36 controls is cheap to fix, and six declarations of one control are six chances to miss five. It is a real 44px box rather than padding with the width handed back: a detail header runs no fit pass, and this button has a visible background, so a hit area larger than the circle would be a control bigger than it looks. The size is #55's, reached there for the same reason -- "the way out is 44px on a phone". **The explore search box was two faults.** The row was 36px *and* the input inside it was 18, so eight pixels at each edge were not a target at all: a tap near the top of the box landed on the container and did nothing. The container is 44 and the input stretches to it. **The Downloads tabs take padding rather than a min-size**, because the mark for the selected tab is its bottom border -- a min-size centres the label and leaves the underline 10px beneath it. page-action-check-now (113x29) is in that table and is not here: it is a PageAction, so #195 raised it with the rest of the header's actions and touch-targets.test.ts already covers it. **The Downloads tabs needed a min-size as well as the padding, and CI is what said so.** Padding alone made them 44px on this machine and **43px in the container**: the total is 13 + 13 + 2 + whatever line box the font gives 13px text, and ubuntu:24.04's is a pixel shorter than Arch's. A height computed from a font's line box is not a height you control -- which is #195's "stated as a property on the strength of one engine" one layer down, in the same PR that recorded it. The padding stays, because it is what keeps the underline against the label; the min-size is the floor. Caught by the new test rather than by a person, which is the half of this that worked. Verified on the device, sweeping each view the way the issue was filed: explore, downloads, autotag and artist-details now report **one** control under the floor apiece, and it is the skip link, which #186 already ruled out as keyboard-only. .search-mode-tab 89x44 and 79x44, the search input 325x44, the Downloads tabs 85x44 and 96x44, .section-toggle 174x44, .folders-menu-trigger 44x44, .back-button 44x44. All 12 new tests fail on main, the source sweep naming all six copies. make ui-test 1041 pass; make e2e 236 pass on chromium, which is half an answer -- CI had the other half, and used it. Closes #186
363 lines
9.9 KiB
TypeScript
363 lines
9.9 KiB
TypeScript
import { LitElement, html, css } from 'lit';
|
|
import {
|
|
customElement,
|
|
property,
|
|
state,
|
|
} from 'lit/decorators.js';
|
|
import * as library from '@go/library/models.js';
|
|
import { LibraryController } from '@store/controllers/library-controller';
|
|
import {
|
|
GetArtistImageURL,
|
|
GetArtistImageCachedPath,
|
|
GetArtistMBID,
|
|
} from '@go/explore/service.js';
|
|
import '@awesome.me/webawesome/dist/components/icon/icon.js';
|
|
import '@components/cover-grid/cover-grid.js';
|
|
import { designTokens } from '../../styles/tokens.css';
|
|
import { backButton } from '../../styles/back-button.css';
|
|
|
|
@customElement('artist-details')
|
|
export class ArtistDetails extends LitElement {
|
|
@property({ type: Number, attribute: 'artist-id' })
|
|
artistId = 0;
|
|
|
|
@property({ type: String, attribute: 'artist-name' })
|
|
artistName = '';
|
|
|
|
@property({ type: String, attribute: 'artist-mbid' })
|
|
artistMBID = '';
|
|
|
|
@state()
|
|
private albums: library.Album[] = [];
|
|
|
|
@state()
|
|
private loading = true;
|
|
|
|
@state()
|
|
private artistImageURL = '';
|
|
|
|
private libraryCtrl = new LibraryController(this);
|
|
|
|
/** Tracks the store's cached array reference to detect refreshes. */
|
|
private lastAlbumsRef: library.Album[] | null = null;
|
|
|
|
static override styles = [designTokens, backButton, css`
|
|
:host {
|
|
display: flex;
|
|
flex-direction: column;
|
|
overflow: hidden;
|
|
height: 100%;
|
|
box-sizing: border-box;
|
|
}
|
|
|
|
/* ====================================
|
|
* Header
|
|
* ==================================== */
|
|
|
|
.artist-header {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 20px;
|
|
padding: 16px 20px;
|
|
flex-shrink: 0;
|
|
border-bottom: 1px solid
|
|
var(
|
|
--yj-border-subtle,
|
|
rgba(255, 255, 255, 0.06)
|
|
);
|
|
}
|
|
|
|
.back-button wa-icon {
|
|
font-size: 16px; /* back button — outside type scale */
|
|
}
|
|
|
|
.artist-avatar {
|
|
width: 80px;
|
|
height: 80px;
|
|
border-radius: 50%;
|
|
overflow: hidden;
|
|
background: linear-gradient(
|
|
135deg,
|
|
var(--yj-bg-overlay, #404040) 0%,
|
|
var(--yj-bg-surface, #282828) 100%
|
|
);
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.artist-avatar img {
|
|
width: 100%;
|
|
height: 100%;
|
|
object-fit: cover;
|
|
}
|
|
|
|
.artist-avatar .initial {
|
|
color: var(
|
|
--yj-text-secondary,
|
|
#b3b3b3
|
|
);
|
|
font-size: 32px; /* large decorative initial */
|
|
font-weight: 600;
|
|
text-transform: uppercase;
|
|
user-select: none;
|
|
line-height: 1;
|
|
}
|
|
|
|
.artist-info {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 4px;
|
|
min-width: 0;
|
|
}
|
|
|
|
.artist-title {
|
|
font-size: 24px; /* page title — outside type scale */
|
|
font-weight: 700;
|
|
color: var(--yj-text-primary, #fff);
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
margin: 0;
|
|
line-height: 1.2;
|
|
}
|
|
|
|
.album-count {
|
|
font-size: var(--yj-text-md);
|
|
color: var(
|
|
--yj-text-secondary,
|
|
#b3b3b3
|
|
);
|
|
}
|
|
|
|
/* ====================================
|
|
* Content
|
|
* ==================================== */
|
|
|
|
.content {
|
|
flex: 1;
|
|
overflow: hidden;
|
|
}
|
|
|
|
cover-grid {
|
|
width: 100%;
|
|
height: 100%;
|
|
}
|
|
|
|
`];
|
|
|
|
override connectedCallback() {
|
|
super.connectedCallback();
|
|
this.loadAlbums();
|
|
this.loadArtistImage();
|
|
}
|
|
|
|
override updated() {
|
|
const cached = this.libraryCtrl.cachedAlbums;
|
|
|
|
if (
|
|
cached !== null &&
|
|
cached !== this.lastAlbumsRef
|
|
) {
|
|
this.lastAlbumsRef = cached;
|
|
this.loadAlbums();
|
|
}
|
|
}
|
|
|
|
/* ================================================================
|
|
* Data loading
|
|
* ================================================================ */
|
|
|
|
private async loadArtistImage() {
|
|
// Resolve MBID from tags if not provided via attribute.
|
|
let mbid = this.artistMBID;
|
|
|
|
if (!mbid && this.artistName) {
|
|
try {
|
|
mbid = await GetArtistMBID(this.artistName);
|
|
} catch {
|
|
return;
|
|
}
|
|
}
|
|
|
|
if (!mbid) return;
|
|
|
|
try {
|
|
// Disk cache first — the resolving call below is MB →
|
|
// Wikidata → Wikipedia → Wikimedia, and most artists this
|
|
// page renders have been resolved once already.
|
|
const cached = await GetArtistImageCachedPath(mbid);
|
|
|
|
if (cached) {
|
|
this.artistImageURL = cached;
|
|
|
|
return;
|
|
}
|
|
|
|
const url = await GetArtistImageURL(mbid);
|
|
|
|
if (url) {
|
|
this.artistImageURL = url;
|
|
}
|
|
} catch {
|
|
// No image — avatar stays as initial letter.
|
|
}
|
|
}
|
|
|
|
private async loadAlbums() {
|
|
if (!this.artistId) return;
|
|
|
|
// Try to populate instantly from the
|
|
// cached all-albums list if available.
|
|
const cached =
|
|
this.libraryCtrl.getAlbumsByArtistNameCached(
|
|
this.artistName,
|
|
);
|
|
|
|
if (cached !== null && cached.length > 0) {
|
|
this.albums = cached;
|
|
this.loading = false;
|
|
}
|
|
|
|
// Always run the authoritative backend
|
|
// query. If we got a cache hit above,
|
|
// this serves as a correction pass.
|
|
try {
|
|
const albums =
|
|
await this.libraryCtrl.getAlbumsByArtist(
|
|
this.artistName,
|
|
);
|
|
|
|
const result = albums ?? [];
|
|
|
|
// Skip update if the cached result
|
|
// is identical (same IDs in same
|
|
// order) to avoid a re-render.
|
|
if (!this.albumsMatch(result)) {
|
|
this.albums = result;
|
|
}
|
|
} catch (error) {
|
|
console.error(
|
|
'Error loading artist albums:',
|
|
error,
|
|
);
|
|
|
|
// Only overwrite if we had no cached
|
|
// result to fall back on.
|
|
if (cached === null) {
|
|
this.albums = [];
|
|
}
|
|
} finally {
|
|
this.loading = false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Compare two album lists by ID to avoid
|
|
* unnecessary re-renders when the backend
|
|
* result matches the cached approximation.
|
|
*/
|
|
private albumsMatch(
|
|
incoming: library.Album[],
|
|
): boolean {
|
|
const current = this.albums;
|
|
|
|
if (current.length !== incoming.length) {
|
|
return false;
|
|
}
|
|
|
|
for (let i = 0; i < current.length; i++) {
|
|
if (
|
|
current[i]!.ID !== incoming[i]!.ID
|
|
) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
/* ================================================================
|
|
* Navigation
|
|
* ================================================================ */
|
|
|
|
private navigateBack() {
|
|
this.dispatchEvent(
|
|
new CustomEvent('navigate', {
|
|
bubbles: true,
|
|
composed: true,
|
|
detail: { view: 'artists' },
|
|
}),
|
|
);
|
|
}
|
|
|
|
/* ================================================================
|
|
* Helpers
|
|
* ================================================================ */
|
|
|
|
private getInitial(name: string): string {
|
|
if (!name) return '?';
|
|
|
|
return name.charAt(0).toUpperCase();
|
|
}
|
|
|
|
/* ================================================================
|
|
* Rendering
|
|
* ================================================================ */
|
|
|
|
override render() {
|
|
const albumCount = this.albums.length;
|
|
const albumLabel =
|
|
albumCount === 1 ? 'album' : 'albums';
|
|
|
|
return html`
|
|
<div class="artist-header">
|
|
<button
|
|
class="back-button"
|
|
@click=${this.navigateBack}
|
|
title="Back to artists"
|
|
aria-label="Back to artists"
|
|
>
|
|
<wa-icon
|
|
name="arrow-left"
|
|
></wa-icon>
|
|
</button>
|
|
<div class="artist-avatar">
|
|
${this.artistImageURL
|
|
? html`<img
|
|
src="${this.artistImageURL}"
|
|
alt="${this.artistName}"
|
|
/>`
|
|
: html`<span class="initial">
|
|
${this.getInitial(this.artistName)}
|
|
</span>`}
|
|
</div>
|
|
<div class="artist-info">
|
|
<h1
|
|
class="artist-title"
|
|
title="${this.artistName}"
|
|
>
|
|
${this.artistName}
|
|
</h1>
|
|
${!this.loading
|
|
? html`
|
|
<span
|
|
class="album-count"
|
|
>
|
|
${albumCount}
|
|
${albumLabel}
|
|
</span>
|
|
`
|
|
: ''}
|
|
</div>
|
|
</div>
|
|
<div class="content">
|
|
<cover-grid
|
|
.externalAlbums=${this.albums}
|
|
></cover-grid>
|
|
</div>
|
|
`;
|
|
}
|
|
}
|