Files
yellowjacket/frontend/src/components/explore-view/explore-view.ts
T
logan 10eca353ab
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 2m53s
CI / e2e (pull_request) Successful in 6m24s
fix(explore): gate playback on the same answer the row is drawn from
Two play paths still accepted `inLibrary`, so a row drawn dimmed and
`aria-disabled` by the new rule would still attempt to play and fail
with "this track could not be found in your library" — the disagreement
this pass exists to remove, one layer down from the badge.
2026-08-19 00:39:18 -04:00

2318 lines
84 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { avatarBackground } from '@utils/avatar-color';
import { albumBadgeFor, libraryStatusFor } from '@utils/library-status';
import {
isOwned,
ownershipLabel,
unownedStyles,
} from '@utils/ownership';
import { completenessStore } from '@store/completeness-store';
import { downloadStore } from '@store/download-store';
import { LitElement, html, css, nothing } from 'lit';
import { customElement, state, query as litQuery } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import '@components/page-header/page-header';
import { designTokens } from '../../styles/tokens.css';
import { srOnly } from '../../styles/sr-only.css';
import { SearchLocal, SearchLyrics, GetThumbnail, GetThumbnails, GetArtistImageURL, GetArtistImagesCachedPaths, GetExploreShelves, RecordSearchClick } from '@go/explore/service.js';
import { GetFilePathsByAlbums, GetFilePathsByRecordingMBIDs } from '@go/library/library.js';
import { EventsOn } from '@runtime/runtime';
import { Events } from '../../events';
import { libraryStore } from '../../store/library-store';
import { exploreCache, ARTIST_IMAGE_CACHE_LIMIT } from '../../store/explore-cache';
import { queueStore } from '../../store/queue-store';
import { notificationStore } from '../../store/notification-store';
import '../notifications/inline-notice';
import { creditLink, trackLink, exploreLinkStyles } from '../../utils/explore-link';
import { creditStore } from '@store/credit-store';
import { describeError } from '../../utils/describe-error';
import '@awesome.me/webawesome/dist/components/icon/icon.js';
import '../library-status-indicator/library-status-indicator.js';
import '../top-results-row/top-results-row.js';
import * as explore from '@go/explore/models.js';
import { ViewLifecycleMixin } from '../../utils/view-lifecycle';
import { registerCacheProbe } from '../../utils/cache-stats';
import { LRUMap } from '../../utils/lru-map';
import {
ContextMenuController,
contextMenuStyles,
isContextMenuKey,
} from '@utils/context-menu-controller.js';
import type { ContextMenuHost } from '@utils/context-menu-controller.js';
import '@awesome.me/webawesome/dist/components/popup/popup.js';
import type WaPopup from '@awesome.me/webawesome/dist/components/popup/popup.js';
import '@awesome.me/webawesome/dist/components/dropdown-item/dropdown-item.js';
import { dict, dictByName } from '@utils/binding';
import { ICON_QUEUE } from '@utils/icon-language';
/** The region explore's own action failures (play/queue) are rendered in. */
export const ExploreRegion = 'explore';
/**
* A context-menu target: an album card or a track/recording. `localId`
* is present only when owned — that's what gates the playback items,
* while `mbid` (always present) is what "View on MusicBrainz" uses, so
* a catalog-only card still gets a menu with somewhere useful to go.
*/
type ExploreMenuTarget =
| { kind: 'album'; mbid: string; localId?: number; title: string }
| { kind: 'recording'; mbid: string; localId?: number; title: string };
type ThumbnailRequest = explore.ThumbnailRequest;
type MBSearchResult = explore.MBSearchResult;
type LyricsResult = explore.LyricsResult;
type MBArtist = explore.MBArtist;
type MBReleaseGroup = explore.MBReleaseGroup;
type MBRecording = explore.MBRecording;
type ShelfPage = explore.ShelfPage;
/* ── Constants ── */
const MIN_QUERY_LENGTH = 2;
// Debounce window for live search-as-you-type. The index query is
// local (no network), so this only coalesces rapid keystrokes.
const SEARCH_DEBOUNCE_MS = 180;
const MAX_SECTION_RESULTS = 10;
/*
* Caps for the two art caches (`perf.M7`).
*
* Both were unbounded, on a view that never unmounts: twelve searches
* retained 8.48 MB and were still climbing 0.7 MB per search. The caps
* come from the measured cost of an entry — a cover thumbnail is ~27 kB
* of base64, an artist photo ~128 kB — so each is set to hold roughly a
* dozen searches' worth and then stop:
*
* thumbnails 96 × ~27 kB ≈ 2.6 MB ceiling
* artist images 32 × ~128 kB ≈ 4.1 MB ceiling
*
* Both are comfortably larger than one screen of results, which matters:
* a cap below the visible count would evict art that is still rendered,
* and the re-render would fetch it again immediately. A search shows at
* most `MAX_SECTION_RESULTS` artists and ~15 release groups, so these
* are ~3× and ~6× a screenful — six searches of history for covers,
* which is far more than the "go back to the previous search" the cache
* exists to serve.
*
* `ARTIST_IMAGE_CACHE_LIMIT` is shared with `explore-cache.ts`, which
* holds the *same* data URLs for the detail pages. Bounding one and
* not the other frees nothing.
*/
const THUMBNAIL_CACHE_LIMIT = 96;
/** Format milliseconds as mm:ss. */
function formatDuration(ms: number): string {
if (!ms || ms <= 0) return '';
const totalSeconds = Math.floor(ms / 1000);
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
}
/** Format a raw listen count as a compact human-readable string. */
function formatPopularity(count: number): string {
if (!count || count <= 0) return '';
if (count >= 1_000_000) return `${(count / 1_000_000).toFixed(1)}M plays`;
if (count >= 1_000) return `${(count / 1_000).toFixed(count >= 10_000 ? 0 : 1)}K plays`;
return `${count} plays`;
}
/** 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);
}
/**
* Find album cover art for an artist from the library store.
* Returns the best available cover art URL, or '' if none.
*/
function getArtistAlbumArt(artistName: string): string {
const cachedAlbums = libraryStore.cachedAlbums;
if (!cachedAlbums) return '';
const name = artistName.toLowerCase();
for (const a of cachedAlbums) {
if (a.ArtistName.toLowerCase() === name) {
const art = a.CoverArtMedium || a.CoverArtSmall || a.CoverArtPath;
if (art) return art;
}
}
return '';
}
@customElement('explore-view')
export class ExploreView extends ViewLifecycleMixin(LitElement) implements ContextMenuHost {
/* ── State ── */
@state() private searchQuery = '';
@state() private results: MBSearchResult | null = null;
@state() private loading = false;
@state() private error = '';
@state() private queryTooShort = false;
/** Which search surface is active: catalog (index) or lyrics. */
@state() private searchMode: 'catalog' | 'lyrics' = 'catalog';
/** Lyric-search hits (library tracks matched by lyric fragment). */
@state() private lyricsResults: LyricsResult[] | null = null;
/**
* What the page shows before anyone has typed (`H-23`).
*
* `null` means "not asked yet", which is a different thing from a
* page with no shelves — the second has an answer and a reason for
* it, and says so.
*/
@state() private shelves: ShelfPage | null = null;
/** Guards against a second fetch while the first is in flight. */
private shelvesPending = false;
/** Unsubscribe for the index-status listener, while active. */
private cancelIndexStatus?: () => void;
/** Monotonic counter to discard stale responses. */
private searchVersion = 0;
/** Debounce timer for live search-as-you-type. */
private searchDebounceTimer?: ReturnType<typeof setTimeout>;
private thumbnailCache = new LRUMap<string, string>(THUMBNAIL_CACHE_LIMIT);
private artistImageCache = new LRUMap<string, string>(ARTIST_IMAGE_CACHE_LIMIT);
constructor() {
super();
// Registered from the constructor rather than on connect: this is
// a cached primary view, so it connects once, and a measurement
// must be able to read the caches whether or not Explore is the
// view currently on screen.
const stat = (m: LRUMap<string, string>) => () => {
let chars = 0;
for (const v of m.values()) chars += v.length;
return { entries: m.size, chars, limit: m.limit };
};
registerCacheProbe('explore.thumbnails', stat(this.thumbnailCache));
registerCacheProbe('explore.artistImages', stat(this.artistImageCache));
}
@litQuery('input') private inputEl!: HTMLInputElement;
/* ── Card/track context menu ── */
private ctxMenu = new ContextMenuController(this);
@state() private ctxMenuTarget: ExploreMenuTarget | null = null;
@litQuery('#explore-context-menu')
private contextMenuPopup!: WaPopup;
// -- ContextMenuHost interface --
// No playlist submenu — same reason as the album/artist detail
// pages: every action here resolves its one file lazily.
getContextMenuPopup(): WaPopup | undefined {
return this.contextMenuPopup;
}
getPlaylistSubmenuPopup(): WaPopup | undefined {
return undefined;
}
onContextMenuClose(): void {
this.ctxMenuTarget = null;
}
/* ── Styles ── */
static override styles = [
designTokens,
srOnly,
exploreLinkStyles,
contextMenuStyles,
unownedStyles,
css`
:host {
display: block;
padding: 24px;
overflow-y: auto;
height: 100%;
box-sizing: border-box;
}
/* The header supplies its own padding and rule, so it runs
to the edge of a host that pads its own content. */
page-header {
margin: -24px -24px 1em;
}
/* ── Search mode tabs ── */
.search-mode-tabs {
display: flex;
gap: 4px;
margin-bottom: 10px;
}
.search-mode-tab {
display: inline-flex;
align-items: center;
gap: 6px;
background: none;
border: 1px solid transparent;
border-radius: 6px;
color: var(--yj-text-tertiary, #888);
cursor: pointer;
padding: 5px 12px;
font-size: var(--yj-text-sm);
font-family: inherit;
transition: color 0.15s ease, background 0.15s ease;
}
.search-mode-tab:hover {
color: var(--yj-text-primary, #fff);
}
.search-mode-tab.active {
color: var(--yj-accent-fg, #000);
background: var(--yj-accent, #ffd43b);
}
/* ── 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;
}
/* ── Lyrics results ── */
.lyrics-results {
margin-top: 20px;
display: flex;
flex-direction: column;
gap: 2px;
max-width: 640px;
}
.lyrics-hit {
display: flex;
align-items: center;
gap: 12px;
width: 100%;
text-align: left;
background: none;
border: none;
border-radius: 6px;
color: var(--yj-text-primary, #fff);
cursor: pointer;
padding: 8px 10px;
font-family: inherit;
transition: background 0.12s ease;
}
.lyrics-hit:hover {
background: var(--yj-bg-surface, #212529);
}
.lyrics-hit-play {
color: var(--yj-text-tertiary, #888);
font-size: var(--yj-icon-sm);
flex-shrink: 0;
}
.lyrics-hit:hover .lyrics-hit-play {
color: var(--yj-accent-text, #ffd43b);
}
.lyrics-hit-main {
display: flex;
flex-direction: column;
min-width: 0;
}
.lyrics-hit-title {
font-size: var(--yj-text-md);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.lyrics-hit-meta {
font-size: var(--yj-text-sm);
color: var(--yj-text-secondary, #b3b3b3);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.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;
}
/* A shelf is a reason, not a filter, and the reason is the
part that makes it one — without it a row of covers is
another grid. Same rule as the home page's shelves. */
.section-reason {
margin: -8px 0 12px;
color: var(--yj-text-tertiary, #888);
font-size: var(--yj-text-sm, 12px);
}
.shelves-note {
margin: 0 0 16px;
color: var(--yj-text-tertiary, #888);
font-size: var(--yj-text-sm, 12px);
}
/* The page with no catalog. It says what is missing and how
to get it, because "no shelves" here can mean the artifact
has not been fetched — which is a thing the user can act
on, unlike an empty shelf on Home. */
.shelves-empty {
max-width: 34em;
margin: 3em auto;
text-align: center;
color: var(--yj-text-secondary, #b3b3b3);
}
.shelves-empty wa-icon {
font-size: 2.5em;
color: var(--yj-text-tertiary, #888);
margin-bottom: 0.5em;
}
.shelves-empty-title {
margin: 0 0 0.5em;
font-size: var(--yj-text-lg, 15px);
font-weight: 600;
}
.shelves-empty-body {
margin: 0;
color: var(--yj-text-tertiary, #888);
font-size: var(--yj-text-sm, 12px);
line-height: 1.5;
}
/* ── 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 ── */
/* ── 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;
overflow: hidden;
}
.artist-avatar img {
width: 100%;
height: 100%;
object-fit: cover;
}
.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-native-name {
color: var(--yj-text-secondary, #aaa);
font-size: var(--yj-text-xs);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
width: 100%;
margin-top: -4px;
}
.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;
justify-content: space-between;
gap: 6px;
color: var(--yj-text-tertiary, #888);
font-size: var(--yj-text-xs);
min-height: 20px;
}
.album-meta-text {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
overflow: hidden;
}
.album-meta library-status-indicator {
flex-shrink: 0;
margin-left: auto;
}
.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.owned {
cursor: pointer;
}
.album-card:focus-visible,
.track-item:focus-visible {
outline: 2px solid var(--yj-accent-text, #ffd43b);
outline-offset: -2px;
}
.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;
}
.track-meta {
display: flex;
align-items: center;
gap: 10px;
flex-shrink: 0;
}
.track-item library-status-indicator {
flex-shrink: 0;
}
.track-popularity {
color: var(--yj-text-tertiary, #888);
font-size: var(--yj-text-xs, 11px);
white-space: nowrap;
opacity: 0.7;
}
`,
];
/* ── Lifecycle ── */
override disconnectedCallback() {
super.disconnectedCallback();
this.cancelPendingSearch();
}
protected override onViewActivate(): void {
// A cached primary view, so this is torn down on the way out
// rather than on disconnect — which never fires here.
this.whileActive(
creditStore.subscribe(() => {
this.requestUpdate();
}),
);
// Fetched on arrival rather than on connect: this is a cached
// primary view, created and warmed at startup, so a fetch there
// is three catalog queries every user pays for whether or not
// they ever open Explore.
void this.loadShelves();
// The catalog can arrive after the page does — the artifact is
// downloaded and merged in the background on first run, which is
// the case where the shelves are empty *and* about to not be.
// There is no ticker behind this any more: `emitStatus` drops an
// unchanged status, so this fires when something actually
// changed, and nothing else will tell us.
this.cancelIndexStatus = EventsOn(Events.IndexStatusChanged, () => {
if (this.shelves?.state !== 'ready') void this.loadShelves();
});
// The badges on every result card say whether something is
// already requested, which a background reconcile pass changes
// without this page doing anything. Registered `whileActive`
// rather than on connect: this view is cached and never
// unmounts, so a connect-time subscription would run for the
// life of the session from pages it is not on.
//
// `init()` is four fetches, and it happens on arrival for the
// same reason `loadShelves()` does — a user who never opens
// Explore should not pay for it.
this.whileActive(downloadStore.subscribe(() => this.requestUpdate()));
void downloadStore.init().then(() => this.requestUpdate());
// How much of an owned album is here arrives a frame after the
// cards do — the store coalesces a screenful into one query —
// so a card that turns out to be 9 of 12 repaints when the
// answer lands rather than showing a plain tick until something
// else happens to re-render the grid.
this.whileActive(completenessStore.subscribe(() => this.requestUpdate()));
}
/** A debounced search that lands after the user has left the page is
* a query nobody asked for, against a 1.1 M-row index. */
protected override onViewDeactivate(): void {
this.cancelPendingSearch();
this.cancelIndexStatus?.();
this.cancelIndexStatus = undefined;
}
/**
* Load the shelves, and the art for the cards they hold.
*
* The art goes through the same two capped caches the search path
* uses rather than a third one of its own: this view never unmounts,
* so an uncapped cache on it is a leak with a long fuse
* (`perf.M7`), and a second cache holding the same data URLs would
* make both caps meaningless.
*/
private async loadShelves(): Promise<void> {
if (this.shelvesPending) return;
this.shelvesPending = true;
try {
const page = await GetExploreShelves();
// A binding that answers with nothing is not an error to
// report — it is a page with no catalog behind it, which is
// a state this view already renders honestly. A *rejected*
// call still reaches the catch below.
if (!page || !Array.isArray(page.shelves)) {
this.shelves = {
shelves: [],
state: 'no-index',
};
return;
}
this.shelves = page;
const albums = page.shelves.flatMap((shelf) => shelf.albums ?? []);
const artists = page.shelves.flatMap((shelf) => shelf.artists ?? []);
this.loadThumbnails(albums);
void this.loadArtistImages(artists, albums);
} catch (err) {
console.error('[explore] shelves failed', err);
// Inline, and quietly: the search box above still works, so
// this is a panel that could not fill itself rather than
// something the user asked for and did not get.
this.shelves = {
shelves: [],
state: 'no-index',
};
} finally {
this.shelvesPending = false;
}
}
/* ── Search Logic ── */
private handleInput(e: Event) {
const input = e.target as HTMLInputElement;
this.searchQuery = input.value;
const trimmed = this.searchQuery.trim();
if (!trimmed) {
this.cancelPendingSearch();
this.results = null;
this.error = '';
this.loading = false;
this.queryTooShort = false;
return;
}
if (trimmed.length < MIN_QUERY_LENGTH) {
this.cancelPendingSearch();
this.results = null;
this.error = '';
this.loading = false;
this.queryTooShort = true;
return;
}
this.queryTooShort = false;
// Both modes debounce straight to their backend search — catalog
// to the offline index (SearchLocal), lyrics to the FTS lyric
// search. No owned-library seed: the index is the sole source of
// catalog results, so we never paint temporary library matches.
this.scheduleSearch();
}
/** Switch between catalog and lyric search, resetting results. */
private setSearchMode(mode: 'catalog' | 'lyrics') {
if (this.searchMode === mode) return;
this.cancelPendingSearch();
this.searchMode = mode;
this.results = null;
this.lyricsResults = null;
this.error = '';
this.loading = false;
if (this.searchQuery.trim().length >= MIN_QUERY_LENGTH) {
void this.executeSearch();
}
this.inputEl?.focus();
}
/** Debounce a live index search after the latest keystroke. */
private scheduleSearch() {
this.cancelPendingSearch();
this.searchDebounceTimer = setTimeout(() => {
this.searchDebounceTimer = undefined;
if (this.searchQuery.trim().length >= MIN_QUERY_LENGTH) {
void this.executeSearch();
}
}, SEARCH_DEBOUNCE_MS);
}
private cancelPendingSearch() {
if (this.searchDebounceTimer) {
clearTimeout(this.searchDebounceTimer);
this.searchDebounceTimer = undefined;
}
}
private handleClear() {
this.cancelPendingSearch();
this.searchQuery = '';
this.results = null;
this.lyricsResults = null;
this.error = '';
this.loading = false;
this.queryTooShort = false;
if (this.inputEl) {
this.inputEl.value = '';
this.inputEl.focus();
}
}
private handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') {
this.handleClear();
return;
}
// Enter is optional now — search runs live as you type — but it
// still fires an immediate search, skipping the debounce wait.
if (e.key === 'Enter') {
e.preventDefault();
if (this.searchQuery.trim().length >= MIN_QUERY_LENGTH) {
this.cancelPendingSearch();
void this.executeSearch();
}
}
}
private async executeSearch() {
const version = ++this.searchVersion;
const query = this.searchQuery.trim();
if (!query) return;
this.loading = true;
this.error = '';
// Lyrics mode: a single FTS lyric search over the library.
if (this.searchMode === 'lyrics') {
void this.executeLyricsSearch(version, query);
return;
}
// Offline search over the local popularity index via Wails RPC.
// No network, and no owned-library seed — the index is the sole
// source of catalog results.
void this.executeIndexSearch(version, query);
}
/**
* Merge full search results with library data: library entries
* take priority (local art, "In Library" badge). MB-only results
* are appended after library matches.
*/
private mergeWithLibrary(result: MBSearchResult): MBSearchResult {
const cachedArtists = libraryStore.cachedArtists;
const cachedAlbums = libraryStore.cachedAlbums;
// Build MBID→library lookups.
const libArtistsByMBID = new Map<string, typeof cachedArtists extends (infer T)[] | null ? T : never>();
const libArtistsByName = new Map<string, typeof cachedArtists extends (infer T)[] | null ? T : never>();
if (cachedArtists) {
for (const a of cachedArtists) {
if (a.MBID) libArtistsByMBID.set(a.MBID, a);
libArtistsByName.set(a.Name.toLowerCase(), a);
}
}
const libAlbumsByMBID = new Map<string, typeof cachedAlbums extends (infer T)[] | null ? T : never>();
if (cachedAlbums) {
for (const a of cachedAlbums) {
if (a.MBID) libAlbumsByMBID.set(a.MBID, a);
}
}
// Enrich artists: if MB result matches a library artist, add local images.
if (result.artists) {
for (const a of result.artists) {
const lib = (a.mbid && libArtistsByMBID.get(a.mbid)) ||
libArtistsByName.get(a.name.toLowerCase());
if (lib) {
(a as any)._imageSmall = lib.ImageSmall || '';
(a as any)._imageMedium = lib.ImageMedium || '';
(a as any)._inLibrary = true;
}
}
}
// Enrich release groups: if MB result matches a library album, use local art.
if (result.releaseGroups) {
for (const rg of result.releaseGroups) {
const lib = rg.mbid ? libAlbumsByMBID.get(rg.mbid) : undefined;
if (lib) {
(rg as any)._coverArt = lib.CoverArtMedium || lib.CoverArtSmall || '';
(rg as any)._inLibrary = true;
}
}
}
return result;
}
private async executeIndexSearch(version: number, query: string) {
try {
// Local FTS index only — no network. Returns null when the
// index has no hits, in which case we keep the owned-library
// matches already displayed.
const result =
(await SearchLocal(query)) ?? {
artists: [],
releaseGroups: [],
recordings: [],
topResults: [],
};
// Discard stale response
if (version !== this.searchVersion) {
console.debug(
`[explore] discarded stale response for "${query}" (v${version} != v${this.searchVersion})`,
);
return;
}
// Enrich index results with local cover art / "In Library"
// badges, but never inject library-only entries — the index
// is the sole source of results shown.
this.results = this.mergeWithLibrary(result);
exploreCache.populateFromSearch(
this.results?.artists || [],
this.results?.releaseGroups || [],
);
this.loadThumbnails(this.results?.releaseGroups ?? []);
void this.loadArtistImages(
this.results?.artists ?? [],
this.results?.releaseGroups ?? [],
);
} catch (err) {
if (version !== this.searchVersion) return;
console.error(`[explore] search error: "${query}"`, err);
// Inline: this failure belongs to the results panel, and
// the panel is what the user is looking at (errors.M9).
this.error = describeError(
err,
'The catalog search did not answer.',
);
} finally {
if (version === this.searchVersion) {
this.loading = false;
}
}
}
/* ── Lyrics Search ── */
private async executeLyricsSearch(version: number, query: string) {
try {
const hits = await SearchLyrics(query);
if (version !== this.searchVersion) return;
this.lyricsResults = hits ?? [];
} catch (err) {
if (version !== this.searchVersion) return;
console.error(`[explore] lyrics search error: "${query}"`, err);
this.error = describeError(
err,
'The lyric search did not answer.',
);
} finally {
if (version === this.searchVersion) {
this.loading = false;
}
}
}
/** Play a lyric-search hit immediately (replaces the queue). */
private playLyricHit(hit: LyricsResult) {
if (!hit.filePath) return;
queueStore.setQueue([hit.filePath], 0);
}
/* ── Playback: album cards and track rows ── */
/**
* File paths for an owned album's tracks, keyed by its local album
* id — the only album key resolved on this page without a further
* fetch. `GetFilePathsByAlbums` only returns files that actually
* exist for that local album, so this can never pull in a track
* from a release the user does not fully own.
*/
private async albumFilePaths(localId: number): Promise<string[]> {
const libraryID = libraryStore.getSelectedLibraryId() ?? 0;
const byAlbum = await dict(GetFilePathsByAlbums([localId], libraryID));
return byAlbum[localId] ?? [];
}
private async recordingFilePath(mbid: string): Promise<string | null> {
const libraryID = libraryStore.getSelectedLibraryId() ?? 0;
const byMBID = await dictByName(
GetFilePathsByRecordingMBIDs([mbid], libraryID),
);
return byMBID[mbid]?.[0] ?? null;
}
private async playAlbum(rg: MBReleaseGroup, shuffle: boolean): Promise<void> {
if (!rg.localId) return;
try {
const paths = await this.albumFilePaths(rg.localId);
if (paths.length === 0) {
notificationStore.inline(ExploreRegion, {
text: 'None of these tracks could be found in your library.',
});
return;
}
if (shuffle && !queueStore.getState().shuffleMode) {
queueStore.toggleShuffle();
}
queueStore.setQueue(paths, 0, shuffle, { type: 'album', id: rg.localId, label: rg.title });
} catch (error) {
console.error('Could not play album:', error);
notificationStore.inline(ExploreRegion, {
text: describeError(error, 'Could not play this album.'),
});
}
}
private async queueAlbum(rg: MBReleaseGroup): Promise<void> {
if (!rg.localId) return;
const paths = await this.albumFilePaths(rg.localId);
if (paths.length > 0) queueStore.addTracksToQueue(paths);
}
private async playRecording(mbid: string): Promise<void> {
try {
const path = await this.recordingFilePath(mbid);
if (!path) {
notificationStore.inline(ExploreRegion, {
text: 'This track could not be found in your library.',
});
return;
}
queueStore.setQueue([path], 0);
} catch (error) {
console.error('Could not play track:', error);
notificationStore.inline(ExploreRegion, {
text: describeError(error, 'Could not play this track.'),
});
}
}
private async queueRecordingNext(mbid: string): Promise<void> {
const path = await this.recordingFilePath(mbid);
if (path) queueStore.playNext(path);
}
private async addRecordingToQueue(mbid: string): Promise<void> {
const path = await this.recordingFilePath(mbid);
if (path) queueStore.addToQueue(path);
}
private onAlbumCardDblClick(rg: MBReleaseGroup): void {
if (!rg.localId) return;
void this.playAlbum(rg, false);
}
// The same answer the row is drawn from. It used to accept
// `inLibrary` as well, so a row drawn dimmed and `aria-disabled`
// would still try to play and fail with a notification.
private onRecordingRowDblClick(r: { mbid: string; localId?: number }): void {
if (!isOwned(r)) return;
void this.playRecording(r.mbid);
}
private onCardKeydown(
e: KeyboardEvent,
onActivate: () => void,
target?: ExploreMenuTarget,
): void {
if (target && isContextMenuKey(e)) {
e.preventDefault();
this.ctxMenuTarget = target;
this.ctxMenu.openFrom(e.currentTarget as HTMLElement);
return;
}
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onActivate();
}
}
private onExploreContextMenu(e: MouseEvent, target: ExploreMenuTarget): void {
e.preventDefault();
e.stopPropagation();
this.ctxMenuTarget = target;
this.ctxMenu.openAt(e.clientX, e.clientY);
}
private onContextMenuAction(action: 'play' | 'add-to-queue' | 'play-next'): void {
const target = this.ctxMenuTarget;
this.ctxMenu.close();
if (!target || !target.localId) return;
if (target.kind === 'album') {
const localId = target.localId;
const rg = { localId, title: target.title } as MBReleaseGroup;
switch (action) {
case 'play':
void this.playAlbum(rg, false);
break;
case 'add-to-queue':
void this.queueAlbum(rg);
break;
case 'play-next':
void this.albumFilePaths(localId).then((paths) => {
if (paths.length > 0) queueStore.playTracksNext(paths);
});
break;
}
return;
}
switch (action) {
case 'play':
void this.playRecording(target.mbid);
break;
case 'add-to-queue':
void this.addRecordingToQueue(target.mbid);
break;
case 'play-next':
void this.queueRecordingNext(target.mbid);
break;
}
}
/**
* Explore's cards carry an MBID whether or not the user owns them,
* so this is the one action that works on a catalog-only card —
* it needs no file, and it's the same URL scheme for a release
* group or a recording.
*/
private viewOnMusicBrainz(): void {
const target = this.ctxMenuTarget;
this.ctxMenu.close();
if (!target?.mbid) return;
const entity = target.kind === 'album' ? 'release-group' : 'recording';
window.open(`https://musicbrainz.org/${entity}/${target.mbid}`, '_blank', 'noopener');
}
private renderExploreContextMenu() {
const target = this.ctxMenuTarget;
const owned = Boolean(target?.localId);
return html`
<wa-popup
id="explore-context-menu"
placement="bottom-start"
flip
shift
.active=${this.ctxMenu.contextMenuOpen}
>
${this.ctxMenu.contextMenuOpen && target
? html`
<div class="context-menu-panel" role="menu" aria-label="${target.title} actions">
${owned
? html`
<wa-dropdown-item @click=${() => this.onContextMenuAction('play')}>
<wa-icon slot="icon" name="play"></wa-icon>
Play
</wa-dropdown-item>
<wa-dropdown-item @click=${() => this.onContextMenuAction('add-to-queue')}>
<wa-icon slot="icon" name=${ICON_QUEUE}></wa-icon>
Add to Queue
</wa-dropdown-item>
<wa-dropdown-item @click=${() => this.onContextMenuAction('play-next')}>
<wa-icon slot="icon" name="forward-step"></wa-icon>
Play Next
</wa-dropdown-item>
`
: nothing}
<wa-dropdown-item @click=${() => this.viewOnMusicBrainz()}>
<wa-icon slot="icon" name="globe"></wa-icon>
View on MusicBrainz
</wa-dropdown-item>
</div>
`
: nothing}
</wa-popup>
`;
}
/* ── Thumbnail Loading ── */
private thumbnailBatchPending = false;
/**
* Load thumbnails for all visible album cards in one batched
* Wails call. Called after search results are set.
*/
/**
* Seed the thumbnail cache from local library data only — does
* no API calls. Reads from cachedAlbums (by MBID) and from any
* `_coverArt` underscore field that searchLibraryCache stamped
* on the release group.
*/
private seedThumbnailsFromLibrary(releaseGroups: MBReleaseGroup[]) {
if (!releaseGroups.length) return;
const cachedAlbums = libraryStore.cachedAlbums;
const libAlbumsByMBID = new Map<string, string>();
if (cachedAlbums) {
for (const a of cachedAlbums) {
if (a.MBID && (a.CoverArtMedium || a.CoverArtSmall)) {
libAlbumsByMBID.set(a.MBID, a.CoverArtMedium || a.CoverArtSmall);
}
}
}
let updated = false;
for (const rg of releaseGroups) {
if (this.thumbnailCache.has(rg.mbid)) continue;
const localArt = libAlbumsByMBID.get(rg.mbid) || (rg as any)._coverArt;
if (localArt) {
this.thumbnailCache.set(rg.mbid, localArt);
updated = true;
}
}
if (updated) {
this.requestUpdate();
}
}
/**
* Seed the artist image cache from local library data only —
* does no API calls. Reads from cachedArtists
* by MBID and from any `_imageMedium`/`_imageSmall` underscore
* field that searchLibraryCache stamped on the artist. Falls
* back to library album art when an artist has no portrait.
*/
private seedArtistImagesFromLibrary(artists: MBArtist[]) {
if (!artists.length) return;
const cachedArtists = libraryStore.cachedArtists;
const libByMBID = new Map<string, string>();
if (cachedArtists) {
for (const a of cachedArtists) {
if (a.MBID && (a.ImageMedium || a.ImageSmall)) {
libByMBID.set(a.MBID, a.ImageMedium || a.ImageSmall);
}
}
}
let updated = false;
for (const a of artists) {
if (!a.mbid || this.artistImageCache.has(a.mbid)) continue;
const local = libByMBID.get(a.mbid) || (a as any)._imageMedium || (a as any)._imageSmall;
if (local) {
this.artistImageCache.set(a.mbid, local);
updated = true;
continue;
}
// Fallback: album art for artists without a portrait.
const albumArt = getArtistAlbumArt(a.name);
if (albumArt) {
this.artistImageCache.set(a.mbid, albumArt);
updated = true;
}
}
if (updated) {
this.requestUpdate();
}
}
private loadThumbnails(releaseGroups: MBReleaseGroup[]) {
if (this.thumbnailBatchPending || !releaseGroups.length) {
return;
}
// Always seed from local library first.
this.seedThumbnailsFromLibrary(releaseGroups);
// Collect MBIDs that still need fetching from the API.
const requests: ThumbnailRequest[] = [];
for (const rg of releaseGroups) {
if (!this.thumbnailCache.has(rg.mbid)) {
requests.push({
mbid: rg.mbid,
albumName: rg.title || '',
artistName: rg.artistCredit || '',
});
}
}
if (requests.length === 0) return;
this.thumbnailBatchPending = true;
// Phase 1: batch cached lookup (instant, backend returns only cached items).
GetThumbnails(requests)
.then((results) => {
let updated = false;
const cached = results || {};
for (const [mbid, dataUrl] of Object.entries(cached)) {
if (dataUrl) {
this.thumbnailCache.set(mbid, dataUrl);
updated = true;
}
}
if (updated) {
this.requestUpdate();
}
// Phase 2: fire individual fetches for uncached items —
// each runs in its own Go goroutine and streams in as
// the CAA fetch completes.
const uncached = requests.filter((req) => !cached[req.mbid]);
for (const req of uncached) {
// Mark as in-flight to prevent duplicates.
if (this.thumbnailCache.has(req.mbid)) continue;
this.thumbnailCache.set(req.mbid, '');
GetThumbnail(req.mbid, req.albumName, req.artistName)
.then((url) => {
if (url) {
this.thumbnailCache.set(req.mbid, url);
this.requestUpdate();
return;
}
// An empty answer is not necessarily "there
// is no art" — a slow Internet Archive node
// is answered by a timeout, which looks
// exactly the same from here. Drop the
// in-flight marker so the next time this
// release group is on screen it is asked
// again; the backend records a genuine 404
// on disk and answers that one instantly,
// so a real miss costs nothing to re-ask.
this.thumbnailCache.delete(req.mbid);
})
.catch(() => {
this.thumbnailCache.delete(req.mbid);
});
}
})
.catch(() => {
// Batch failed — mark all as attempted so we don't retry.
for (const req of requests) {
if (!this.thumbnailCache.has(req.mbid)) {
this.thumbnailCache.set(req.mbid, '');
}
}
})
.finally(() => {
this.thumbnailBatchPending = false;
});
}
/**
* Load artist images for all visible artist cards.
*
* The order matters, because the three sources cost wildly
* different things. The library store is free. `GetArtistImages
* CachedPaths` is one call that asks the disk about every remaining
* artist at once — a portrait already downloaded costs no network
* at all, which on a catalog search is most of them, and seeding
* only from the library (owned artists, nearly none of a search's
* results) is what sent them to the resolver instead.
* `GetArtistImageURL` is the *resolving* entry point — MusicBrainz
* rels → Wikidata → Wikipedia → a download — so only what the disk
* did not answer reaches it, and those run together rather than one
* `await` at a time.
*/
private async loadArtistImages(
artists: MBArtist[],
releaseGroups: MBReleaseGroup[] = [],
) {
if (!artists.length) return;
// Seed from library store first (instant, no API).
this.seedArtistImagesFromLibrary(artists);
// One disk existence check for everything still unresolved.
const unresolved = artists
.filter((a) => a.mbid && !this.artistImageCache.has(a.mbid))
.map((a) => a.mbid);
if (unresolved.length > 0) {
try {
const cached = (await GetArtistImagesCachedPaths(unresolved)) || {};
let seeded = false;
for (const [mbid, path] of Object.entries(cached)) {
if (path) {
this.artistImageCache.set(mbid, path);
seeded = true;
}
}
if (seeded) this.requestUpdate();
} catch {
// The disk check is an optimisation; fall through.
}
}
// Whatever the disk did not answer goes to the resolver, in
// parallel — these are independent lookups against different
// upstreams and nothing about them is ordered.
await Promise.all(
artists.map(async (a) => {
if (!a.mbid || this.artistImageCache.has(a.mbid)) return;
this.artistImageCache.set(a.mbid, '');
try {
const url = await GetArtistImageURL(a.mbid);
if (url) {
this.artistImageCache.set(a.mbid, url);
this.requestUpdate();
}
} catch {
// No image — leave empty string.
}
}),
);
// Final fallback: album art for artists the API couldn't resolve.
// Try library store first, then search-result release groups.
let fallbackUpdated = false;
for (const a of artists) {
if (a.mbid && !this.artistImageCache.get(a.mbid)) {
// 1) Library album art
const albumArt = getArtistAlbumArt(a.name);
if (albumArt) {
this.artistImageCache.set(a.mbid, albumArt);
fallbackUpdated = true;
continue;
}
// 2) Cover art from a release group by this artist in
// the same batch — a search's results, or a shelf's.
{
const name = a.name.toLowerCase();
for (const rg of releaseGroups) {
if (rg.mbid && rg.artistCredit?.toLowerCase().includes(name)) {
const url = this.thumbnailCache.get(rg.mbid);
if (url) {
this.artistImageCache.set(a.mbid, url);
fallbackUpdated = true;
break;
}
}
}
}
}
}
if (fallbackUpdated) this.requestUpdate();
// Sync resolved images into the explore cache so detail pages
// pick them up without redundant API calls.
for (const a of artists) {
const url = this.artistImageCache.get(a.mbid);
if (url) {
const cached = exploreCache.getArtist(a.mbid);
if (cached) {
cached.imageURL = url;
} else {
exploreCache.setArtist(a.mbid, {
mbid: a.mbid,
name: a.name,
imageURL: url,
});
}
}
}
}
/* ── Navigation ── */
private navigateToArtist(artist: MBArtist) {
RecordSearchClick(this.searchQuery, artist.mbid, 'artist').catch(() => {});
this.dispatchEvent(
new CustomEvent('navigate', {
bubbles: true,
composed: true,
detail: {
view: 'explore-artist-details',
artistMBID: artist.mbid,
artistName: artist.name,
localArtistId: (artist as MBArtist & { localId?: number }).localId || 0,
},
}),
);
}
private navigateToAlbum(rg: MBReleaseGroup) {
RecordSearchClick(this.searchQuery, rg.mbid, 'release_group').catch(() => {});
const isLocal = typeof rg.mbid === 'string' && rg.mbid.startsWith('local:');
const realMBID = isLocal ? '' : (rg.mbid || '');
const localId = (rg as MBReleaseGroup & { localId?: number }).localId
|| (isLocal ? Number(rg.mbid.slice('local:'.length)) : 0);
this.dispatchEvent(
new CustomEvent('navigate', {
bubbles: true,
composed: true,
detail: {
view: 'explore-album-details',
releaseGroupMBID: realMBID,
albumName: rg.title,
artistName: rg.artistCredit || '',
localAlbumId: localId,
},
}),
);
}
private handleTopResultClick(e: CustomEvent<explore.TopResult>) {
const r = e.detail;
switch (r.entityType) {
case 'artist':
this.dispatchEvent(
new CustomEvent('navigate', {
bubbles: true,
composed: true,
detail: {
view: 'explore-artist-details',
artistMBID: r.mbid,
artistName: r.name,
},
}),
);
break;
case 'release_group':
this.dispatchEvent(
new CustomEvent('navigate', {
bubbles: true,
composed: true,
detail: {
view: 'explore-album-details',
releaseGroupMBID: r.mbid,
albumName: r.name,
},
}),
);
break;
case 'recording':
// Standard track-click behaviour (matches the library list,
// queue, playlists, etc.): open the track's album page with
// the track highlighted. The backend resolves the parent
// release group from the local index, so this is an instant,
// index-backed load rather than a name-only artist lookup.
if (r.releaseGroupMbid) {
this.dispatchEvent(
new CustomEvent('navigate', {
bubbles: true,
composed: true,
detail: {
view: 'explore-album-details',
releaseGroupMBID: r.releaseGroupMbid,
albumName: r.releaseName || '',
highlightTrackMBID: r.mbid,
},
}),
);
} else if (r.artistCredit) {
// Fallback only when the album can't be resolved locally.
this.dispatchEvent(
new CustomEvent('navigate', {
bubbles: true,
composed: true,
detail: {
view: 'explore-artist-details',
artistMBID: '',
artistName: r.artistCredit,
},
}),
);
}
break;
}
}
/* ── 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`
<page-header heading="Explore"></page-header>
<div class="sr-only" role="status" aria-live="polite">
${this.liveStatus()}
</div>
${this.renderSearchInput()}
${this.loading
? html`<div class="loading-indicator">Searching\u2026</div>`
: nothing}
${this.error
? html`<div class="error-message">
<wa-icon name="triangle-exclamation"></wa-icon>
${this.error}
</div>`
: nothing}
${this.renderBody()}
<inline-notice
region=${ExploreRegion}
testid="explore-action-message"
></inline-notice>
${this.renderExploreContextMenu()}
`;
}
/** "Searching…" and the error block were both silent (a11y.12). */
private liveStatus(): string {
if (this.error) return this.error;
if (this.loading) return 'Searching…';
const results = this.results;
if (!results) return '';
const count =
(results.artists?.length ?? 0)
+ (results.releaseGroups?.length ?? 0)
+ (results.recordings?.length ?? 0);
return count === 0
? 'No results.'
: `${count} result${count === 1 ? '' : 's'}.`;
}
private renderSearchInput() {
const placeholder =
this.searchMode === 'lyrics'
? 'Search by a lyric\u2026'
: 'Search artists, albums, and tracks\u2026';
return html`
<div class="search-mode-tabs">
<button
class="search-mode-tab ${this.searchMode === 'catalog' ? 'active' : ''}"
@click=${() => this.setSearchMode('catalog')}
>
<wa-icon name="magnifying-glass"></wa-icon>
Catalog
</button>
<button
class="search-mode-tab ${this.searchMode === 'lyrics' ? 'active' : ''}"
@click=${() => this.setSearchMode('lyrics')}
>
<wa-icon name="quote-left"></wa-icon>
Lyrics
</button>
</div>
<div class="search-container">
<wa-icon class="search-icon" name="magnifying-glass"></wa-icon>
<input
type="text"
aria-label=${this.searchMode === 'lyrics'
? 'Search the catalog by a lyric'
: 'Search the catalog'}
placeholder=${placeholder}
.value=${this.searchQuery}
@input=${this.handleInput}
@keydown=${this.handleKeydown}
/>
${this.searchQuery
? html`
<button
class="clear-button"
@click=${this.handleClear}
>
<wa-icon name="xmark"></wa-icon>
</button>
`
: nothing}
</div>
`;
}
private renderLyricsBody() {
if (this.queryTooShort) {
return html`<div class="status-message">Keep typing…</div>`;
}
if (!this.searchQuery.trim() && !this.lyricsResults) {
return html`<div class="status-message">
Type a line of lyrics to find the track in your library.
</div>`;
}
if (this.loading && !this.lyricsResults) return nothing;
if (this.lyricsResults && this.lyricsResults.length === 0) {
return html`<div class="status-message">
No tracks with lyrics matching “${this.searchQuery}”.
</div>`;
}
if (!this.lyricsResults) return nothing;
return html`
<div class="lyrics-results">
${this.lyricsResults.map(
(hit) => html`
<button
class="lyrics-hit"
@click=${() => this.playLyricHit(hit)}
title="Play ${hit.title}"
>
<wa-icon class="lyrics-hit-play" name="play"></wa-icon>
<span class="lyrics-hit-main">
<span class="lyrics-hit-title">${hit.title || 'Unknown title'}</span>
<span class="lyrics-hit-meta">
${hit.artist || 'Unknown artist'}${hit.album
? html` · ${hit.album}`
: nothing}
</span>
</span>
</button>
`,
)}
</div>
`;
}
private renderBody() {
if (this.searchMode === 'lyrics') {
return this.renderLyricsBody();
}
// Query too short
if (this.queryTooShort) {
return html`<div class="status-message">
Keep typing\u2026
</div>`;
}
// No query entered yet: this is where the page used to be a
// sentence telling the user to type into a 1.1 M-row catalog
// they had never seen (`H-23`).
if (!this.searchQuery.trim() && !this.results) {
return this.renderShelves();
}
// 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`<div class="status-message">
No results found for \u201c${this.searchQuery}\u201d
</div>`;
}
return html`
<div class="results-container">
${this.results.topResults?.length
? html`<top-results-row
.results=${this.results.topResults}
.query=${this.searchQuery}
@top-result-click=${this.handleTopResultClick}
></top-results-row>`
: nothing}
${hasArtists
? this.renderArtistsSection(this.results.artists!.slice(0, MAX_SECTION_RESULTS))
: nothing}
${hasAlbums
? this.renderAlbumsSection(this.results.releaseGroups!.slice(0, MAX_SECTION_RESULTS))
: nothing}
${hasTracks
? this.renderTracksSection(this.results.recordings!.slice(0, MAX_SECTION_RESULTS))
: nothing}
</div>
`;
}
return nothing;
}
/* ── Shelves ── */
/**
* The page before a query: shelves, or an honest account of why
* there are none.
*
* Explore's data is a downloaded artifact, so "no shelves" is not
* the same statement it is on Home. Home's empty shelf means a
* library with no history, which is true and permanent-ish. Here it
* can mean the catalog has not arrived yet — so a page that renders
* nothing and explains nothing is the blank panel this whole feature
* is removing, wearing different clothes.
*/
private renderShelves() {
const page = this.shelves;
// Not asked yet. Deliberately blank rather than a spinner: the
// call is three local index queries and lands in a few
// milliseconds, and a spinner that flashes is worse than a beat
// of nothing.
if (!page) return nothing;
if (!page.shelves?.length) {
return html`
<div class="shelves-empty">
<wa-icon
name=${page.state === 'building'
? 'hourglass-half'
: 'compact-disc'}
></wa-icon>
<p class="shelves-empty-title">
${page.state === 'building'
? 'The music catalog is still downloading.'
: 'The music catalog has not been downloaded yet.'}
</p>
<p class="shelves-empty-body">
${page.state === 'building'
? 'Suggestions will appear here when it finishes. Search still works over whatever has arrived.'
: 'Explore suggests music from a catalog of over a million albums. You can fetch it from Settings — search still works without it, over anything already indexed.'}
</p>
</div>
`;
}
return html`
<div class="results-container">
${page.state === 'building'
? html`<p class="shelves-note">
The catalog is still downloading, so there is more
to come.
</p>`
: nothing}
${(page.shelves ?? []).map((shelf) =>
shelf.artists?.length
? this.renderArtistsSection(
shelf.artists,
shelf.title,
shelf.subtitle,
)
: this.renderAlbumsSection(
shelf.albums ?? [],
shelf.title,
shelf.subtitle,
),
)}
</div>
`;
}
/* ── Section Renderers ── */
private renderArtistsSection(
artists: MBArtist[],
heading = 'Artists',
subtitle = '',
) {
return html`
<section>
<h3 class="section-header">${heading}</h3>
${subtitle
? html`<p class="section-reason">${subtitle}</p>`
: nothing}
<div class="horizontal-row">
${artists.map((a) => {
const owned = isOwned(a);
const name = a.englishName || a.name;
return html`
<div
class=${classMap({ 'artist-card': true, unowned: !owned })}
@click=${() => this.navigateToArtist(a)}
role="button"
tabindex="0"
aria-label=${ownershipLabel(owned, 'Artist', name, 'artist')}
@keydown=${(e: KeyboardEvent) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
this.navigateToArtist(a);
}
}}
>
<div
class="artist-avatar"
style="background: ${avatarBackground(a.name)}"
>
${this.artistImageCache.get(a.mbid)
? html`<img
src="${this.artistImageCache.get(a.mbid)}"
alt="${a.englishName || a.name}"
/>`
: (a.englishName || a.name).charAt(0).toUpperCase()}
</div>
<div class="artist-name" title="${a.englishName || a.name}">
${a.englishName || a.name}
</div>
${a.englishName
? html`<div class="artist-native-name">${a.name}</div>`
: nothing}
${a.disambiguation
? html`<div class="artist-disambiguation">
${a.disambiguation}
</div>`
: nothing}
${a.country
? html`<div class="artist-country">
${a.country}
</div>`
: nothing}
</div>
`;
})}
</div>
</section>
`;
}
private renderAlbumsSection(
releaseGroups: MBReleaseGroup[],
heading = 'Albums',
subtitle = '',
) {
return html`
<section>
<h3 class="section-header">${heading}</h3>
${subtitle
? html`<p class="section-reason">${subtitle}</p>`
: nothing}
<div class="horizontal-row">
${releaseGroups.map((rg) => {
const artURL = this.thumbnailCache.get(rg.mbid) || '';
const year = extractYear(rg.firstReleaseDate);
const owned = isOwned(rg);
const badge = albumBadgeFor(rg, rg.mbid);
return html`
<div
class=${classMap({
'album-card': true,
owned,
unowned: !owned,
})}
aria-label=${ownershipLabel(owned, 'Album', rg.title, 'album')}
@click=${() => this.navigateToAlbum(rg)}
@dblclick=${() => this.onAlbumCardDblClick(rg)}
@contextmenu=${(e: MouseEvent) =>
this.onExploreContextMenu(e, {
kind: 'album',
mbid: rg.mbid,
localId: rg.localId,
title: rg.title,
})}
role="button"
tabindex="0"
@keydown=${(e: KeyboardEvent) =>
this.onCardKeydown(
e,
() => this.navigateToAlbum(rg),
{
kind: 'album',
mbid: rg.mbid,
localId: rg.localId,
title: rg.title,
},
)}
>
<div class="album-art-container">
${artURL
? html`<img
src="${artURL}"
alt="${rg.title}"
loading="lazy"
@error=${this.handleImageError}
/>`
: nothing}
<div
class="album-art-fallback"
style="${artURL ? 'display: none' : ''}"
>
<wa-icon name="compact-disc"></wa-icon>
</div>
</div>
<div class="album-title" title="${rg.title}">
${rg.title}
</div>
<div class="album-artist">${creditLink(creditStore.credits(rg.mbid), rg.artistCredit, rg.artistMbid ?? '')}</div>
<div class="album-meta">
<div class="album-meta-text">
${rg.primaryType
? html`<span class="type-badge"
>${rg.primaryType}</span
>`
: nothing}
${year ? html`<span>${year}</span>` : nothing}
</div>
${badge.status === 'in-library'
? nothing
: html`<library-status-indicator
status=${badge.status}
owned=${badge.owned}
expected=${badge.expected}
entity-type="album"
label=${rg.title}
request-mbid=${rg.mbid}
request-artist=${rg.artistCredit ?? ''}
></library-status-indicator>`}
</div>
</div>
`;
})}
</div>
</section>
`;
}
private renderTracksSection(recordings: MBRecording[]) {
return html`
<section>
<h3 class="section-header">Tracks</h3>
<div class="track-list">
${recordings.map((r) => {
const owned = isOwned(r);
return html`
<div
class=${classMap({
'track-item': true,
owned,
unowned: !owned,
})}
role="button"
tabindex="0"
aria-disabled=${owned ? 'false' : 'true'}
aria-label=${ownershipLabel(owned, 'Play', r.title, 'track')}
@dblclick=${() => this.onRecordingRowDblClick(r)}
@contextmenu=${(e: MouseEvent) =>
this.onExploreContextMenu(e, {
kind: 'recording',
mbid: r.mbid,
localId: r.localId,
title: r.title,
})}
@keydown=${(e: KeyboardEvent) =>
this.onCardKeydown(
e,
() => this.onRecordingRowDblClick(r),
{
kind: 'recording',
mbid: r.mbid,
localId: r.localId,
title: r.title,
},
)}
>
<div class="track-info">
<div class="track-title">
${trackLink(r.title, r.releaseName ?? '', r.releaseGroupMbid ?? '', r.mbid)}
</div>
<div class="track-artist">
${creditLink(creditStore.credits(r.mbid), r.artistCredit, r.artistMbid ?? '')}
</div>
</div>
<div class="track-meta">
${r.popularity > 0
? html`<span class="track-popularity">${formatPopularity(r.popularity)}</span>`
: nothing}
${r.length > 0
? html`<span class="track-duration">${formatDuration(r.length)}</span>`
: nothing}
</div>
${owned
? nothing
: html`<library-status-indicator
status=${libraryStatusFor(false, r.mbid)}
entity-type="track"
label=${r.title}
request-mbid=${r.mbid}
request-artist=${r.artistCredit ?? ''}
></library-status-indicator>`}
</div>
`;
})}
</div>
</section>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'explore-view': ExploreView;
}
}