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 = {
'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 {
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`
void this.load(),
},
] satisfies PageAction[]}
>