beginnings of artist grid and artist details view

This commit is contained in:
2026-02-22 19:17:27 -05:00
parent 30bbdd7c53
commit 9dabfb81c5
15 changed files with 1186 additions and 10 deletions
@@ -0,0 +1,297 @@
import { LitElement, html, css } from 'lit';
import {
customElement,
property,
state,
} from 'lit/decorators.js';
import { EventsOn } from '@runtime/runtime';
import { library } from '@go/models';
import { LibraryController } from '@store/controllers/library-controller';
import { Events } from '../../events';
import '@awesome.me/webawesome/dist/components/icon/icon.js';
import '@components/cover-grid/cover-grid.js';
@customElement('artist-details')
export class ArtistDetails extends LitElement {
@property({ type: Number, attribute: 'artist-id' })
artistId = 0;
@property({ type: String, attribute: 'artist-name' })
artistName = '';
@state()
private albums: library.Album[] = [];
@state()
private loading = true;
private libraryCtrl = new LibraryController(this);
private cancelScanComplete?: () => void;
static override styles = css`
:host {
display: flex;
flex-direction: column;
overflow: hidden;
height: 100%;
}
/* ====================================
* Header
* ==================================== */
.artist-header {
display: flex;
align-items: center;
gap: 20px;
padding: 16px 20px;
flex-shrink: 0;
border-bottom: 1px solid
var(
--yj-border-subtle,
rgba(255, 255, 255, 0.06)
);
}
.back-button {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border: none;
border-radius: 50%;
background: var(
--yj-bg-overlay,
rgba(255, 255, 255, 0.06)
);
color: var(--yj-text-primary, #fff);
cursor: pointer;
flex-shrink: 0;
transition: background-color 0.15s ease;
}
.back-button:hover {
background: var(
--yj-bg-hover,
rgba(255, 255, 255, 0.12)
);
}
.back-button wa-icon {
font-size: 16px;
}
.artist-avatar {
width: 80px;
height: 80px;
border-radius: 50%;
overflow: hidden;
background: linear-gradient(
135deg,
var(--yj-bg-overlay, #404040) 0%,
var(--yj-bg-surface, #282828) 100%
);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.artist-avatar .initial {
color: var(
--yj-text-secondary,
#b3b3b3
);
font-size: 32px;
font-weight: 600;
text-transform: uppercase;
user-select: none;
line-height: 1;
}
.artist-info {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
}
.artist-title {
font-size: 24px;
font-weight: 700;
color: var(--yj-text-primary, #fff);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin: 0;
line-height: 1.2;
}
.album-count {
font-size: 13px;
color: var(
--yj-text-secondary,
#b3b3b3
);
}
/* ====================================
* Content
* ==================================== */
.content {
flex: 1;
overflow: hidden;
}
cover-grid {
width: 100%;
height: 100%;
}
.loading-message,
.empty-message {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
color: var(
--yj-text-secondary,
#b3b3b3
);
font-size: 14px;
}
`;
override connectedCallback() {
super.connectedCallback();
this.loadAlbums();
this.cancelScanComplete = EventsOn(
Events.LibraryScanComplete,
() => this.loadAlbums(),
);
}
override disconnectedCallback() {
super.disconnectedCallback();
this.cancelScanComplete?.();
}
/* ================================================================
* Data loading
* ================================================================ */
private async loadAlbums() {
if (!this.artistId) return;
try {
this.loading = true;
const albums =
await this.libraryCtrl.getAlbumsByArtist(
this.artistId,
);
this.albums = albums ?? [];
} catch (error) {
console.error(
'Error loading artist albums:',
error,
);
this.albums = [];
} finally {
this.loading = false;
}
}
/* ================================================================
* Navigation
* ================================================================ */
private navigateBack() {
this.dispatchEvent(
new CustomEvent('navigate', {
bubbles: true,
composed: true,
detail: { view: 'artists' },
}),
);
}
/* ================================================================
* Helpers
* ================================================================ */
private getInitial(name: string): string {
if (!name) return '?';
return name.charAt(0).toUpperCase();
}
/* ================================================================
* Rendering
* ================================================================ */
override render() {
const albumCount = this.albums.length;
const albumLabel =
albumCount === 1 ? 'album' : 'albums';
return html`
<div class="artist-header">
<button
class="back-button"
@click=${this.navigateBack}
title="Back to artists"
aria-label="Back to artists"
>
<wa-icon
name="arrow-left"
></wa-icon>
</button>
<div class="artist-avatar">
<span class="initial">
${this.getInitial(
this.artistName,
)}
</span>
</div>
<div class="artist-info">
<h1
class="artist-title"
title="${this.artistName}"
>
${this.artistName}
</h1>
${!this.loading
? html`
<span
class="album-count"
>
${albumCount}
${albumLabel}
</span>
`
: ''}
</div>
</div>
<div class="content">
${this.loading
? html`
<div
class="loading-message"
>
Loading albums...
</div>
`
: html`
<cover-grid
.externalAlbums=${this
.albums}
></cover-grid>
`}
</div>
`;
}
}
@@ -0,0 +1,518 @@
import { LitElement, html, css } from 'lit';
import { customElement, state } from 'lit/decorators.js';
import { EventsOn } from '@runtime/runtime';
import '@lit-labs/virtualizer';
import { grid } from '@lit-labs/virtualizer/layouts/grid.js';
import { library } from '@go/models';
import { LibraryController } from '@store/controllers/library-controller';
import { SearchController } from '@store/controllers/search-controller';
import { Events } from '../../events';
import '@awesome.me/webawesome/dist/components/icon/icon.js';
/** Pixels to change card width per scroll tick. */
const ZOOM_STEP = 16;
/** localStorage key for persisted artist card size. */
const CARD_SIZE_KEY = 'artists-view-card-size';
/** Card size limits. */
const CARD_SIZE_MIN = 100;
const CARD_SIZE_MAX = 350;
const CARD_SIZE_DEFAULT = 176;
/**
* Grid entry for the virtualized artist grid.
*/
interface ArtistEntry {
artist: library.Artist;
index: number;
}
@customElement('artists-view')
export class ArtistsView extends LitElement {
private libraryCtrl = new LibraryController(this);
private searchCtrl = new SearchController(this);
private cancelScanComplete?: () => void;
private wheelListenerAttached = false;
@state()
private artists: library.Artist[] = [];
@state()
private loading = true;
@state()
private cardSize: number = CARD_SIZE_DEFAULT;
// Fixed grid spacing constants.
private static readonly GRID_GAP = 8;
private static readonly GRID_PADDING = 8;
private static readonly CARD_PADDING = 5;
private get imageSize(): number {
return this.cardSize - ArtistsView.CARD_PADDING * 2;
}
private get cardTextHeight(): number {
const w = this.cardSize;
if (w < 160) return 30;
if (w > 250) return 42;
return 36;
}
/** Wheel handler reference for add/remove. */
private wheelHandler = (e: WheelEvent) => {
this.onWheel(e);
};
private gridLayout = this.createGridLayout();
private createGridLayout() {
const w = this.cardSize ?? CARD_SIZE_DEFAULT;
const h = w + this.cardTextHeight;
const gap = ArtistsView.GRID_GAP;
const pad = ArtistsView.GRID_PADDING;
return grid({
itemSize: {
width: `${w}px`,
height: `${h}px`,
},
gap: `${gap}px`,
padding: `${pad}px`,
justify: 'center',
});
}
/** Filtered artists based on search term. */
private get filteredArtists(): library.Artist[] {
const term =
this.searchCtrl.term.toLowerCase();
if (!term) {
return this.artists;
}
return this.artists.filter((a) =>
a.Name.toLowerCase().includes(term),
);
}
/** Build grid entries from filtered artists. */
private get gridEntries(): ArtistEntry[] {
return this.filteredArtists.map(
(artist, index) => ({
artist,
index,
}),
);
}
static override styles = css`
:host {
display: flex;
flex-direction: column;
overflow: hidden;
position: relative;
}
.grid-scroll-container {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
}
lit-virtualizer {
width: 100%;
min-height: 100%;
}
.artist-card {
display: flex;
flex-direction: column;
align-items: center;
padding: 5px;
border-radius: 8px;
cursor: pointer;
transition:
background-color 0.15s ease,
transform 0.1s ease;
overflow: hidden;
}
.artist-card:hover {
background-color: var(
--yj-bg-overlay,
rgba(255, 255, 255, 0.06)
);
}
.artist-card:active {
transform: scale(0.97);
}
.avatar-container {
width: var(--avatar-size);
height: var(--avatar-size);
border-radius: 50%;
overflow: hidden;
background: linear-gradient(
135deg,
var(--yj-bg-overlay, #404040) 0%,
var(--yj-bg-surface, #282828) 100%
);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.avatar-placeholder {
color: var(
--yj-text-secondary,
#b3b3b3
);
font-size: var(--placeholder-font, 48px);
font-weight: 600;
text-transform: uppercase;
user-select: none;
line-height: 1;
}
.artist-name {
width: 100%;
text-align: center;
font-size: var(--artist-name-font, 14px);
font-weight: 500;
color: var(--yj-text-primary, #fff);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
padding: var(--artist-name-pad, 6px) 2px 0;
line-height: 1.3;
}
.loading-message,
.empty-message {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
color: var(
--yj-text-secondary,
#b3b3b3
);
font-size: 14px;
}
`;
override connectedCallback() {
super.connectedCallback();
this.loadCardSize();
this.loadArtists();
this.cancelScanComplete = EventsOn(
Events.LibraryScanComplete,
() => this.loadArtists(),
);
}
override disconnectedCallback() {
super.disconnectedCallback();
this.cancelScanComplete?.();
this.detachWheelListener();
}
override updated() {
this.updateSizeProperties();
this.ensureWheelListener();
this.updateGridLayout();
}
/* ================================================================
* Data loading
* ================================================================ */
private async loadArtists() {
try {
this.loading = true;
const artists =
await this.libraryCtrl.getArtists();
this.artists = artists ?? [];
} catch (error) {
console.error(
'Error loading artists:',
error,
);
this.artists = [];
} finally {
this.loading = false;
}
}
/* ================================================================
* Card size (zoom)
* ================================================================ */
private loadCardSize(): void {
try {
const stored =
localStorage.getItem(CARD_SIZE_KEY);
if (stored !== null) {
const parsed = parseInt(stored, 10);
if (!Number.isNaN(parsed)) {
this.cardSize = Math.max(
CARD_SIZE_MIN,
Math.min(
CARD_SIZE_MAX,
parsed,
),
);
}
}
} catch {
// localStorage may be unavailable.
}
}
private saveCardSize(): void {
try {
localStorage.setItem(
CARD_SIZE_KEY,
String(this.cardSize),
);
} catch {
// localStorage may be unavailable.
}
}
private setCardSize(size: number): void {
const clamped = Math.round(
Math.max(
CARD_SIZE_MIN,
Math.min(CARD_SIZE_MAX, size),
),
);
if (clamped === this.cardSize) return;
this.cardSize = clamped;
this.saveCardSize();
}
/* ================================================================
* Wheel zoom (Ctrl+scroll)
* ================================================================ */
private onWheel(e: WheelEvent) {
if (!e.ctrlKey) return;
e.preventDefault();
const delta =
e.deltaY < 0 ? ZOOM_STEP : -ZOOM_STEP;
this.setCardSize(this.cardSize + delta);
}
private ensureWheelListener() {
const container = this.shadowRoot?.querySelector(
'.grid-scroll-container',
);
if (container && !this.wheelListenerAttached) {
container.addEventListener(
'wheel',
this.wheelHandler as EventListener,
{ passive: false },
);
this.wheelListenerAttached = true;
}
}
private detachWheelListener() {
const container = this.shadowRoot?.querySelector(
'.grid-scroll-container',
);
if (container && this.wheelListenerAttached) {
container.removeEventListener(
'wheel',
this.wheelHandler as EventListener,
);
this.wheelListenerAttached = false;
}
}
/* ================================================================
* Grid layout
* ================================================================ */
private lastLayoutWidth = 0;
private updateGridLayout() {
if (this.cardSize === this.lastLayoutWidth) {
return;
}
this.lastLayoutWidth = this.cardSize;
this.gridLayout = this.createGridLayout();
}
/* ================================================================
* Dynamic size properties
* ================================================================ */
private updateSizeProperties() {
const w = this.cardSize;
if (w < 160) {
this.style.setProperty(
'--artist-name-font',
'12px',
);
this.style.setProperty(
'--artist-name-pad',
'4px',
);
} else if (w > 250) {
this.style.setProperty(
'--artist-name-font',
'15px',
);
this.style.setProperty(
'--artist-name-pad',
'8px',
);
} else {
this.style.setProperty(
'--artist-name-font',
'14px',
);
this.style.setProperty(
'--artist-name-pad',
'6px',
);
}
}
/* ================================================================
* Artist card click
* ================================================================ */
private onArtistClick(artist: library.Artist) {
this.dispatchEvent(
new CustomEvent('navigate', {
bubbles: true,
composed: true,
detail: {
view: 'artist-details',
artistId: artist.ID,
artistName: artist.Name,
},
}),
);
}
/* ================================================================
* Helpers
* ================================================================ */
private getArtistInitial(name: string): string {
if (!name) return '?';
return name.charAt(0).toUpperCase();
}
/* ================================================================
* Rendering
* ================================================================ */
private renderArtistCard(
entry: ArtistEntry,
) {
const { artist } = entry;
const imgSize = this.imageSize;
const placeholderFont = Math.round(
imgSize * 0.38,
);
return html`
<div
class="artist-card"
tabindex="0"
role="button"
aria-label="${artist.Name}"
style="
--avatar-size: ${imgSize}px;
--placeholder-font: ${placeholderFont}px;
"
@click=${() =>
this.onArtistClick(artist)}
@keydown=${(e: KeyboardEvent) => {
if (
e.key === 'Enter' ||
e.key === ' '
) {
e.preventDefault();
this.onArtistClick(artist);
}
}}
>
<div class="avatar-container">
<span class="avatar-placeholder">
${this.getArtistInitial(
artist.Name,
)}
</span>
</div>
<div
class="artist-name"
title="${artist.Name}"
>
${artist.Name}
</div>
</div>
`;
}
override render() {
if (this.loading) {
return html`
<div class="loading-message">
Loading artists...
</div>
`;
}
const entries = this.gridEntries;
if (entries.length === 0) {
return html`
<div class="empty-message">
${this.searchCtrl.term
? 'No artists match your search.'
: 'No artists in library.'}
</div>
`;
}
return html`
<div class="grid-scroll-container">
<lit-virtualizer
.items=${entries}
.renderItem=${(
entry: ArtistEntry,
) =>
this.renderArtistCard(entry)}
.layout=${this.gridLayout}
></lit-virtualizer>
</div>
`;
}
}
@@ -1,5 +1,10 @@
import { LitElement, html, css, nothing } from 'lit';
import { customElement, state, query } from 'lit/decorators.js';
import {
customElement,
property,
state,
query,
} from 'lit/decorators.js';
import { EventsOn } from '@runtime/runtime';
import '@lit-labs/virtualizer';
import type {
@@ -124,6 +129,15 @@ type SortDirection = 'asc' | 'desc';
@customElement('cover-grid')
export class CoverGrid extends LitElement {
/**
* When set, the grid displays these albums instead of
* fetching all albums from the library store. The
* component also skips the LibraryScanComplete listener
* since the parent is responsible for reloading.
*/
@property({ type: Array, attribute: false })
externalAlbums?: library.Album[];
private libraryCtrl = new LibraryController(this);
private searchCtrl = new SearchController(this);
private cancelScanComplete?: () => void;
@@ -871,10 +885,16 @@ export class CoverGrid extends LitElement {
super.connectedCallback();
this.restoreSortPreferences();
this.loadAlbums();
this.cancelScanComplete = EventsOn(
Events.LibraryScanComplete,
() => this.loadAlbums(),
);
// Skip the scan listener when driven by an
// external album list — the parent manages
// reloading.
if (!this.externalAlbums) {
this.cancelScanComplete = EventsOn(
Events.LibraryScanComplete,
() => this.loadAlbums(),
);
}
document.addEventListener(
'click',
this.closeHandler,
@@ -949,6 +969,15 @@ export class CoverGrid extends LitElement {
) {
super.willUpdate(changed);
// When the parent provides a new external album
// list, update local albums and reset selection.
if (changed.has('externalAlbums') && this.externalAlbums) {
this.albums = this.externalAlbums;
this.selectedAlbums = new Set();
this.lastSelectedAlbumIndex = null;
this.loading = false;
}
// When switching albums, expandedTracks
// briefly goes to []. Exit split mode so
// the single virtualizer takes over while
@@ -1468,10 +1497,13 @@ export class CoverGrid extends LitElement {
try {
this.loading = true;
const albums =
await this.libraryCtrl.getAlbums();
// When driven by an external album list,
// skip the backend fetch entirely.
const albums = this.externalAlbums
?? (await this.libraryCtrl.getAlbums())
?? [];
this.albums = albums ?? [];
this.albums = albums;
this.selectedAlbums = new Set();
this.lastSelectedAlbumIndex = null;
} catch (error) {