Files
yellowjacket/frontend/src/components/home-view/home-view.ts
T
yonlu 977f624123
CI / check (push) Skipped
CI / e2e (push) Skipped
fix(home): gate the card play button on the device having hover
The play button on a home shelf's cover cards is revealed by :hover, and
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 the user was reaching for a different one.

It is gated on `(hover: hover) and (pointer: fine)` rather than on width,
so it is absent on any touch device and present on a desktop with a small
window. A phone user taps the album and plays from the detail view, so
nothing replaces it.

The default outside the query is display:none, not opacity:0. An
opacity-0 button still takes taps and is still in the accessibility tree,
so leaving the reveal as the only guarded part would keep the hit area
for a control the phone can never show.

The test asserts the parsed stylesheet rather than rendering as a phone,
and says so: CDP's Emulation.setEmulatedMedia does not reach this tier's
iframe, so matchMedia still answers `hover: hover` after it is set. The
regression worth catching is someone hoisting the rule back out of the
query as a tidy-up — a change no desktop assertion can see.

Closes #68
2026-08-19 14:08:06 -04:00

428 lines
14 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 '@awesome.me/webawesome/dist/components/button/button.js';
import { GetShelves } from '@go/home/service.js';
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">
<!-- "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. -->
<wa-button
slot="actions"
size="small"
appearance="plain"
title="Reshuffle the suggestions"
@click=${() => void this.load()}
>
<wa-icon slot="start" name="shuffle"></wa-icon>
Shuffle suggestions
</wa-button>
</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;
}
}