diff --git a/backend/database/sql/queries/artists.sql b/backend/database/sql/queries/artists.sql
index 23ea4e0..a16b201 100644
--- a/backend/database/sql/queries/artists.sql
+++ b/backend/database/sql/queries/artists.sql
@@ -30,3 +30,11 @@ DELETE FROM artists;
-- name: GetAllArtists :many
SELECT * FROM artists
ORDER BY name;
+
+-- name: GetAlbumArtists :many
+SELECT DISTINCT a.id, a.name
+FROM artists a
+JOIN artist_credit_artist aca ON aca.artist_id = a.id
+JOIN artist_credit ac ON ac.id = aca.credit_id
+JOIN release_groups rg ON rg.album_artist_credit_id = ac.id
+ORDER BY a.name;
diff --git a/backend/database/sql/queries/release_groups.sql b/backend/database/sql/queries/release_groups.sql
index 3689553..70668c9 100644
--- a/backend/database/sql/queries/release_groups.sql
+++ b/backend/database/sql/queries/release_groups.sql
@@ -63,3 +63,24 @@ LEFT JOIN (
GROUP BY rgr.release_group_id
) fallback_ac ON fallback_ac.release_group_id = rg.id
ORDER BY rg.name;
+
+-- name: GetAlbumsByArtist :many
+SELECT
+ rg.id,
+ rg.name,
+ rg.year,
+ COALESCE(ac.text, fallback_ac.text, '') as artist_name,
+ COALESCE(ca.file_path, '') as cover_art_path
+FROM release_groups rg
+JOIN artist_credit ac ON rg.album_artist_credit_id = ac.id
+JOIN artist_credit_artist aca ON aca.credit_id = ac.id
+LEFT JOIN cover_art ca ON rg.cover_art_id = ca.id
+LEFT JOIN (
+ SELECT rgr.release_group_id, ac2.text
+ FROM release_group_recordings rgr
+ JOIN recordings rec ON rec.id = rgr.recording_id
+ JOIN artist_credit ac2 ON ac2.id = rec.artist_credit_id
+ GROUP BY rgr.release_group_id
+) fallback_ac ON fallback_ac.release_group_id = rg.id
+WHERE aca.artist_id = ?
+ORDER BY rg.name;
diff --git a/backend/database/sql/sqlcgen/artists.sql.go b/backend/database/sql/sqlcgen/artists.sql.go
index 08cf13d..a4524d4 100644
--- a/backend/database/sql/sqlcgen/artists.sql.go
+++ b/backend/database/sql/sqlcgen/artists.sql.go
@@ -40,6 +40,38 @@ func (q *Queries) DeleteArtist(ctx context.Context, id int64) error {
return err
}
+const getAlbumArtists = `-- name: GetAlbumArtists :many
+SELECT DISTINCT a.id, a.name
+FROM artists a
+JOIN artist_credit_artist aca ON aca.artist_id = a.id
+JOIN artist_credit ac ON ac.id = aca.credit_id
+JOIN release_groups rg ON rg.album_artist_credit_id = ac.id
+ORDER BY a.name
+`
+
+func (q *Queries) GetAlbumArtists(ctx context.Context) ([]Artist, error) {
+ rows, err := q.db.QueryContext(ctx, getAlbumArtists)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ var items []Artist
+ for rows.Next() {
+ var i Artist
+ if err := rows.Scan(&i.ID, &i.Name); err != nil {
+ return nil, err
+ }
+ items = append(items, i)
+ }
+ if err := rows.Close(); err != nil {
+ return nil, err
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ return items, nil
+}
+
const getAllArtists = `-- name: GetAllArtists :many
SELECT id, name FROM artists
ORDER BY name
diff --git a/backend/database/sql/sqlcgen/release_groups.sql.go b/backend/database/sql/sqlcgen/release_groups.sql.go
index 2968323..2f920a4 100644
--- a/backend/database/sql/sqlcgen/release_groups.sql.go
+++ b/backend/database/sql/sqlcgen/release_groups.sql.go
@@ -87,6 +87,65 @@ func (q *Queries) DeleteReleaseGroup(ctx context.Context, id int64) error {
return err
}
+const getAlbumsByArtist = `-- name: GetAlbumsByArtist :many
+SELECT
+ rg.id,
+ rg.name,
+ rg.year,
+ COALESCE(ac.text, fallback_ac.text, '') as artist_name,
+ COALESCE(ca.file_path, '') as cover_art_path
+FROM release_groups rg
+JOIN artist_credit ac ON rg.album_artist_credit_id = ac.id
+JOIN artist_credit_artist aca ON aca.credit_id = ac.id
+LEFT JOIN cover_art ca ON rg.cover_art_id = ca.id
+LEFT JOIN (
+ SELECT rgr.release_group_id, ac2.text
+ FROM release_group_recordings rgr
+ JOIN recordings rec ON rec.id = rgr.recording_id
+ JOIN artist_credit ac2 ON ac2.id = rec.artist_credit_id
+ GROUP BY rgr.release_group_id
+) fallback_ac ON fallback_ac.release_group_id = rg.id
+WHERE aca.artist_id = ?
+ORDER BY rg.name
+`
+
+type GetAlbumsByArtistRow struct {
+ ID int64
+ Name string
+ Year sql.NullInt64
+ ArtistName string
+ CoverArtPath string
+}
+
+func (q *Queries) GetAlbumsByArtist(ctx context.Context, artistID int64) ([]GetAlbumsByArtistRow, error) {
+ rows, err := q.db.QueryContext(ctx, getAlbumsByArtist, artistID)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ var items []GetAlbumsByArtistRow
+ for rows.Next() {
+ var i GetAlbumsByArtistRow
+ if err := rows.Scan(
+ &i.ID,
+ &i.Name,
+ &i.Year,
+ &i.ArtistName,
+ &i.CoverArtPath,
+ ); err != nil {
+ return nil, err
+ }
+ items = append(items, i)
+ }
+ if err := rows.Close(); err != nil {
+ return nil, err
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ return items, nil
+}
+
const getAllAlbumsWithDetails = `-- name: GetAllAlbumsWithDetails :many
SELECT
rg.id,
diff --git a/backend/library/query.go b/backend/library/query.go
index 38b5d10..edaf6cf 100644
--- a/backend/library/query.go
+++ b/backend/library/query.go
@@ -43,6 +43,12 @@ func splitGenres(concatenated string) []string {
return strings.Split(concatenated, genreDelimiter)
}
+// Artist represents an artist in the library.
+type Artist struct {
+ ID int64
+ Name string
+}
+
// Album represents an album for the cover grid display.
type Album struct {
ID int64
@@ -177,3 +183,90 @@ func (l *Library) GetAllAlbums() ([]Album, error) {
return albums, nil
}
+
+// GetAllArtists returns artists that are credited as album artists, ordered by name.
+func (l *Library) GetAllArtists() ([]Artist, error) {
+ rows, err := l.db.Queries.GetAlbumArtists(l.ctx)
+ if err != nil {
+ l.logger.Error(
+ "could not retrieve artists",
+ "error", err,
+ )
+
+ return nil, fmt.Errorf(
+ "could not get artists: %w",
+ err,
+ )
+ }
+
+ l.logger.Info("artist list", "count", len(rows))
+
+ artists := make([]Artist, 0, len(rows))
+
+ for _, row := range rows {
+ artists = append(artists, Artist{
+ ID: row.ID,
+ Name: row.Name,
+ })
+ }
+
+ return artists, nil
+}
+
+// GetAlbumsByArtist returns all albums where the given artist is the album artist.
+func (l *Library) GetAlbumsByArtist(
+ artistID int64,
+) ([]Album, error) {
+ rows, err := l.db.Queries.GetAlbumsByArtist(
+ l.ctx,
+ artistID,
+ )
+ if err != nil {
+ l.logger.Error(
+ "could not retrieve albums for artist",
+ "artistID", artistID,
+ "error", err,
+ )
+
+ return nil, fmt.Errorf(
+ "could not get albums for artist: %w",
+ err,
+ )
+ }
+
+ l.logger.Info(
+ "albums for artist",
+ "artistID", artistID,
+ "count", len(rows),
+ )
+
+ albums := make([]Album, 0, len(rows))
+
+ for _, row := range rows {
+ album := Album{
+ ID: row.ID,
+ Name: row.Name,
+ ArtistName: row.ArtistName,
+ }
+
+ if row.Year.Valid {
+ album.Year = row.Year.Int64
+ }
+
+ // Convert filesystem path to URL path for the asset handler.
+ if row.CoverArtPath != "" {
+ base := filepath.Base(row.CoverArtPath)
+ album.CoverArtPath = "/covers/" + base
+ album.CoverArtSmall = "/covers/" +
+ SizedFilename(base, "_sm")
+ album.CoverArtMedium = "/covers/" +
+ SizedFilename(base, "_md")
+ album.CoverArtLarge = "/covers/" +
+ SizedFilename(base, "_lg")
+ }
+
+ albums = append(albums, album)
+ }
+
+ return albums, nil
+}
diff --git a/frontend/index.ts b/frontend/index.ts
index ef4c47d..f746d3b 100644
--- a/frontend/index.ts
+++ b/frontend/index.ts
@@ -7,6 +7,8 @@ import '@components/queue-panel/queue-panel.ts';
import '@components/playlist-view/playlist-view.ts';
import '@components/library-manager/library-manager.ts';
import '@components/config-page/config-page.ts';
+import '@components/artists-view/artists-view.ts';
+import '@components/artist-details/artist-details.ts';
import '@components/search-bar/search-bar.ts';
import type { SearchBar } from '@components/search-bar/search-bar.ts';
import '@awesome.me/webawesome/dist/styles/themes/default.css';
@@ -52,6 +54,20 @@ document.addEventListener('navigate', (e: Event) => {
case 'playlists':
mainContent.innerHTML = '';
break;
+ case 'artists':
+ mainContent.innerHTML = '';
+ break;
+ case 'artist-details': {
+ const { artistId, artistName } =
+ (e as CustomEvent).detail;
+ const el = document.createElement('artist-details');
+
+ el.setAttribute('artist-id', String(artistId));
+ el.setAttribute('artist-name', artistName);
+ mainContent.innerHTML = '';
+ mainContent.appendChild(el);
+ break;
+ }
case 'libraries':
mainContent.innerHTML = '';
break;
diff --git a/frontend/src/components/artist-details/artist-details.ts b/frontend/src/components/artist-details/artist-details.ts
new file mode 100644
index 0000000..339169c
--- /dev/null
+++ b/frontend/src/components/artist-details/artist-details.ts
@@ -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`
+
+
+ ${this.loading
+ ? html`
+
+ Loading albums...
+
+ `
+ : html`
+
+ `}
+
+ `;
+ }
+}
diff --git a/frontend/src/components/artists-view/artists-view.ts b/frontend/src/components/artists-view/artists-view.ts
new file mode 100644
index 0000000..63e8e28
--- /dev/null
+++ b/frontend/src/components/artists-view/artists-view.ts
@@ -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`
+
+ this.onArtistClick(artist)}
+ @keydown=${(e: KeyboardEvent) => {
+ if (
+ e.key === 'Enter' ||
+ e.key === ' '
+ ) {
+ e.preventDefault();
+ this.onArtistClick(artist);
+ }
+ }}
+ >
+
+
+ ${this.getArtistInitial(
+ artist.Name,
+ )}
+
+
+
+ ${artist.Name}
+
+
+ `;
+ }
+
+ override render() {
+ if (this.loading) {
+ return html`
+
+ Loading artists...
+
+ `;
+ }
+
+ const entries = this.gridEntries;
+
+ if (entries.length === 0) {
+ return html`
+
+ ${this.searchCtrl.term
+ ? 'No artists match your search.'
+ : 'No artists in library.'}
+
+ `;
+ }
+
+ return html`
+
+
+ this.renderArtistCard(entry)}
+ .layout=${this.gridLayout}
+ >
+
+ `;
+ }
+}
diff --git a/frontend/src/components/cover-grid/cover-grid.ts b/frontend/src/components/cover-grid/cover-grid.ts
index 735f7c9..ce0e0c1 100644
--- a/frontend/src/components/cover-grid/cover-grid.ts
+++ b/frontend/src/components/cover-grid/cover-grid.ts
@@ -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) {
diff --git a/frontend/src/store/controllers/library-controller.ts b/frontend/src/store/controllers/library-controller.ts
index 0815c69..d727cd1 100644
--- a/frontend/src/store/controllers/library-controller.ts
+++ b/frontend/src/store/controllers/library-controller.ts
@@ -51,6 +51,16 @@ export class LibraryController implements ReactiveController {
return libraryStore.getAlbums();
}
+ async getArtists(): Promise {
+ return libraryStore.getArtists();
+ }
+
+ async getAlbumsByArtist(
+ artistID: number,
+ ): Promise {
+ return libraryStore.getAlbumsByArtist(artistID);
+ }
+
get cachedTracks(): library.Track[] | null {
return libraryStore.getCachedTracks();
}
@@ -59,6 +69,10 @@ export class LibraryController implements ReactiveController {
return libraryStore.getCachedAlbums();
}
+ get cachedArtists(): library.Artist[] | null {
+ return libraryStore.getCachedArtists();
+ }
+
get tracksLoading(): boolean {
return libraryStore.isTracksLoading();
}
@@ -67,6 +81,10 @@ export class LibraryController implements ReactiveController {
return libraryStore.isAlbumsLoading();
}
+ get artistsLoading(): boolean {
+ return libraryStore.isArtistsLoading();
+ }
+
// ===================================================================
// SCROLL POSITION
// ===================================================================
diff --git a/frontend/src/store/library-store.ts b/frontend/src/store/library-store.ts
index bad9ece..eeb1440 100644
--- a/frontend/src/store/library-store.ts
+++ b/frontend/src/store/library-store.ts
@@ -1,5 +1,10 @@
import { EventsOn } from '@runtime/runtime';
-import { GetAllTracks, GetAllAlbums } from '@go/library/Library';
+import {
+ GetAllTracks,
+ GetAllAlbums,
+ GetAllArtists,
+ GetAlbumsByArtist,
+} from '@go/library/Library';
import type { library } from '@go/models';
import { Events } from '../events';
@@ -22,9 +27,11 @@ const COVER_SIZE_KEY = 'cover-grid-size';
class LibraryStore {
private tracks: library.Track[] | null = null;
private albums: library.Album[] | null = null;
+ private artists: library.Artist[] | null = null;
private tracksLoading = false;
private albumsLoading = false;
+ private artistsLoading = false;
private coverSizeValue: number = COVER_SIZE_DEFAULT;
@@ -94,6 +101,35 @@ class LibraryStore {
}
}
+ async getArtists(): Promise {
+ if (this.artists !== null) {
+ return this.artists;
+ }
+
+ if (this.artistsLoading) {
+ return this.waitForArtists();
+ }
+
+ this.artistsLoading = true;
+ this.notify();
+
+ try {
+ const artists = await GetAllArtists();
+ this.artists = artists;
+
+ return artists;
+ } finally {
+ this.artistsLoading = false;
+ this.notify();
+ }
+ }
+
+ async getAlbumsByArtist(
+ artistID: number,
+ ): Promise {
+ return GetAlbumsByArtist(artistID);
+ }
+
// ===================================================================
// STATE ACCESSORS
// Synchronous access for controllers that need current cached values.
@@ -115,6 +151,14 @@ class LibraryStore {
return this.albumsLoading;
}
+ getCachedArtists(): library.Artist[] | null {
+ return this.artists;
+ }
+
+ isArtistsLoading(): boolean {
+ return this.artistsLoading;
+ }
+
// ===================================================================
// SCROLL POSITION
// ===================================================================
@@ -184,6 +228,7 @@ class LibraryStore {
private invalidate(): void {
this.tracks = null;
this.albums = null;
+ this.artists = null;
this.scrollPositions = { tracks: 0, albums: 0 };
this.notify();
}
@@ -228,6 +273,17 @@ class LibraryStore {
});
});
}
+
+ private waitForArtists(): Promise {
+ return new Promise((resolve) => {
+ const unsub = this.subscribe(() => {
+ if (!this.artistsLoading && this.artists !== null) {
+ unsub();
+ resolve(this.artists);
+ }
+ });
+ });
+ }
}
// Singleton instance.
diff --git a/frontend/src/store/search-store.ts b/frontend/src/store/search-store.ts
index 13cd43d..665e0a3 100644
--- a/frontend/src/store/search-store.ts
+++ b/frontend/src/store/search-store.ts
@@ -1,5 +1,5 @@
/** Searchable views that respond to the global search term. */
-const SEARCHABLE_VIEWS = new Set(['tracks', 'albums', 'playlists']);
+const SEARCHABLE_VIEWS = new Set(['tracks', 'albums', 'playlists', 'artists']);
type Subscriber = () => void;
diff --git a/frontend/wailsjs/go/library/Library.d.ts b/frontend/wailsjs/go/library/Library.d.ts
index 54a0758..8ae186b 100755
--- a/frontend/wailsjs/go/library/Library.d.ts
+++ b/frontend/wailsjs/go/library/Library.d.ts
@@ -7,8 +7,12 @@ export function FullRescan():Promise;
export function GetAlbumTracks(arg1:number):Promise>;
+export function GetAlbumsByArtist(arg1:number):Promise>;
+
export function GetAllAlbums():Promise>;
+export function GetAllArtists():Promise>;
+
export function GetAllTracks():Promise>;
export function Scan():Promise;
diff --git a/frontend/wailsjs/go/library/Library.js b/frontend/wailsjs/go/library/Library.js
index 3302081..d809140 100755
--- a/frontend/wailsjs/go/library/Library.js
+++ b/frontend/wailsjs/go/library/Library.js
@@ -10,10 +10,18 @@ export function GetAlbumTracks(arg1) {
return window['go']['library']['Library']['GetAlbumTracks'](arg1);
}
+export function GetAlbumsByArtist(arg1) {
+ return window['go']['library']['Library']['GetAlbumsByArtist'](arg1);
+}
+
export function GetAllAlbums() {
return window['go']['library']['Library']['GetAllAlbums']();
}
+export function GetAllArtists() {
+ return window['go']['library']['Library']['GetAllArtists']();
+}
+
export function GetAllTracks() {
return window['go']['library']['Library']['GetAllTracks']();
}
diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts
index d70e46a..1250754 100755
--- a/frontend/wailsjs/go/models.ts
+++ b/frontend/wailsjs/go/models.ts
@@ -26,6 +26,20 @@ export namespace library {
this.Year = source["Year"];
}
}
+ export class Artist {
+ ID: number;
+ Name: string;
+
+ static createFrom(source: any = {}) {
+ return new Artist(source);
+ }
+
+ constructor(source: any = {}) {
+ if ('string' === typeof source) source = JSON.parse(source);
+ this.ID = source["ID"];
+ this.Name = source["Name"];
+ }
+ }
export class ScanMetrics {
total: number;
loadExisting: number;