Playlists slotted three buttons totalling 390px into a header that gets 700px at 900x600, so "New Smart Playlist" rendered 114 of its 162px with the queue closed, and 158 of 162 at the 800x600 enforced minimum. On a phone none of the three could be reached at all, which is what the Android report said. Plan 018's size matrix promises the opposite: no action is ever unreachable at any supported size. The header could not fix that for slotted markup, and that is a fact about the API rather than an effort estimate — a component cannot move another component's light-DOM children into a dropdown and keep their behaviour, and arbitrary markup offers nothing generic to render as a menu item. So a host passes `PageAction[]` and the header chooses the rendering; the slot survives for markup a data list cannot express, at the stated cost that a slotted action does not collapse. All three hosts that slot actions migrated, which also normalises the plain-<button>/<wa-button> split between them onto one shape the header styles — and lets it measure a button that has already upgraded, rather than a wa-button whose shadow DOM arrives in its own first update. Four things in it are load-bearing: - Every measuring pass starts from all-visible, so the collapsed set is a pure function of the current width and an action comes back when the window grows. It flips `hidden` imperatively rather than re-rendering between steps, or the intermediate state paints and the fix flashes the overflow it exists to prevent. - "Fits" means nothing is clipped, not that the header does not overflow. Once the title can ellipsis it absorbs the pressure and scrollWidth reports a perfect fit while the heading reads "Playlis…" — this bug moved from the button to the title, and invisible to the same measurement that missed it the first time. - New Playlist has the highest priority because it is the drop target and a closed menu cannot be one. `PageAction.drop` therefore carries the host's own handlers; the affordance is absent from the overflow rather than approximated there. - The overflow trigger is a named button with aria-expanded and an aria-controls naming a panel that is always in the DOM, and the keyboard model is the shared `MenuKeyboard`. `layout-overflow.spec.ts` passes on the broken build — it asserts the shell needs no sideways scrolling, and clipping inside a component is invisible to it, which is why this defect survived a spec named for it. The new spec measures each button against its own header at four viewports and asserts buttons plus menu account for every declared action, without which it would pass vacuously on a build rendering none. Closes #69
430 lines
15 KiB
TypeScript
430 lines
15 KiB
TypeScript
import { LitElement, html, css, nothing } from 'lit';
|
|
import { customElement, state } from 'lit/decorators.js';
|
|
import '@awesome.me/webawesome/dist/components/icon/icon.js';
|
|
import { GetShelves } from '@go/home/service.js';
|
|
import { ICON_SHUFFLE } from '@utils/icon-language';
|
|
import type { PageAction } from '@components/page-header/page-header';
|
|
import { GetAlbumTracks } from '@go/library/library.js';
|
|
import type * as home from '@go/home/models.js';
|
|
import type * as library from '@go/library/models.js';
|
|
import { queueStore } from '@store/queue-store';
|
|
import { libraryStore } from '@store/library-store';
|
|
import { EventsOn } from '@runtime/runtime';
|
|
import { Events } from '../../events';
|
|
import '@components/page-header/page-header';
|
|
import { designTokens } from '../../styles/tokens.css';
|
|
import { ViewLifecycleMixin } from '../../utils/view-lifecycle';
|
|
|
|
type Shelf = home.Shelf;
|
|
|
|
/** Icon per shelf kind — a row's reason, at a glance. */
|
|
const KIND_ICONS: Record<string, string> = {
|
|
'recently-played': 'clock-rotate-left',
|
|
'recently-added': 'star',
|
|
'most-played': 'repeat',
|
|
unplayed: 'box-open',
|
|
stale: 'hourglass-half',
|
|
artist: 'user',
|
|
genre: 'masks-theater',
|
|
random: 'shuffle',
|
|
};
|
|
|
|
/**
|
|
* The home page: a set of ways *into* the library, rather than another
|
|
* view of it.
|
|
*
|
|
* Everything here is computed by `backend/home`, including the reason
|
|
* each row exists, so the rows can change with the user's listening
|
|
* without the frontend holding a second opinion about what "on repeat"
|
|
* means. This component's job is only to render them and to make a
|
|
* cover do the two things a cover should: open the album, or play it.
|
|
*/
|
|
@customElement('home-view')
|
|
export class HomeView extends ViewLifecycleMixin(LitElement) {
|
|
@state() private shelves: Shelf[] = [];
|
|
|
|
@state() private loading = true;
|
|
|
|
@state() private failed = false;
|
|
|
|
/** Generation of the library the shelves were built from. */
|
|
private builtFromGeneration = -1;
|
|
|
|
private unsubScan?: () => void;
|
|
|
|
static override styles = [
|
|
designTokens,
|
|
css`
|
|
:host {
|
|
display: block;
|
|
height: 100%;
|
|
overflow-y: auto;
|
|
padding: 24px 20px 40px;
|
|
box-sizing: border-box;
|
|
}
|
|
|
|
/* The header brings its own padding, and the host already
|
|
has some — without this the title sits indented from the
|
|
lede directly beneath it. */
|
|
page-header {
|
|
margin: -24px -20px 4px;
|
|
}
|
|
|
|
.lede {
|
|
margin: 0 0 24px;
|
|
font-size: var(--yj-text-md, 13px);
|
|
color: var(--yj-text-secondary, #b3b3b3);
|
|
}
|
|
|
|
.shelf {
|
|
margin-bottom: 28px;
|
|
}
|
|
|
|
.shelf-head {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
margin-bottom: 2px;
|
|
}
|
|
|
|
.shelf-title {
|
|
font-size: var(--yj-text-xl, 18px);
|
|
font-weight: 700;
|
|
color: var(--yj-text-primary, #fff);
|
|
}
|
|
|
|
.shelf-sub {
|
|
margin: 0 0 10px;
|
|
font-size: var(--yj-text-sm, 12px);
|
|
color: var(--yj-text-tertiary, #888);
|
|
}
|
|
|
|
.row {
|
|
display: grid;
|
|
grid-auto-flow: column;
|
|
grid-auto-columns: 160px;
|
|
gap: 14px;
|
|
overflow-x: auto;
|
|
padding-bottom: 6px;
|
|
scrollbar-width: thin;
|
|
}
|
|
|
|
.card {
|
|
background: none;
|
|
border: none;
|
|
padding: 0;
|
|
text-align: left;
|
|
cursor: pointer;
|
|
color: inherit;
|
|
display: block;
|
|
}
|
|
|
|
.art {
|
|
position: relative;
|
|
width: 160px;
|
|
height: 160px;
|
|
border-radius: 6px;
|
|
overflow: hidden;
|
|
background: var(--yj-bg-surface, #181818);
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
color: var(--yj-text-tertiary, #888);
|
|
}
|
|
|
|
.art img {
|
|
width: 100%;
|
|
height: 100%;
|
|
object-fit: cover;
|
|
display: block;
|
|
}
|
|
|
|
/* An album with no cover used to be a small dim icon on a
|
|
surface the same colour as the page, so a shelf read as
|
|
having holes in it (H-9) — while the Albums and Artists
|
|
grids both drew a letter tile. This is that tile, and the
|
|
gradient is what makes it a tile rather than a gap. */
|
|
.art .placeholder {
|
|
width: 100%;
|
|
height: 100%;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
background: linear-gradient(
|
|
135deg,
|
|
var(--yj-bg-overlay, #404040) 0%,
|
|
var(--yj-bg-surface, #282828) 100%
|
|
);
|
|
color: var(--yj-text-secondary, #b3b3b3);
|
|
font-size: 48px;
|
|
font-weight: 300;
|
|
user-select: none;
|
|
}
|
|
|
|
/*
|
|
* The hover play button is a *hover* affordance, so it is
|
|
* gated on the device having hover rather than on width. A
|
|
* touch long-press synthesises a hover state in the WebView,
|
|
* so on a phone it flashed into view during the 500ms hold
|
|
* that utils/long-press.ts is measuring for a context menu —
|
|
* a control appearing because you were reaching for a
|
|
* different one. A phone user taps the album and plays from
|
|
* the detail view, so there is nothing to replace it with.
|
|
*
|
|
* display:none outside the query rather than opacity:0 on
|
|
* its own: an opacity-0 button still takes taps and is
|
|
* still in the accessibility tree, so the invisible control
|
|
* would keep the hit area it was never meant to have on
|
|
* touch. Everything else stays inside, so the desktop
|
|
* animation is unchanged.
|
|
*/
|
|
.play {
|
|
display: none;
|
|
}
|
|
|
|
@media (hover: hover) and (pointer: fine) {
|
|
.play {
|
|
position: absolute;
|
|
right: 8px;
|
|
bottom: 8px;
|
|
width: 38px;
|
|
height: 38px;
|
|
border: none;
|
|
border-radius: 50%;
|
|
background: var(--yj-accent, #ffd43b);
|
|
color: var(--yj-accent-fg, #000);
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
cursor: pointer;
|
|
opacity: 0;
|
|
transform: translateY(6px);
|
|
transition: opacity 0.12s ease, transform 0.12s ease;
|
|
}
|
|
|
|
.card:hover .play,
|
|
.card:focus-within .play {
|
|
opacity: 1;
|
|
transform: translateY(0);
|
|
}
|
|
}
|
|
|
|
.name {
|
|
margin-top: 8px;
|
|
font-size: var(--yj-text-md, 13px);
|
|
color: var(--yj-text-primary, #fff);
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
}
|
|
|
|
.artist {
|
|
font-size: var(--yj-text-sm, 12px);
|
|
color: var(--yj-text-tertiary, #888);
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
}
|
|
|
|
.empty {
|
|
padding: 48px 20px;
|
|
text-align: center;
|
|
color: var(--yj-text-tertiary, #888);
|
|
font-size: var(--yj-text-lg, 15px);
|
|
}
|
|
`,
|
|
];
|
|
|
|
protected override onViewActivate(): void {
|
|
// Reloaded on arrival rather than kept live: the shelves are a
|
|
// judgement about the whole library, so the answer while the
|
|
// page is off screen is of no interest to anyone.
|
|
void this.load();
|
|
|
|
// A finished scan changes what every shelf would say, and the
|
|
// home page is the view most likely to be sitting open while
|
|
// one runs.
|
|
this.unsubScan = EventsOn(Events.LibraryScanComplete, () => {
|
|
void this.load();
|
|
});
|
|
this.whileActive(() => {
|
|
this.unsubScan?.();
|
|
this.unsubScan = undefined;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Rebuild when the view is shown again after the library changed.
|
|
* Navigation keeps this element alive (see `frontend/index.ts`), so
|
|
* without this the shelves would be as old as the session.
|
|
*/
|
|
override willUpdate(): void {
|
|
if (
|
|
!this.loading
|
|
&& this.builtFromGeneration !== libraryStore.changeGeneration
|
|
) {
|
|
void this.load();
|
|
}
|
|
}
|
|
|
|
private async load(): Promise<void> {
|
|
this.builtFromGeneration = libraryStore.changeGeneration;
|
|
this.loading = true;
|
|
|
|
try {
|
|
this.shelves = (await GetShelves()) ?? [];
|
|
this.failed = false;
|
|
} catch (err) {
|
|
console.error('Could not build the home page:', err);
|
|
this.failed = true;
|
|
} finally {
|
|
this.loading = false;
|
|
}
|
|
}
|
|
|
|
override render() {
|
|
return html`
|
|
<page-header
|
|
heading="Home"
|
|
.actions=${[
|
|
{
|
|
// "Shuffle" alone was two different controls
|
|
// with one name: this one and the transport's
|
|
// shuffle mode. They were never on screen
|
|
// together until the app started landing on
|
|
// Home (H-8), and a cached view is in the
|
|
// accessibility tree either way.
|
|
id: 'shuffle-suggestions',
|
|
label: 'Shuffle suggestions',
|
|
icon: ICON_SHUFFLE,
|
|
title: 'Reshuffle the suggestions',
|
|
onSelect: () => void this.load(),
|
|
},
|
|
] satisfies PageAction[]}
|
|
></page-header>
|
|
<p class="lede">Somewhere to start listening.</p>
|
|
${this.renderBody()}
|
|
`;
|
|
}
|
|
|
|
private renderBody() {
|
|
if (this.loading && this.shelves.length === 0) {
|
|
return html`<div class="empty">Looking through your library\u2026</div>`;
|
|
}
|
|
|
|
if (this.failed) {
|
|
return html`<div class="empty">
|
|
Could not read your library just now.
|
|
</div>`;
|
|
}
|
|
|
|
if (this.shelves.length === 0) {
|
|
return html`<div class="empty">
|
|
Nothing to suggest yet \u2014 add a music folder under Settings
|
|
and the shelves fill in once it has been scanned.
|
|
</div>`;
|
|
}
|
|
|
|
return this.shelves.map((shelf) => this.renderShelf(shelf));
|
|
}
|
|
|
|
private renderShelf(shelf: Shelf) {
|
|
return html`
|
|
<section class="shelf" data-kind=${shelf.kind}>
|
|
<div class="shelf-head">
|
|
<wa-icon name=${KIND_ICONS[shelf.kind] ?? 'compact-disc'}></wa-icon>
|
|
<span class="shelf-title">${shelf.title}</span>
|
|
</div>
|
|
<p class="shelf-sub">${shelf.subtitle}</p>
|
|
<div class="row">
|
|
${(shelf.albums ?? []).map((album) => this.renderCard(album))}
|
|
</div>
|
|
</section>
|
|
`;
|
|
}
|
|
|
|
private renderCard(album: library.Album) {
|
|
const art = album.CoverArtMedium || album.CoverArtSmall || album.CoverArtPath;
|
|
|
|
return html`
|
|
<div
|
|
class="card"
|
|
role="button"
|
|
tabindex="0"
|
|
title="${album.Name}${album.ArtistName ? ` \u2014 ${album.ArtistName}` : ''}"
|
|
@click=${() => this.openAlbum(album)}
|
|
@keydown=${(e: KeyboardEvent) => this.onCardKey(e, album)}
|
|
>
|
|
<div class="art">
|
|
${art
|
|
? html`<img src=${art} alt="" loading="lazy" decoding="async" />`
|
|
: html`<div class="placeholder" aria-hidden="true">
|
|
${album.Name.charAt(0).toUpperCase()}
|
|
</div>`}
|
|
<button
|
|
class="play"
|
|
title="Play this album"
|
|
aria-label="Play ${album.Name}"
|
|
@click=${(e: Event) => {
|
|
e.stopPropagation();
|
|
void this.playAlbum(album);
|
|
}}
|
|
>
|
|
<wa-icon name="play"></wa-icon>
|
|
</button>
|
|
</div>
|
|
<div class="name">${album.Name}</div>
|
|
${album.ArtistName
|
|
? html`<div class="artist">${album.ArtistName}</div>`
|
|
: nothing}
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
private onCardKey(e: KeyboardEvent, album: library.Album): void {
|
|
if (e.key !== 'Enter' && e.key !== ' ') return;
|
|
|
|
e.preventDefault();
|
|
this.openAlbum(album);
|
|
}
|
|
|
|
private openAlbum(album: library.Album): void {
|
|
this.dispatchEvent(
|
|
new CustomEvent('navigate', {
|
|
bubbles: true,
|
|
composed: true,
|
|
detail: {
|
|
view: 'explore-album-details',
|
|
releaseGroupMBID: album.MBID || '',
|
|
albumName: album.Name,
|
|
artistName: album.ArtistName,
|
|
localAlbumId: album.ID,
|
|
},
|
|
}),
|
|
);
|
|
}
|
|
|
|
private async playAlbum(album: library.Album): Promise<void> {
|
|
try {
|
|
const tracks = await GetAlbumTracks(album.ID, libraryStore.libraryFilter());
|
|
const paths = (tracks ?? []).map((t) => t.FilePath).filter(Boolean);
|
|
|
|
if (paths.length === 0) return;
|
|
|
|
queueStore.setQueue(paths, 0, true, {
|
|
type: 'album',
|
|
id: album.ID,
|
|
label: album.Name,
|
|
});
|
|
} catch (err) {
|
|
console.error('Could not play that album:', err);
|
|
}
|
|
}
|
|
}
|
|
|
|
declare global {
|
|
interface HTMLElementTagNameMap {
|
|
'home-view': HomeView;
|
|
}
|
|
}
|