import { LitElement, html, css, nothing } from 'lit'; import { customElement, state, query as litQuery } from 'lit/decorators.js'; import { designTokens } from '../../styles/tokens.css'; import { Search } from '@go/explore/Service'; import type { MBSearchResult, MBArtist, MBReleaseGroup, MBRecording, } from '@go/explore/Service'; import '@awesome.me/webawesome/dist/components/icon/icon.js'; /* ── Constants ── */ const DEBOUNCE_MS = 300; const CAA_GROUP_BASE = 'https://coverartarchive.org/release-group'; const TOP_RESULTS_COUNT = 3; /** * Build a Cover Art Archive URL for a release-group front cover. * Mirrors the Wails CoverArtGroupURL binding but runs synchronously * on the frontend — avoids N async round-trips per render cycle. */ function CoverArtGroupURL(releaseGroupMBID: string): string { return `${CAA_GROUP_BASE}/${releaseGroupMBID}/front-250`; } /** Hash a string to a hue value 0–360 for avatar coloring. */ function nameToHue(name: string): number { let hash = 0; for (let i = 0; i < name.length; i++) { hash = name.charCodeAt(i) + ((hash << 5) - hash); } return Math.abs(hash) % 360; } /** Format milliseconds as mm:ss. */ function formatDuration(ms: number): string { if (!ms || ms <= 0) return '0:00'; const totalSeconds = Math.floor(ms / 1000); const minutes = Math.floor(totalSeconds / 60); const seconds = totalSeconds % 60; return `${minutes}:${seconds.toString().padStart(2, '0')}`; } /** Extract the year from a date string like "2005-03-29" or "2005". */ function extractYear(dateStr: string): string { if (!dateStr) return ''; return dateStr.substring(0, 4); } /** * Union type for top-results ranking. We can only rank items with * a score field — MBReleaseGroup lacks one in the Go struct. */ interface ScoredItem { type: 'artist' | 'recording'; score: number; artist?: MBArtist; recording?: MBRecording; } @customElement('explore-view') export class ExploreView extends LitElement { /* ── State ── */ @state() private searchQuery = ''; @state() private results: MBSearchResult | null = null; @state() private loading = false; @state() private error = ''; /** Monotonic counter to discard stale responses. */ private searchVersion = 0; private debounceTimer: ReturnType | null = null; @litQuery('input') private inputEl!: HTMLInputElement; /* ── Styles ── */ static override styles = [ designTokens, css` :host { display: block; padding: 24px; overflow-y: auto; height: 100%; box-sizing: border-box; } /* ── Search input ── */ .search-container { display: flex; align-items: center; background: var(--yj-bg-surface, #212529); border: 1px solid var(--yj-border-subtle, #555); border-radius: 6px; padding: 0 12px; gap: 8px; height: 36px; max-width: 520px; transition: border-color 0.15s ease; } .search-container:focus-within { border-color: var(--yj-accent, #ffd43b); } .search-icon { color: var(--yj-text-tertiary, #888); font-size: var(--yj-icon-sm); flex-shrink: 0; } input { flex: 1; background: none; border: none; outline: none; color: var(--yj-text-primary, #fff); font-size: var(--yj-text-md); font-family: inherit; min-width: 0; } input::placeholder { color: var(--yj-text-tertiary, #888); } .clear-button { display: flex; align-items: center; justify-content: center; background: none; border: none; color: var(--yj-text-tertiary, #888); cursor: pointer; padding: 0; font-size: var(--yj-text-sm); flex-shrink: 0; } .clear-button:hover { color: var(--yj-text-primary, #fff); } /* ── Status messages ── */ .status-message { margin-top: 32px; color: var(--yj-text-secondary, #b3b3b3); font-size: var(--yj-text-md); } .error-message { margin-top: 16px; display: flex; align-items: center; gap: 6px; color: var(--yj-text-secondary, #b3b3b3); font-size: var(--yj-text-sm); } .error-message wa-icon { color: #e5534b; font-size: var(--yj-icon-sm); flex-shrink: 0; } .loading-indicator { margin-top: 24px; color: var(--yj-text-secondary, #b3b3b3); font-size: var(--yj-text-md); animation: pulse 1.5s ease-in-out infinite; } @keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.5; } } /* ── Sections ── */ .results-container { margin-top: 24px; display: flex; flex-direction: column; gap: 28px; } .section-header { font-size: var(--yj-text-lg); font-weight: 600; color: var(--yj-text-secondary, #b3b3b3); margin: 0 0 12px; text-transform: uppercase; letter-spacing: 0.05em; font-size: 11px; } /* ── Horizontal scroll rows ── */ .horizontal-row { display: flex; gap: 12px; overflow-x: auto; padding-bottom: 4px; scrollbar-width: none; } .horizontal-row::-webkit-scrollbar { display: none; } /* ── Top result cards ── */ .top-card { display: flex; align-items: center; gap: 12px; padding: 10px 14px; background: var(--yj-bg-surface, #212529); border-radius: 8px; cursor: pointer; min-width: 200px; max-width: 280px; flex-shrink: 0; transition: background 0.15s ease; } .top-card:hover { background: var(--yj-bg-overlay, rgba(255, 255, 255, 0.06)); } .top-card:active { transform: scale(0.98); } .top-card-info { flex: 1; min-width: 0; } .top-card-name { font-weight: 500; color: var(--yj-text-primary, #fff); font-size: var(--yj-text-md); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .top-card-meta { color: var(--yj-text-tertiary, #888); font-size: var(--yj-text-sm); margin-top: 2px; } /* ── Artist cards ── */ .artist-card { display: flex; flex-direction: column; align-items: center; gap: 8px; padding: 10px; border-radius: 8px; cursor: pointer; min-width: 100px; max-width: 120px; flex-shrink: 0; text-align: center; transition: background 0.15s ease; } .artist-card:hover { background: var(--yj-bg-overlay, rgba(255, 255, 255, 0.06)); } .artist-card:active { transform: scale(0.97); } .artist-avatar { width: 48px; height: 48px; border-radius: 50%; display: flex; align-items: center; justify-content: center; color: #fff; font-weight: 600; font-size: 20px; text-transform: uppercase; user-select: none; flex-shrink: 0; } .artist-name { font-weight: 500; color: var(--yj-text-primary, #fff); font-size: var(--yj-text-sm); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; width: 100%; } .artist-disambiguation { color: var(--yj-text-tertiary, #888); font-size: var(--yj-text-xs); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; width: 100%; margin-top: -4px; } .artist-country { color: var(--yj-text-tertiary, #888); font-size: var(--yj-text-xs); } /* ── Album cards ── */ .album-card { display: flex; flex-direction: column; gap: 6px; padding: 8px; border-radius: 8px; cursor: pointer; min-width: 130px; max-width: 150px; flex-shrink: 0; transition: background 0.15s ease; } .album-card:hover { background: var(--yj-bg-overlay, rgba(255, 255, 255, 0.06)); } .album-card:active { transform: scale(0.97); } .album-art-container { width: 100%; aspect-ratio: 1; border-radius: 4px; 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; position: relative; } .album-art-container img { width: 100%; height: 100%; object-fit: cover; display: block; } .album-art-fallback { display: flex; align-items: center; justify-content: center; width: 100%; height: 100%; position: absolute; inset: 0; } .album-art-fallback wa-icon { color: var(--yj-text-tertiary, #888); font-size: 24px; opacity: 0.5; } .album-title { font-weight: 500; color: var(--yj-text-primary, #fff); font-size: var(--yj-text-sm); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .album-artist { color: var(--yj-text-tertiary, #888); font-size: var(--yj-text-xs); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .album-meta { display: flex; align-items: center; gap: 6px; color: var(--yj-text-tertiary, #888); font-size: var(--yj-text-xs); } .type-badge { background: var(--yj-bg-overlay, rgba(255, 255, 255, 0.08)); padding: 1px 6px; border-radius: 3px; font-size: 10px; white-space: nowrap; } /* ── Track list ── */ .track-list { display: flex; flex-direction: column; } .track-item { display: flex; align-items: center; gap: 12px; padding: 8px 12px; border-radius: 6px; cursor: default; transition: background 0.1s ease; } .track-item:hover { background: var(--yj-bg-overlay, rgba(255, 255, 255, 0.04)); } .track-info { flex: 1; min-width: 0; } .track-title { font-weight: 500; color: var(--yj-text-primary, #fff); font-size: var(--yj-text-md); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .track-artist { color: var(--yj-text-tertiary, #888); font-size: var(--yj-text-sm); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .track-duration { color: var(--yj-text-tertiary, #888); font-size: var(--yj-text-sm); flex-shrink: 0; font-variant-numeric: tabular-nums; } `, ]; /* ── Lifecycle ── */ override disconnectedCallback() { super.disconnectedCallback(); if (this.debounceTimer !== null) { clearTimeout(this.debounceTimer); this.debounceTimer = null; } } /* ── Search Logic ── */ private handleInput(e: Event) { const input = e.target as HTMLInputElement; this.searchQuery = input.value; if (this.debounceTimer !== null) { clearTimeout(this.debounceTimer); this.debounceTimer = null; } if (!this.searchQuery.trim()) { this.results = null; this.error = ''; this.loading = false; return; } this.debounceTimer = setTimeout(() => { this.debounceTimer = null; void this.executeSearch(); }, DEBOUNCE_MS); } private handleClear() { this.searchQuery = ''; this.results = null; this.error = ''; this.loading = false; if (this.debounceTimer !== null) { clearTimeout(this.debounceTimer); this.debounceTimer = null; } if (this.inputEl) { this.inputEl.value = ''; this.inputEl.focus(); } } private handleKeydown(e: KeyboardEvent) { if (e.key === 'Escape') { this.handleClear(); } } private async executeSearch() { const version = ++this.searchVersion; const query = this.searchQuery.trim(); if (!query) return; this.loading = true; this.error = ''; const startTime = performance.now(); console.log(`[explore] search started: "${query}"`); try { const result = await Search(query); // Discard stale response if (version !== this.searchVersion) { console.debug( `[explore] discarded stale response for "${query}" (v${version} != v${this.searchVersion})`, ); return; } this.results = result; const elapsed = (performance.now() - startTime).toFixed(0); console.log( `[explore] search completed: "${query}" in ${elapsed}ms — ` + `artists=${result.artists?.length ?? 0}, ` + `albums=${result.releaseGroups?.length ?? 0}, ` + `tracks=${result.recordings?.length ?? 0}`, ); } catch (err) { if (version !== this.searchVersion) return; const message = err instanceof Error ? err.message : String(err); this.error = message; console.error(`[explore] search error: "${query}" — ${message}`); } finally { if (version === this.searchVersion) { this.loading = false; } } } /* ── Top Results ── */ private getTopResults(): ScoredItem[] { if (!this.results) return []; const items: ScoredItem[] = []; if (this.results.artists) { for (const a of this.results.artists) { items.push({ type: 'artist', score: a.score, artist: a }); } } if (this.results.recordings) { for (const r of this.results.recordings) { items.push({ type: 'recording', score: r.score, recording: r }); } } items.sort((a, b) => b.score - a.score); return items.slice(0, TOP_RESULTS_COUNT); } /* ── Navigation ── */ private navigateToArtist(artist: MBArtist) { this.dispatchEvent( new CustomEvent('navigate', { bubbles: true, composed: true, detail: { view: 'explore-artist-details', artistMBID: artist.mbid, artistName: artist.name, }, }), ); } private navigateToAlbum(rg: MBReleaseGroup) { this.dispatchEvent( new CustomEvent('navigate', { bubbles: true, composed: true, detail: { view: 'explore-album-details', releaseGroupMBID: rg.mbid, albumName: rg.title, }, }), ); } /* ── Image Error Handling ── */ private handleImageError(e: Event) { const img = e.target as HTMLImageElement; img.style.display = 'none'; // Reveal the fallback placeholder behind the image const fallback = img.nextElementSibling as HTMLElement | null; if (fallback) { fallback.style.display = 'flex'; } } /* ── Render ── */ override render() { return html` ${this.renderSearchInput()} ${this.loading ? html`
Searching\u2026
` : nothing} ${this.error ? html`
${this.error}
` : nothing} ${this.renderBody()} `; } private renderSearchInput() { return html`
${this.searchQuery ? html` ` : nothing}
`; } private renderBody() { // No query entered yet if (!this.searchQuery.trim() && !this.results) { return html`
Search MusicBrainz to discover artists, albums, and tracks.
`; } // Loading state already shown above if (this.loading && !this.results) return nothing; // Results exist if (this.results) { const hasArtists = (this.results.artists?.length ?? 0) > 0; const hasAlbums = (this.results.releaseGroups?.length ?? 0) > 0; const hasTracks = (this.results.recordings?.length ?? 0) > 0; if (!hasArtists && !hasAlbums && !hasTracks) { return html`
No results found for \u201c${this.searchQuery}\u201d
`; } const topResults = this.getTopResults(); return html`
${topResults.length > 0 ? this.renderTopResults(topResults) : nothing} ${hasArtists ? this.renderArtistsSection(this.results.artists!) : nothing} ${hasAlbums ? this.renderAlbumsSection(this.results.releaseGroups!) : nothing} ${hasTracks ? this.renderTracksSection(this.results.recordings!) : nothing}
`; } return nothing; } /* ── Section Renderers ── */ private renderTopResults(items: ScoredItem[]) { return html`

Top Results

${items.map((item) => this.renderTopCard(item))}
`; } private renderTopCard(item: ScoredItem) { if (item.type === 'artist' && item.artist) { const a = item.artist; const hue = nameToHue(a.name); return html`
this.navigateToArtist(a)} role="button" tabindex="0" @keydown=${(e: KeyboardEvent) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); this.navigateToArtist(a); } }} >
${a.name.charAt(0).toUpperCase()}
${a.name}
Artist${a.country ? ` · ${a.country}` : ''}
`; } if (item.type === 'recording' && item.recording) { const r = item.recording; return html`
${r.title}
${r.artistCredit}${r.length ? ` · ${formatDuration(r.length)}` : ''}
`; } return nothing; } private renderArtistsSection(artists: MBArtist[]) { return html`

Artists

${artists.map((a) => { const hue = nameToHue(a.name); return html`
this.navigateToArtist(a)} role="button" tabindex="0" @keydown=${(e: KeyboardEvent) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); this.navigateToArtist(a); } }} >
${a.name.charAt(0).toUpperCase()}
${a.name}
${a.disambiguation ? html`
${a.disambiguation}
` : nothing} ${a.country ? html`
${a.country}
` : nothing}
`; })}
`; } private renderAlbumsSection(releaseGroups: MBReleaseGroup[]) { return html`

Albums

${releaseGroups.map((rg) => { const artURL = CoverArtGroupURL(rg.mbid); const year = extractYear(rg.firstReleaseDate); return html`
this.navigateToAlbum(rg)} role="button" tabindex="0" @keydown=${(e: KeyboardEvent) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); this.navigateToAlbum(rg); } }} >
${rg.title}
${rg.title}
${rg.artistCredit}
${rg.primaryType ? html`${rg.primaryType}` : nothing} ${year ? html`${year}` : nothing}
`; })}
`; } private renderTracksSection(recordings: MBRecording[]) { return html`

Tracks

${recordings.map( (r) => html`
${r.title}
${r.artistCredit}
${formatDuration(r.length)}
`, )}
`; } } declare global { interface HTMLElementTagNameMap { 'explore-view': ExploreView; } }