diff --git a/frontend/src/components/explore-view/explore-view.ts b/frontend/src/components/explore-view/explore-view.ts index bc7e30f..71cf9a0 100644 --- a/frontend/src/components/explore-view/explore-view.ts +++ b/frontend/src/components/explore-view/explore-view.ts @@ -1,36 +1,907 @@ -import { LitElement, html, css } from 'lit'; -import { customElement } from 'lit/decorators.js'; +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; } - h1 { - margin: 0 0 16px; - font-size: 1.5rem; - color: var(--yj-text-primary); + /* ── 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; } - .placeholder { - color: var(--yj-text-secondary); + .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` -

Explore

-

- Search MusicBrainz to discover artists, albums, and - tracks. -

+ ${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)} +
+
+ `, + )} +
+
`; } }