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.
2318 lines
84 KiB
TypeScript
2318 lines
84 KiB
TypeScript
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;
|
||
}
|
||
}
|