feat(home): populate the home page with start-listening shelves
The sidebar had a Home item that fell through to "Coming soon". What was missing was not another view of the library — four of those exist, sorted and complete — but the opposite: a complete, sorted library is exactly what gives you nothing to play, because every entry point into it is alphabetical and identical every time you open the app. So a shelf is a *reason*, not a filter. Each one answers a different question you might be asking when you do not know what you want (what was I listening to, what is new, what do I keep coming back to, what have I forgotten, what fits, what would I never pick myself) and each says which question it answered — a row of covers with no explanation is just another grid. Two consequences run through it. Shelves are built from what the user actually did — play counts, last played, import order — with random sampling only where there is no signal to use, so randomness is the fallback rather than the design. And a shelf with nothing behind it is omitted instead of rendered empty: a fresh library legitimately gets three, and an empty row labelled "on repeat" would be a lie. The queries return album ids and nothing else, joined back to GetAllAlbumsWithDetails in Go, so the album projection keeps having one definition rather than one per shelf.
This commit is contained in:
@@ -24,6 +24,7 @@ import '@components/first-run-wizard/first-run-wizard.ts';
|
||||
import '@components/jobs/job-indicator.ts';
|
||||
import '@components/jobs/jobs-view.ts';
|
||||
import '@components/downloads-view/downloads-view.ts';
|
||||
import '@components/home-view/home-view.ts';
|
||||
import '@awesome.me/webawesome/dist/styles/themes/default.css';
|
||||
import '@awesome.me/webawesome/dist/components/icon/icon.js';
|
||||
import { setBasePath } from '@awesome.me/webawesome/dist/webawesome.js';
|
||||
@@ -57,6 +58,7 @@ setBasePath('/dist/webawesome');
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const VIEW_TAGS: Record<string, string> = {
|
||||
home: 'home-view',
|
||||
tracks: 'track-list',
|
||||
albums: 'cover-grid',
|
||||
artists: 'artists-view',
|
||||
|
||||
@@ -0,0 +1,376 @@
|
||||
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';
|
||||
import { GetAlbumTracks } from '@go/library/Library';
|
||||
import type { home, library } from '@go/models';
|
||||
import { queueStore } from '@store/queue-store';
|
||||
import { libraryStore } from '@store/library-store';
|
||||
import { EventsOn } from '@runtime/runtime';
|
||||
import { Events } from '../../events';
|
||||
import { designTokens } from '../../styles/tokens.css';
|
||||
|
||||
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 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;
|
||||
}
|
||||
|
||||
header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 12px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: var(--yj-text-primary, #fff);
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.play {
|
||||
position: absolute;
|
||||
right: 8px;
|
||||
bottom: 8px;
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
background: var(--yj-accent, #ffd43b);
|
||||
color: #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);
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
override connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
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();
|
||||
});
|
||||
}
|
||||
|
||||
override disconnectedCallback(): void {
|
||||
super.disconnectedCallback();
|
||||
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`
|
||||
<header>
|
||||
<h1>Home</h1>
|
||||
<wa-button
|
||||
size="small"
|
||||
appearance="plain"
|
||||
title="Reshuffle the suggestions"
|
||||
@click=${() => void this.load()}
|
||||
>
|
||||
<wa-icon slot="start" name="shuffle"></wa-icon>
|
||||
Shuffle
|
||||
</wa-button>
|
||||
</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" />`
|
||||
: html`<wa-icon name="compact-disc"></wa-icon>`}
|
||||
<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);
|
||||
const paths = (tracks ?? []).map((t) => t.FilePath).filter(Boolean);
|
||||
|
||||
if (paths.length === 0) return;
|
||||
|
||||
queueStore.setQueue(paths, 0, true);
|
||||
} catch (err) {
|
||||
console.error('Could not play that album:', err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'home-view': HomeView;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* The home page renders whatever `backend/home` decided, and nothing
|
||||
* else: the shelves, their stated reasons, and two things a cover can
|
||||
* do. So these tests are about the contract between the two — that a
|
||||
* shelf's reason is displayed rather than swallowed, that a card opens
|
||||
* the album it names, and that a play button plays it instead of
|
||||
* opening it.
|
||||
*/
|
||||
import { describe, expect, it, beforeEach } from 'vitest';
|
||||
|
||||
import '@components/home-view/home-view';
|
||||
import { stub, calls, lastArgs, stubFailure } from '@test/support/harness';
|
||||
import { fixture, shadow, shadowAll, texts, text } from '@test/support/render';
|
||||
|
||||
function album(id: number, name: string, artist = 'Artist') {
|
||||
return {
|
||||
ID: id,
|
||||
Name: name,
|
||||
ArtistName: artist,
|
||||
MBID: '',
|
||||
Year: 2000,
|
||||
ReleaseYear: 2000,
|
||||
CoverArtPath: '',
|
||||
CoverArtSmall: '',
|
||||
CoverArtMedium: '',
|
||||
CoverArtLarge: '',
|
||||
ArtistMBID: '',
|
||||
};
|
||||
}
|
||||
|
||||
const SHELVES = [
|
||||
{
|
||||
id: 'recently-played',
|
||||
kind: 'recently-played',
|
||||
title: 'Pick up where you left off',
|
||||
subtitle: 'The last albums you played',
|
||||
albums: [album(1, 'Kid A'), album(2, 'Amnesiac')],
|
||||
},
|
||||
{
|
||||
id: 'genre-Doom Jazz',
|
||||
kind: 'genre',
|
||||
title: 'Doom Jazz',
|
||||
subtitle: 'Because your library is full of it',
|
||||
albums: [album(3, 'Black Ships')],
|
||||
},
|
||||
];
|
||||
|
||||
describe('home view', () => {
|
||||
beforeEach(() => {
|
||||
stub('home.Service.GetShelves', SHELVES);
|
||||
stub('library.Library.GetAlbumTracks', [
|
||||
{ FilePath: '/music/1.mp3' },
|
||||
{ FilePath: '/music/2.mp3' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('renders a row per shelf, each with the reason it exists', async () => {
|
||||
const el = await fixture('home-view');
|
||||
await el.updateComplete;
|
||||
|
||||
expect(texts(el, '.shelf-title')).toEqual([
|
||||
'Pick up where you left off',
|
||||
'Doom Jazz',
|
||||
]);
|
||||
|
||||
// The subtitle is the whole difference between a shelf and a grid.
|
||||
expect(texts(el, '.shelf-sub')).toEqual([
|
||||
'The last albums you played',
|
||||
'Because your library is full of it',
|
||||
]);
|
||||
});
|
||||
|
||||
it('keys each row by the kind the backend assigned', async () => {
|
||||
const el = await fixture('home-view');
|
||||
await el.updateComplete;
|
||||
|
||||
expect(
|
||||
shadowAll(el, '.shelf').map((s) => s.getAttribute('data-kind')),
|
||||
).toEqual(['recently-played', 'genre']);
|
||||
});
|
||||
|
||||
it('opens the album a card names, by local id', async () => {
|
||||
const el = await fixture('home-view');
|
||||
await el.updateComplete;
|
||||
|
||||
const seen: unknown[] = [];
|
||||
el.addEventListener('navigate', (e) => seen.push((e as CustomEvent).detail));
|
||||
|
||||
shadow<HTMLElement>(el, '.card')!.click();
|
||||
|
||||
expect(seen).toEqual([
|
||||
{
|
||||
view: 'explore-album-details',
|
||||
releaseGroupMBID: '',
|
||||
albumName: 'Kid A',
|
||||
artistName: 'Artist',
|
||||
localAlbumId: 1,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('plays the album from the play button without navigating', async () => {
|
||||
const el = await fixture('home-view');
|
||||
await el.updateComplete;
|
||||
|
||||
const seen: unknown[] = [];
|
||||
el.addEventListener('navigate', (e) => seen.push(e));
|
||||
|
||||
shadow<HTMLElement>(el, '.play')!.click();
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
|
||||
expect(seen).toEqual([]);
|
||||
expect(lastArgs('library.Library.GetAlbumTracks')).toEqual([1]);
|
||||
expect(lastArgs('queue.Queue.SetQueue')).toEqual([
|
||||
['/music/1.mp3', '/music/2.mp3'],
|
||||
0,
|
||||
true,
|
||||
]);
|
||||
});
|
||||
|
||||
it('says so rather than rendering an empty page when there is nothing', async () => {
|
||||
stub('home.Service.GetShelves', []);
|
||||
|
||||
const el = await fixture('home-view');
|
||||
await el.updateComplete;
|
||||
|
||||
expect(text(el, '.empty')).toContain('Nothing to suggest yet');
|
||||
});
|
||||
|
||||
it('reports a backend failure instead of pretending the library is empty', async () => {
|
||||
stubFailure('home.Service.GetShelves');
|
||||
|
||||
const el = await fixture('home-view');
|
||||
await el.updateComplete;
|
||||
await el.updateComplete;
|
||||
|
||||
expect(text(el, '.empty')).toContain('Could not read your library');
|
||||
});
|
||||
|
||||
it('rebuilds on demand', async () => {
|
||||
const el = await fixture('home-view');
|
||||
await el.updateComplete;
|
||||
|
||||
const before = calls('home.Service.GetShelves').length;
|
||||
|
||||
shadow<HTMLElement>(el, 'wa-button')!.click();
|
||||
await el.updateComplete;
|
||||
|
||||
expect(calls('home.Service.GetShelves').length).toBe(before + 1);
|
||||
});
|
||||
});
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
|
||||
// This file is automatically generated. DO NOT EDIT
|
||||
import {home} from '../models';
|
||||
|
||||
export function GetShelves():Promise<Array<home.Shelf>>;
|
||||
Executable
+7
@@ -0,0 +1,7 @@
|
||||
// @ts-check
|
||||
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
|
||||
// This file is automatically generated. DO NOT EDIT
|
||||
|
||||
export function GetShelves() {
|
||||
return window['go']['home']['Service']['GetShelves']();
|
||||
}
|
||||
@@ -1381,6 +1381,49 @@ export namespace explore {
|
||||
|
||||
}
|
||||
|
||||
export namespace home {
|
||||
|
||||
export class Shelf {
|
||||
id: string;
|
||||
kind: string;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
albums: library.Album[];
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new Shelf(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.id = source["id"];
|
||||
this.kind = source["kind"];
|
||||
this.title = source["title"];
|
||||
this.subtitle = source["subtitle"];
|
||||
this.albums = this.convertValues(source["albums"], library.Album);
|
||||
}
|
||||
|
||||
convertValues(a: any, classs: any, asMap: boolean = false): any {
|
||||
if (!a) {
|
||||
return a;
|
||||
}
|
||||
if (a.slice && a.map) {
|
||||
return (a as any[]).map(elem => this.convertValues(elem, classs));
|
||||
} else if ("object" === typeof a) {
|
||||
if (asMap) {
|
||||
for (const key of Object.keys(a)) {
|
||||
a[key] = new classs(a[key]);
|
||||
}
|
||||
return a;
|
||||
}
|
||||
return new classs(a);
|
||||
}
|
||||
return a;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export namespace jobs {
|
||||
|
||||
export class Caps {
|
||||
|
||||
Reference in New Issue
Block a user