Phase 1 of #63, and the design the issue asks for as one piece is .planning/plans/active/019-android-touch-model.md. **A finger has no second button and no modifier keys**, so the primary action has to be the primary gesture: tap plays the row, and the hold that opened a context menu now enters selection mode with that row selected. Three decisions in it, and two diverge from the report. **The predicate is the pointer, not the platform or the viewport.** `pointerType === 'touch'`, per event, which is already how long-press.ts decided and is the only such test in the frontend. This is #64's rule -- named after the capability -- and it carries #64's warning: keyed on a width, an Android *tablet* at 600px gets click-selects/double-click-plays on a touchscreen, which is the inversion this issue exists to fix, on the platform it exists for. A touchscreen laptop cannot be described by a width at all. Per event, a mouse keeps desktop semantics on the very same row, and there is no second declaration of what a phone does. **There is no double-tap, and the number is why.** The report asks for single tap to play *and* double tap for the menu. Those cannot both be honoured: the first tap of a double tap is indistinguishable from a single tap until the interval expires, so "tap plays" becomes "tap waits". Measured on the device, the play command to TrackChanged is 155/123/85/56/91 ms -- median ~100 -- and the app's own DOUBLE_CLICK_GRACE_MS is 250. That is 3.5x the primary interaction, 250ms of it spent deliberately doing nothing, on every track anyone plays, to reach a menu the hold already reaches. So the menu and the selection action bar are the same surface, which is also the platform's convention and removes a concept rather than adding one. **Tap-to-play and selection mode ship together**, because splitting them is a regression dressed as an increment: a touch user selects by tapping today and acts through the long-press menu, so moving tap to play on its own would leave a window with no way to select forty tracks at all. **What lets this reassign the hold without touching one of the fourteen context menus**: the layer announces `yj-tap` / `yj-long-press` (composed, cancelable) and acts on nothing. A component claims one with preventDefault. An **unclaimed long press still becomes a `contextmenu`**, so the card grids, Explore, the playlist rows and every other menu behave exactly as they did, and only lists that opt in get selection mode. An unclaimed *tap* does nothing at all and the click follows normally, which is what leaves every button in the app alone -- only a claimed tap has its click swallowed, or playing a track would also select it. **And the device found the one thing no browser tier can see.** Chrome 113's Android WebView fires its own `contextmenu` on a long press. long-press.ts stood down when a trusted one arrived, which was right while both paths ended in a context menu; they no longer do, so standing down means the gesture silently does the *old* thing. Measured, before the fix, holding a track row: {"log":["contextmenu isTrusted=true"], "state":{"bar":null,"menuActive":true,"selected":1}} `yj-long-press` was never announced, the menu opened, and all 26 tests passed -- dispatched pointer events do not make a browser synthesise one. So the native event is a **trigger, not a competitor**: the gesture is announced from it and only a claim suppresses it. Unclaimed it propagates untouched, which is the same "browser wins" outcome reached by asking instead of assuming. The tier could not find that and can hold it, because this module has always told its own events apart by identity rather than isTrusted, so an untrusted one from a test takes exactly the browser's path. Verified on the device by *performing* the gestures rather than describing the page -- `adb shell input tap` and `input swipe x y x y 700` reach the WebView as real pointer events, which is new here and is written down in the plan with the pixel mapping. Tap plays; a hold raises the bar with one selected and no menu; a tap toggles to two, back to one, and the mode ends with the last row; an album card still opens its context menu. 29 new tests. The e2e spec is rewritten to assert **both** halves -- the row selects, and a card elsewhere still opens the real menu -- because a spec that only checked the row would pass on a build that had silently broken the other thirteen. Phases 2-4 (swipe to queue, the other three surfaces, and what #67 inherits) are in the plan and not in this commit.
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/touch-gestures.ts is measuring for a long press —
|
|
* 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;
|
|
}
|
|
}
|