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.getInitial( + this.artistName, + )} + +
+
+

+ ${this.artistName} +

+ ${!this.loading + ? html` + + ${albumCount} + ${albumLabel} + + ` + : ''} +
+
+
+ ${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;