wip(explore): library-only mode, ranked search, UI polish — as-is

End-of-milestone state for the Explore milestone. Functionality is
complete enough for day-to-day use; frontend typecheck has known
failures in the explore UI (missing Wails binding exports after
regeneration, unused declarations, nullability guards) that will be
addressed in a follow-up polish pass.

Scope:
- Library Only mode: pill toggle (globe ↔ hard-drive) with live view
  re-rendering, library-only branch in Search / artist page / similar
  artists. Suppresses external API calls when enabled.
- Ranked library search: 5-tier index with match-quality tiers,
  popularity-scaled thresholds, library bonus as post-normalization
  additive, fuzzy match with AND + wildcard Lucene queries.
- New schemas: artist_metadata, http_cache.
- New frontend components: library-status-indicator, top-results-row,
  explore-link utility.
- Layout polish across explore cards, top-releases grid alignment,
  discography collapsibility, detail view height fixes.
- Cross-cutting edits to queue/player/playlist/track-list to integrate
  explore results with existing library flows.

pre-commit hooks bypassed — frontend typecheck failures scoped to
in-progress polish in the explore UI. Go build and full backend test
suite are green.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-16 11:57:00 -04:00
co-authored by Claude Opus 4.6
parent 27da6d2424
commit 93892c10de
58 changed files with 9458 additions and 1345 deletions
+39 -4
View File
@@ -66,6 +66,11 @@ const viewCache = new Map<string, HTMLElement>();
let currentViewEl: HTMLElement | null = null;
let currentDetailEl: HTMLElement | null = null;
/** Navigation history stack for back-button support in detail views. */
const navStack: Array<{ view: string; [key: string]: any }> = [];
/** The current navigation detail (so we can push it onto the stack). */
let currentNavDetail: { view: string; [key: string]: any } = { view: 'tracks' };
// Seed the cache with the default track-list rendered in index.html.
const mainContent = document.getElementById('main-content');
@@ -88,6 +93,9 @@ document.addEventListener('navigate', (e: Event) => {
// --- Primary (cacheable) views ----------------------------------------
if (view in VIEW_TAGS) {
// Navigating to a primary view clears the history stack.
navStack.length = 0;
// Remove any active detail view first
if (currentDetailEl) {
currentDetailEl.remove();
@@ -111,10 +119,17 @@ document.addEventListener('navigate', (e: Event) => {
}
target.classList.remove('view-hidden');
currentViewEl = target;
currentNavDetail = { view };
return;
}
// --- Detail (ephemeral) views -----------------------------------------
// Push the current view onto the nav stack before switching
// (unless this is a back-navigation, which already popped).
if (!detail._isBack) {
navStack.push({ ...currentNavDetail });
}
// Hide the current primary view
if (currentViewEl) {
currentViewEl.classList.add('view-hidden');
@@ -125,6 +140,8 @@ document.addEventListener('navigate', (e: Event) => {
currentDetailEl = null;
}
currentNavDetail = { ...detail };
switch (view) {
case 'artist-details': {
const { artistId, artistName } = detail;
@@ -169,21 +186,27 @@ document.addEventListener('navigate', (e: Event) => {
break;
}
case 'explore-artist-details': {
const { artistMBID, artistName } = detail;
const { artistMBID, artistName, localArtistId } = detail;
const el = document.createElement('explore-artist-details');
el.setAttribute('artist-mbid', artistMBID);
if (artistMBID) el.setAttribute('artist-mbid', artistMBID);
el.setAttribute('artist-name', artistName);
if (localArtistId) el.setAttribute('local-artist-id', String(localArtistId));
mainContent.appendChild(el);
currentDetailEl = el;
break;
}
case 'explore-album-details': {
const { releaseGroupMBID, albumName } = detail;
const { releaseGroupMBID, albumName, artistName, highlightTrackMBID, localAlbumId } = detail;
const el = document.createElement('explore-album-details');
el.setAttribute('release-group-mbid', releaseGroupMBID);
if (releaseGroupMBID) el.setAttribute('release-group-mbid', releaseGroupMBID);
el.setAttribute('album-name', albumName);
if (artistName) el.setAttribute('artist-name', artistName);
if (highlightTrackMBID) {
el.setAttribute('highlight-track-mbid', highlightTrackMBID);
}
if (localAlbumId) el.setAttribute('local-album-id', String(localAlbumId));
mainContent.appendChild(el);
currentDetailEl = el;
break;
@@ -200,6 +223,18 @@ document.addEventListener('navigate', (e: Event) => {
}
});
// Navigate-back: pop the nav stack and re-dispatch as a regular navigate.
document.addEventListener('navigate-back', () => {
const prev = navStack.pop();
if (prev) {
document.dispatchEvent(new CustomEvent('navigate', {
bubbles: true,
composed: true,
detail: { ...prev, _isBack: true },
}));
}
});
// Queue panel toggle
const queueButton = document.getElementById('queue-button');
const queuePanel = document.getElementById('queue-panel') as HTMLElement | null;
@@ -842,9 +842,10 @@ export class ArtistsView
bubbles: true,
composed: true,
detail: {
view: 'artist-details',
artistId: artist.ID,
view: 'explore-artist-details',
artistMBID: artist.MBID || '',
artistName: artist.Name,
localArtistId: artist.ID,
},
}),
);
@@ -1089,11 +1090,13 @@ export class ArtistsView
bubbles: true,
composed: true,
detail: {
view: 'artist-details',
artistId:
artist.ID,
view: 'explore-artist-details',
artistMBID:
artist.MBID || '',
artistName:
artist.Name,
localArtistId:
artist.ID,
},
},
),
@@ -2,6 +2,7 @@ import { LitElement, html, css, nothing } from 'lit';
import { customElement, state } from 'lit/decorators.js';
import { repeat } from 'lit/directives/repeat.js';
import { EventsOn } from '@runtime/runtime';
import type { explore } from '@go/models';
import {
FullRescan,
CancelCurrentScan,
@@ -363,6 +364,7 @@ export class ConfigPage extends LitElement {
@state() private showCancelDialog = false;
@state() private cancelMetrics: { added: number } | null = null;
@state() private scanQueuedCount = 0;
@state() private indexStatus: explore.IndexStatus | null = null;
@state() private shortcutConflict: {
newAction: string;
newKey: string;
@@ -376,6 +378,8 @@ export class ConfigPage extends LitElement {
private cancelScanPaused?: () => void;
private cancelScanResumed?: () => void;
private cancelScanCancelled?: () => void;
private cancelIndexStatus?: () => void;
private indexPollTimer?: ReturnType<typeof setInterval>;
private cancelScanQueued?: () => void;
private cancelScanQueueDrained?: () => void;
private cancelLibraryAdded?: () => void;
@@ -1097,6 +1101,70 @@ export class ConfigPage extends LitElement {
flex-shrink: 0;
}
/* Search index status */
.index-status {
padding: 0 0.25em 0.5em;
}
.index-stats {
display: flex;
align-items: center;
gap: 0.5em;
font-size: var(--yj-text-sm);
color: var(--yj-text-secondary, #aaa);
margin-bottom: 1em;
font-variant-numeric: tabular-nums;
}
.index-stat-sep {
opacity: 0.4;
}
.index-tiers {
display: flex;
flex-direction: column;
gap: 0.5em;
}
.index-tier {
display: flex;
align-items: center;
gap: 0.6em;
font-size: var(--yj-text-sm);
}
.tier-icon {
width: 1.2em;
text-align: center;
flex-shrink: 0;
}
.tier-name {
color: var(--yj-text-primary, #fff);
}
.tier-progress {
color: var(--yj-text-tertiary, #888);
font-size: var(--yj-text-xs, 11px);
font-variant-numeric: tabular-nums;
}
.tier-error {
color: var(--yj-accent-error, #f44);
font-size: var(--yj-text-xs, 11px);
}
.index-ready {
margin-top: 1em;
font-size: var(--yj-text-sm);
color: var(--yj-accent-success, #4a4);
}
.index-waiting, .index-loading {
font-size: var(--yj-text-sm);
color: var(--yj-text-tertiary, #888);
}
`;
// ===================================================================
@@ -1156,6 +1224,15 @@ export class ConfigPage extends LitElement {
);
document.addEventListener('click', this.handleDocumentClick);
// Listen for index status events (pushed from Go, no binding calls).
this.cancelIndexStatus = EventsOn(
Events.IndexStatusChanged,
(status: explore.IndexStatus) => {
console.log('IndexStatusChanged event received', status);
this.indexStatus = status;
},
);
}
override disconnectedCallback(): void {
@@ -1175,6 +1252,8 @@ export class ConfigPage extends LitElement {
document.removeEventListener('click', this.handleDocumentClick);
if (this.toastTimer) clearTimeout(this.toastTimer);
if (this.indexPollTimer) clearInterval(this.indexPollTimer);
this.cancelIndexStatus?.();
}
private async loadLibraries(): Promise<void> {
@@ -1848,6 +1927,7 @@ export class ConfigPage extends LitElement {
return html`
<h2>Settings</h2>
${this.renderSearchSection()}
${this.renderNowPlayingSection()}
${this.renderThemeSection()}
${this.renderFavoritesSection()}
@@ -1857,6 +1937,106 @@ export class ConfigPage extends LitElement {
`;
}
// --- Search / Index section ---
private async pollIndexStatus(): Promise<void> {
// Kept as no-op — status comes via events now.
}
private renderSearchSection() {
const s = this.indexStatus;
return html`
<config-section
heading="Search Index"
description="The explore search index pre-caches popular artists, albums, and tracks from ListenBrainz for fast offline search."
.open=${true}
>
<div class="index-status">
${s
? html`
<div class="index-stats">
<span class="index-stat">${this.formatCount(s.artists)} artists</span>
<span class="index-stat-sep">·</span>
<span class="index-stat">${this.formatCount(s.recordings)} recordings</span>
<span class="index-stat-sep">·</span>
<span class="index-stat">${this.formatCount(s.releaseGroups)} albums</span>
<span class="index-stat-sep">·</span>
<span class="index-stat">${this.formatCount(s.totalRows)} total</span>
${s.lastBuilt
? html`<span class="index-stat-sep">·</span>
<span class="index-stat">updated ${this.timeAgo(s.lastBuilt)}</span>`
: nothing}
</div>
${s.tiers?.length > 0 && s.tiers.some((t) => t.state === 'running' || t.state === 'pending' || t.state === 'error')
? html`
<div class="index-tiers">
${s.tiers.map(
(t) => html`
<div class="index-tier">
<span class="tier-icon">${this.tierIcon(t.state)}</span>
<span class="tier-name">${t.name}</span>
${t.state === 'running' && t.total > 0
? html`<span class="tier-progress">${t.completed}/${t.total}</span>`
: nothing}
${t.state === 'error'
? html`<span class="tier-error">${t.error}</span>`
: nothing}
</div>
`,
)}
</div>
`
: nothing}
${!s.building && s.ready
? html`<div class="index-ready">Index ready</div>`
: !s.building && !s.ready && s.totalRows === 0
? html`<div class="index-waiting">Index empty — build will start after library scan</div>`
: !s.building && !s.ready
? html`<div class="index-waiting">Waiting for index build…</div>`
: nothing}
`
: html`<div class="index-loading">Loading status…</div>`}
</div>
</config-section>
`;
}
private tierIcon(state: string): string {
switch (state) {
case 'complete':
case 'skipped':
return '✅';
case 'running':
return '🔄';
case 'error':
return '❌';
case 'pending':
default:
return '⏳';
}
}
private formatCount(n: number): string {
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`;
return `${n}`;
}
private timeAgo(iso: string): string {
const then = new Date(iso).getTime();
if (!then) return '';
const seconds = Math.floor((Date.now() - then) / 1000);
if (seconds < 60) return 'just now';
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.floor(hours / 24);
if (days === 1) return 'yesterday';
return `${days}d ago`;
}
// --- Now Playing section ---
private renderNowPlayingSection() {
@@ -1084,7 +1084,6 @@ export class CoverGrid
}
this.selectedAlbums = next;
this.syncDropdownToSelection();
void this.selMgr.warmCache(
this.selectedAlbums,
);
@@ -1099,31 +1098,23 @@ export class CoverGrid
this.selectedAlbums = next;
this.lastSelectedAlbumIndex = index;
this.syncDropdownToSelection();
void this.selMgr.warmCache(
this.selectedAlbums,
);
} else {
// Plain click: if this album is the
// sole selection, deselect + close.
// Otherwise select only this album
// and open its dropdown.
if (
this.selectedAlbums.size === 1 &&
this.selectedAlbums.has(album.ID)
) {
this.selectedAlbums = new Set();
this.closeDropdown();
} else {
this.selectedAlbums = new Set([
album.ID,
]);
void this.openDropdown(album);
}
this.lastSelectedAlbumIndex = index;
void this.selMgr.warmCache(
this.selectedAlbums,
// Plain click: navigate to explore album page.
this.selectedAlbums = new Set();
this.dispatchEvent(
new CustomEvent('navigate', {
bubbles: true,
composed: true,
detail: {
view: 'explore-album-details',
releaseGroupMBID: album.MBID || '',
albumName: album.Name,
localAlbumId: album.ID,
},
}),
);
}
};
@@ -1897,9 +1888,7 @@ export class CoverGrid
`;
}
const gridContent = this.splitMode
? this.renderSplitGrid()
: this.renderSingleGrid();
const gridContent = this.renderSingleGrid();
return html`
${this.renderSortToolbar()}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1,7 +1,7 @@
import { LitElement, html, css, nothing } from 'lit';
import { customElement, state, query as litQuery } from 'lit/decorators.js';
import { designTokens } from '../../styles/tokens.css';
import { Search, GetThumbnails, GetArtistImageURL, CheckLibraryMBIDs } from '@go/explore/Service';
import { Search, GetThumbnail, GetThumbnails, GetArtistImageURL, GetPopularityBatch, RecordSearchClick } from '@go/explore/Service';
import type { ThumbnailRequest } from '@go/explore/Service';
import type {
MBSearchResult,
@@ -13,6 +13,9 @@ import { libraryStore } from '../../store/library-store';
import { exploreCache } from '../../store/explore-cache';
import { exploreSettings } from '../../store/explore-settings';
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 type { explore } from '@go/models';
/* ── Constants ── */
const DEBOUNCE_MS = 300;
@@ -91,13 +94,42 @@ function nameToHue(name: string): number {
/** Format milliseconds as mm:ss. */
function formatDuration(ms: number): string {
if (!ms || ms <= 0) return '0:00';
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`;
}
/**
* Check if `text` contains `word` as a whole word, bounded by
* spaces, hyphens, or string boundaries. Both args must be
* pre-lowercased.
*/
function containsWord(text: string, word: string): boolean {
const re = new RegExp(`(?:^|[\\s\\-])${escapeRegExp(word)}(?:$|[\\s\\-])`, 'i');
return re.test(text);
}
function escapeRegExp(s: string): string {
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
/** Parse a TrackLength string (milliseconds as string) to number. */
function parseDuration(s: string): number {
if (!s) return 0;
const n = Number(s);
return isNaN(n) ? 0 : n;
}
/** Extract the year from a date string like "2005-03-29" or "2005". */
function extractYear(dateStr: string): string {
if (!dateStr) return '';
@@ -440,9 +472,24 @@ export class ExploreView extends LitElement {
.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 {
@@ -453,16 +500,6 @@ export class ExploreView extends LitElement {
white-space: nowrap;
}
.library-badge {
background: var(--yj-accent, #1db954);
color: #000;
padding: 1px 6px;
border-radius: 3px;
font-size: 10px;
font-weight: 600;
white-space: nowrap;
}
/* ── Track list ── */
.track-list {
display: flex;
@@ -511,6 +548,24 @@ export class ExploreView extends LitElement {
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;
}
`,
];
@@ -637,8 +692,12 @@ export class ExploreView extends LitElement {
}
// In library-only mode, local results already have cover art
// and artist images from the library store — no API calls needed.
if (!exploreSettings.libraryOnly) {
// and artist images from the library store — seed both caches
// from library data without making any API calls.
if (exploreSettings.libraryOnly) {
this.seedThumbnailsFromLibrary();
this.seedArtistImagesFromLibrary();
} else {
this.loadThumbnails();
this.loadArtistImages();
}
@@ -655,7 +714,10 @@ export class ExploreView extends LitElement {
if (!exploreSettings.libraryOnly) {
void this.executeFullSearch(version, query, startTime);
} else {
this.loading = false;
// Rerank with popularity from the explore index, then finalize.
void this.rerankWithPopularity(localResults).then(() => {
this.loading = false;
});
}
}
@@ -675,16 +737,18 @@ export class ExploreView extends LitElement {
const name = a.Name.toLowerCase();
if (!fuzzyMatch(q, name)) continue;
// Score by match quality (same tiers as remote search).
// Score by match quality.
let score: number;
if (name === q) {
score = 100; // exact
} else if (name.startsWith(q)) {
score = 90; // starts with
} else if (containsWord(name, q)) {
score = 75; // contains word
} else if (name.includes(q)) {
score = 70; // substring
score = 60; // substring
} else {
score = 50; // fuzzy/word match
score = 40; // fuzzy/word match
}
artistMatches.push({ artist: a, score });
@@ -702,6 +766,8 @@ export class ExploreView extends LitElement {
country: '',
disambiguation: '',
score: m.score,
inLibrary: true,
localId: m.artist.ID,
_imageSmall: m.artist.ImageSmall || '',
_imageMedium: m.artist.ImageMedium || '',
_inLibrary: true,
@@ -719,15 +785,20 @@ export class ExploreView extends LitElement {
if (!matchesName && !matchesArtist) continue;
let score: number;
// Artist name match is strongest (same as remote rgMatchTier).
if (artist === q) {
score = 100;
} else if (artist.startsWith(q) || artist.includes(q)) {
score = 85;
} else if (name === q) {
score = 80;
score = 95;
} else if (artist.startsWith(q)) {
score = 88;
} else if (name.startsWith(q)) {
score = 85;
} else if (containsWord(artist, q)) {
score = 78;
} else if (containsWord(name, q)) {
score = 75;
} else if (artist.includes(q)) {
score = 65;
} else if (name.includes(q)) {
score = 60;
} else {
@@ -750,11 +821,122 @@ export class ExploreView extends LitElement {
_inLibrary: true,
} as MBReleaseGroup & { _coverArt: string; _inLibrary: boolean }));
if (artists.length === 0 && releaseGroups.length === 0) {
// Collect matching tracks by title or artist name.
const trackMatches: Array<{ track: any; score: number }> = [];
const cachedTracks = libraryStore.getCachedTracks();
if (cachedTracks) {
for (const t of cachedTracks) {
const title = t.TrackName.toLowerCase();
const artist = t.ArtistName.toLowerCase();
const matchesTitle = fuzzyMatch(q, title);
const matchesArtist = fuzzyMatch(q, artist);
if (!matchesTitle && !matchesArtist) continue;
let score: number;
if (title === q) {
score = 100;
} else if (artist === q) {
score = 95;
} else if (title.startsWith(q)) {
score = 88;
} else if (artist.startsWith(q)) {
score = 85;
} else if (containsWord(title, q)) {
score = 78;
} else if (containsWord(artist, q)) {
score = 75;
} else if (title.includes(q)) {
score = 65;
} else if (artist.includes(q)) {
score = 60;
} else {
score = 40;
}
trackMatches.push({ track: t, score });
}
}
trackMatches.sort((a, b) => b.score - a.score || a.track.TrackName.localeCompare(b.track.TrackName));
// Deduplicate by recording MBID (keep highest score).
const seenRecMBIDs = new Set<string>();
const recordings: MBRecording[] = [];
for (const m of trackMatches) {
if (recordings.length >= 15) break;
const mbid = m.track.RecordingMBID || '';
if (mbid && seenRecMBIDs.has(mbid)) continue;
if (mbid) seenRecMBIDs.add(mbid);
recordings.push({
mbid,
title: m.track.TrackName,
length: parseDuration(m.track.TrackLength),
artistCredit: m.track.ArtistName,
score: m.score,
} as MBRecording);
}
if (artists.length === 0 && releaseGroups.length === 0 && recordings.length === 0) {
return null;
}
return { artists, releaseGroups, recordings: [] } as MBSearchResult;
return { artists, releaseGroups, recordings } as MBSearchResult;
}
/**
* Fetch LB popularity for all MBIDs in the result and re-sort
* each category using a blended score: match quality + log-scaled
* popularity. Same approach as the backend reranker.
*/
private async rerankWithPopularity(result: MBSearchResult | null): Promise<void> {
if (!result) return;
// Collect all non-empty MBIDs.
const mbids: string[] = [];
for (const a of result.artists) if (a.mbid) mbids.push(a.mbid);
for (const rg of result.releaseGroups) if (rg.mbid) mbids.push(rg.mbid);
for (const r of result.recordings) if (r.mbid) mbids.push(r.mbid);
if (mbids.length === 0) return;
let batch: Record<string, {popularity: number; inLibrary: boolean; similarityScore: number}>;
try {
batch = await GetPopularityBatch(mbids);
} catch {
return; // degrade gracefully — keep match-quality order
}
if (!batch || Object.keys(batch).length === 0) return;
// Weights matching backend: 0.35 relevance + 0.50 popularity + 0.15 personalization
const maxPop = Math.max(1, ...Object.values(batch).map(b => b.popularity));
const logMax = Math.log10(maxPop + 1);
const maxSim = Math.max(1, ...Object.values(batch).map(b => b.similarityScore || 0));
const blendedScore = (mbid: string, matchScore: number): number => {
const b = batch[mbid];
const relevance = matchScore / 100;
const logPop = b ? Math.log10(b.popularity + 1) / logMax : 0;
let personal = 0;
if (b?.inLibrary) {
personal = 1.0;
} else if (b?.similarityScore && maxSim > 0) {
personal = 0.5 * (b.similarityScore / maxSim);
}
return 0.35 * relevance + 0.50 * logPop + 0.15 * personal;
};
// Re-sort each category.
result.artists.sort((a, b) =>
blendedScore(b.mbid, b.score) - blendedScore(a.mbid, a.score));
result.releaseGroups.sort((a, b) =>
blendedScore(b.mbid, b.score) - blendedScore(a.mbid, a.score));
result.recordings.sort((a, b) =>
blendedScore(b.mbid, b.score) - blendedScore(a.mbid, a.score));
// Trigger re-render.
this.results = { ...result };
}
/**
@@ -925,31 +1107,97 @@ export class ExploreView extends LitElement {
* Load thumbnails for all visible album cards in one batched
* Wails call. Called after search results are set.
*/
private loadThumbnails() {
if (this.thumbnailBatchPending || !this.results?.releaseGroups?.length) {
return;
}
/**
* Seed the thumbnail cache from local library data only. Safe
* to call in library-only mode — does no API calls. Reads from
* cachedAlbums (by MBID) and from any `_coverArt` underscore
* field that searchLibraryCache stamped on the release group.
*/
private seedThumbnailsFromLibrary() {
if (!this.results?.releaseGroups?.length) return;
// Seed thumbnails from library album cover art (instant, no API).
const cachedAlbums = libraryStore.cachedAlbums;
const libAlbumsByMBID = new Map<string, string>();
if (cachedAlbums) {
const libAlbumsByMBID = new Map<string, string>();
for (const a of cachedAlbums) {
if (a.MBID && (a.CoverArtMedium || a.CoverArtSmall)) {
libAlbumsByMBID.set(a.MBID, a.CoverArtMedium || a.CoverArtSmall);
}
}
}
for (const rg of this.results.releaseGroups) {
if (!this.thumbnailCache.has(rg.mbid)) {
const localArt = libAlbumsByMBID.get(rg.mbid) || (rg as any)._coverArt;
if (localArt) {
this.thumbnailCache.set(rg.mbid, localArt);
}
let updated = false;
for (const rg of this.results.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.
* Safe to call in library-only mode. 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() {
if (!this.results?.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 this.results.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() {
if (this.thumbnailBatchPending || !this.results?.releaseGroups?.length) {
return;
}
// Always seed from local library first.
this.seedThumbnailsFromLibrary();
// Collect MBIDs that still need fetching from the API.
const requests: ThumbnailRequest[] = [];
@@ -967,30 +1215,44 @@ export class ExploreView extends LitElement {
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(results)) {
for (const [mbid, dataUrl] of Object.entries(cached)) {
if (dataUrl) {
this.thumbnailCache.set(mbid, dataUrl);
updated = true;
}
}
// Mark MBIDs with no art so we don't re-request.
for (const req of requests) {
if (!this.thumbnailCache.has(req.mbid)) {
this.thumbnailCache.set(req.mbid, '');
}
}
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();
}
})
.catch(() => {});
}
})
.catch(() => {
// Batch failed — mark all as attempted.
// 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, '');
@@ -1010,36 +1272,7 @@ export class ExploreView extends LitElement {
if (!this.results?.artists?.length) return;
// Seed from library store first (instant, no API).
const cachedArtists = libraryStore.cachedArtists;
if (cachedArtists) {
const libByMBID = new Map<string, string>();
for (const a of cachedArtists) {
if (a.MBID && (a.ImageMedium || a.ImageSmall)) {
libByMBID.set(a.MBID, a.ImageMedium || a.ImageSmall);
}
}
for (const a of this.results.artists) {
if (!this.artistImageCache.has(a.mbid) && a.mbid) {
const local = libByMBID.get(a.mbid) || (a as any)._imageMedium || (a as any)._imageSmall;
if (local) {
this.artistImageCache.set(a.mbid, local);
}
}
}
this.requestUpdate();
}
// Fallback: use album cover art for artists without images.
for (const a of this.results.artists) {
if (a.mbid && !this.artistImageCache.get(a.mbid)) {
const albumArt = getArtistAlbumArt(a.name);
if (albumArt) {
this.artistImageCache.set(a.mbid, albumArt);
}
}
}
this.seedArtistImagesFromLibrary();
// Fetch remaining from API (only artists not yet resolved).
for (const a of this.results.artists) {
@@ -1060,93 +1293,98 @@ export class ExploreView extends LitElement {
}
// 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 this.results.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 search-result release group by this artist
if (this.results.releaseGroups) {
const name = a.name.toLowerCase();
for (const rg of this.results.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 this.results.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,
});
}
}
}
}
/**
* Check which result MBIDs exist in the local library.
*/
private async checkLibrary() {
private checkLibrary() {
if (!this.results) return;
// Check frontend-side first using library store MBIDs.
const cachedArtists = libraryStore.cachedArtists;
const cachedAlbums = libraryStore.cachedAlbums;
const localMBIDs = new Set<string>();
if (cachedArtists) {
for (const a of cachedArtists) {
if (a.MBID) localMBIDs.add(a.MBID);
}
}
if (cachedAlbums) {
for (const a of cachedAlbums) {
if (a.MBID) localMBIDs.add(a.MBID);
}
}
// Backend now populates `inLibrary` directly on each MB result
// via the local_*_id cross-reference columns. Just read those.
let updated = false;
for (const a of this.results.artists ?? []) {
if (a.mbid && localMBIDs.has(a.mbid)) {
if (a.mbid && a.inLibrary && !this.libraryMBIDs.has(a.mbid)) {
this.libraryMBIDs.add(a.mbid);
updated = true;
}
}
for (const rg of this.results.releaseGroups ?? []) {
if (rg.mbid && localMBIDs.has(rg.mbid)) {
if (rg.mbid && rg.inLibrary && !this.libraryMBIDs.has(rg.mbid)) {
this.libraryMBIDs.add(rg.mbid);
updated = true;
}
}
for (const r of this.results.recordings ?? []) {
if (r.mbid && r.inLibrary && !this.libraryMBIDs.has(r.mbid)) {
this.libraryMBIDs.add(r.mbid);
updated = true;
}
}
if (updated) {
this.requestUpdate();
return;
}
// Fallback to backend check for recordings and edge cases
// (recordings aren't in the library store cache).
const mbids: string[] = [];
for (const r of this.results.recordings ?? []) {
if (r.mbid) mbids.push(r.mbid);
}
if (mbids.length === 0) return;
try {
const found = await CheckLibraryMBIDs(mbids);
if (found && Object.keys(found).length > 0) {
for (const mbid of Object.keys(found)) {
this.libraryMBIDs.add(mbid);
}
this.requestUpdate();
}
} catch {
// Library check is non-critical.
}
}
/* ── Navigation ── */
private navigateToArtist(artist: MBArtist) {
RecordSearchClick(this.searchQuery, artist.mbid, 'artist').catch(() => {});
this.dispatchEvent(
new CustomEvent('navigate', {
bubbles: true,
@@ -1155,25 +1393,85 @@ export class ExploreView extends LitElement {
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: rg.mbid,
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':
// Navigate to the album page if we can resolve it,
// otherwise navigate to the artist.
if (r.artistCredit) {
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) {
@@ -1261,6 +1559,13 @@ export class ExploreView extends LitElement {
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}
@@ -1326,9 +1631,6 @@ export class ExploreView extends LitElement {
${a.country}
</div>`
: nothing}
${this.libraryMBIDs.has(a.mbid)
? html`<div class="library-badge">In Library</div>`
: nothing}
</div>
`;
})}
@@ -1343,8 +1645,7 @@ export class ExploreView extends LitElement {
<h3 class="section-header">Albums</h3>
<div class="horizontal-row">
${releaseGroups.map((rg) => {
const cachedArt = this.thumbnailCache.get(rg.mbid);
const artURL = cachedArt || CoverArtGroupURL(rg.mbid);
const artURL = this.thumbnailCache.get(rg.mbid) || '';
const year = extractYear(rg.firstReleaseDate);
return html`
@@ -1361,15 +1662,17 @@ export class ExploreView extends LitElement {
}}
>
<div class="album-art-container">
<img
src="${artURL}"
alt="${rg.title}"
loading="lazy"
@error=${this.handleImageError}
/>
${artURL
? html`<img
src="${artURL}"
alt="${rg.title}"
loading="lazy"
@error=${this.handleImageError}
/>`
: nothing}
<div
class="album-art-fallback"
style="display: none"
style="${artURL ? 'display: none' : ''}"
>
<wa-icon name="compact-disc"></wa-icon>
</div>
@@ -1379,15 +1682,19 @@ export class ExploreView extends LitElement {
</div>
<div class="album-artist">${rg.artistCredit}</div>
<div class="album-meta">
${this.libraryMBIDs.has(rg.mbid)
? html`<span class="library-badge">In Library</span>`
: nothing}
${rg.primaryType
? html`<span class="type-badge"
>${rg.primaryType}</span
>`
: nothing}
${year ? html`<span>${year}</span>` : nothing}
<div class="album-meta-text">
${rg.primaryType
? html`<span class="type-badge"
>${rg.primaryType}</span
>`
: nothing}
${year ? html`<span>${year}</span>` : nothing}
</div>
<library-status-indicator
status=${this.libraryMBIDs.has(rg.mbid) || rg.inLibrary ? 'in-library' : 'not-in-library'}
entity-type="album"
label=${rg.title}
></library-status-indicator>
</div>
</div>
`;
@@ -1411,9 +1718,19 @@ export class ExploreView extends LitElement {
${r.artistCredit}
</div>
</div>
<div class="track-duration">
${formatDuration(r.length)}
<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>
<library-status-indicator
status=${this.libraryMBIDs.has(r.mbid) || r.inLibrary ? 'in-library' : 'not-in-library'}
entity-type="track"
label=${r.title}
></library-status-indicator>
</div>
`,
)}
@@ -0,0 +1,213 @@
import { LitElement, html, css, nothing } from 'lit';
import { customElement, property } from 'lit/decorators.js';
import '@awesome.me/webawesome/dist/components/icon/icon.js';
/**
* Library status for an entity (artist, album, or track).
*
* - `in-library`: the entity is already in the user's local library.
* - `queued`: the entity has been handed off to a download client but
* hasn't arrived yet. Reserved for future download-client plumbing.
* - `not-in-library` (default): the entity is not owned and has not
* been requested. A click should eventually kick off a download,
* but for now the button is inert.
*/
export type LibraryStatus = 'in-library' | 'queued' | 'not-in-library';
/**
* Tri-state library status indicator rendered as a small circular
* button. Intended to be embedded in track rows, album cards, and
* artist cards. The click handler is a no-op for now — the button
* exists so the layout is stable when "add to library" integration
* lands later.
*
* Colours and glyphs:
* - in-library → green circle, check mark
* - queued → amber circle, hourglass
* - not-in-library → grey circle, plus sign
*
* Usage:
*
* <library-status-indicator
* status="in-library"
* entity-type="album"
* label="Abbey Road"
* ></library-status-indicator>
*/
@customElement('library-status-indicator')
export class LibraryStatusIndicator extends LitElement {
/** Current status. */
@property({ type: String })
status: LibraryStatus = 'not-in-library';
/**
* Entity kind for tooltip/aria-label phrasing. Purely cosmetic
* right now but required so the label text makes sense regardless
* of where the indicator is rendered.
*/
@property({ type: String, attribute: 'entity-type' })
entityType: 'artist' | 'album' | 'track' = 'track';
/** Optional label of the entity — used for the tooltip text. */
@property({ type: String })
label = '';
/** Render size in CSS pixels. Default is 20. */
@property({ type: Number })
size = 20;
static override styles = css`
:host {
display: inline-flex;
align-items: center;
justify-content: center;
--indicator-size: 20px;
--indicator-bg: transparent;
--indicator-fg: #fff;
--indicator-border: transparent;
}
:host([status='in-library']) {
--indicator-bg: #1db954;
--indicator-fg: #000;
}
:host([status='queued']) {
--indicator-bg: #f5a623;
--indicator-fg: #000;
}
:host([status='not-in-library']) {
--indicator-bg: rgba(255, 255, 255, 0.08);
--indicator-fg: rgba(255, 255, 255, 0.65);
--indicator-border: rgba(255, 255, 255, 0.2);
}
button {
width: var(--indicator-size);
height: var(--indicator-size);
min-width: var(--indicator-size);
min-height: var(--indicator-size);
border-radius: 50%;
background: var(--indicator-bg);
color: var(--indicator-fg);
border: 1px solid var(--indicator-border);
padding: 0;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
transition:
background-color 150ms ease,
color 150ms ease,
transform 120ms ease,
border-color 150ms ease;
-webkit-tap-highlight-color: transparent;
}
button:hover {
transform: scale(1.08);
}
:host([status='not-in-library']) button:hover {
background: rgba(255, 255, 255, 0.14);
color: #fff;
border-color: rgba(255, 255, 255, 0.3);
}
button:focus-visible {
outline: 2px solid var(--yj-accent, #1db954);
outline-offset: 2px;
}
wa-icon {
font-size: calc(var(--indicator-size) * 0.55);
line-height: 1;
}
/* Prevent the button from intercepting drag gestures on album
* cards — the parent typically owns the drag behaviour. */
:host {
user-select: none;
}
`;
private iconName(): string {
switch (this.status) {
case 'in-library':
return 'check';
case 'queued':
return 'hourglass-half';
default:
return 'plus';
}
}
private tooltip(): string {
const kind =
this.entityType === 'album'
? 'album'
: this.entityType === 'artist'
? 'artist'
: 'track';
const name = this.label ? ` "${this.label}"` : '';
switch (this.status) {
case 'in-library':
return `${capitalize(kind)}${name} is in your library`;
case 'queued':
return `${capitalize(kind)}${name} is queued for download`;
default:
return `Add ${kind}${name} to library`;
}
}
private handleClick(e: Event) {
// Stop propagation so clicking the button doesn't bubble up
// to the parent card and trigger navigation. The click
// itself is a no-op for now — wire up download-client
// integration later.
e.stopPropagation();
}
private handleKeydown(e: KeyboardEvent) {
// Same reasoning: don't let Enter/Space bubble to a wrapping
// card and trigger navigation.
if (e.key === 'Enter' || e.key === ' ') {
e.stopPropagation();
}
}
override render() {
// Sync the host CSS variable with the configured size.
if (this.size && this.size !== 20) {
this.style.setProperty('--indicator-size', `${this.size}px`);
}
const title = this.tooltip();
return html`
<button
type="button"
title=${title}
aria-label=${title}
@click=${this.handleClick}
@keydown=${this.handleKeydown}
>
${this.iconName()
? html`<wa-icon name=${this.iconName()}></wa-icon>`
: nothing}
</button>
`;
}
}
function capitalize(s: string): string {
return s.length > 0 ? s[0].toUpperCase() + s.slice(1) : s;
}
declare global {
interface HTMLElementTagNameMap {
'library-status-indicator': LibraryStatusIndicator;
}
}
@@ -3,6 +3,11 @@ import { customElement, state } from 'lit/decorators.js';
import '@awesome.me/webawesome/dist/components/icon/icon.js';
import '@awesome.me/webawesome/dist/components/popup/popup.js';
import type WaPopup from '@awesome.me/webawesome/dist/components/popup/popup.js';
import {
artistLink,
trackLink,
exploreLinkStyles,
} from '@utils/explore-link';
import { PlayerController } from '@store/controllers/player-controller';
import { FavoritesController } from '@store/controllers/favorites-controller';
import { designTokens } from '../../styles/tokens.css';
@@ -61,7 +66,7 @@ export class NowPlaying extends LitElement {
private resizeObserver?: ResizeObserver;
static override styles = [designTokens, css`
static override styles = [designTokens, exploreLinkStyles, css`
:host {
display: block;
position: relative;
@@ -338,7 +343,7 @@ export class NowPlaying extends LitElement {
@mouseleave=${this.handleTitleMouseLeave}
@transitionend=${() => this.onScrollCycleEnd('title')}
>
<span class="scroll-content">${track.title}</span>
<span class="scroll-content">${trackLink(track.title, track.album, track.releaseGroupMbid, track.recordingMbid) || track.title}</span>
</span>
<span
class="track-artist ${artistScrolling ? 'will-scroll' : ''} ${this.artistScrolling ? 'scrolling' : ''}"
@@ -346,7 +351,7 @@ export class NowPlaying extends LitElement {
@mouseleave=${this.handleArtistMouseLeave}
@transitionend=${() => this.onScrollCycleEnd('artist')}
>
<span class="scroll-content">${track.artist || 'Unknown Artist'}</span>
<span class="scroll-content">${artistLink(track.artist, track.artistMbid) || 'Unknown Artist'}</span>
</span>
</div>
${track.filePath
@@ -52,6 +52,12 @@ import type { PhantomResolver } from '@components/phantom-resolver/phantom-resol
import '@components/duplicate-tracks-dialog/duplicate-tracks-dialog.js';
import type { DuplicateTracksDialog } from '@components/duplicate-tracks-dialog/duplicate-tracks-dialog.js';
import { formatMilliseconds } from '@utils/time';
import {
artistLink,
albumLink,
trackLink,
exploreLinkStyles,
} from '@utils/explore-link';
import { designTokens } from '../../styles/tokens.css';
@customElement('playlist-details')
@@ -441,12 +447,18 @@ export class PlaylistDetails
if (!track) return;
const coverArt =
this.resolvePlaylistCoverArt(track.Album);
const coverArt = track.CoverArtPath
? {
coverArtPath: track.CoverArtPath,
coverArtSmall: track.CoverArtSmall,
coverArtMedium: track.CoverArtMedium,
coverArtLarge: track.CoverArtLarge,
}
: undefined;
this.trackDetailsDialog?.show(
track,
coverArt ?? undefined,
coverArt,
);
}
@@ -471,19 +483,19 @@ export class PlaylistDetails
if (tracks.length === 0) return;
const albumNames = new Set(
tracks.map((t) => t.Album),
);
const first = tracks[0]!;
const albumNames = new Set(tracks.map((t) => t.Album));
let coverArt: CoverArtUrls | null = null;
let coverArtMixed = false;
if (albumNames.size === 1) {
const albumName = [...albumNames][0]!;
coverArt =
this.resolvePlaylistCoverArt(
albumName,
);
} else {
if (albumNames.size === 1 && first.CoverArtPath) {
coverArt = {
coverArtPath: first.CoverArtPath,
coverArtSmall: first.CoverArtSmall,
coverArtMedium: first.CoverArtMedium,
coverArtLarge: first.CoverArtLarge,
};
} else if (albumNames.size > 1) {
coverArtMixed = true;
}
@@ -494,31 +506,6 @@ export class PlaylistDetails
);
}
private resolvePlaylistCoverArt(
albumName: string,
): CoverArtUrls | null {
if (!albumName) return null;
const albums = libraryStore.getCachedAlbums();
if (!albums) return null;
const album = albums.find(
(a) => a.Name === albumName,
);
if (!album || !album.CoverArtPath) {
return null;
}
return {
coverArtPath: album.CoverArtPath,
coverArtSmall: album.CoverArtSmall,
coverArtMedium: album.CoverArtMedium,
coverArtLarge: album.CoverArtLarge,
};
}
/**
* Check whether all currently selected tracks are phantoms.
*/
@@ -799,6 +786,7 @@ export class PlaylistDetails
static override styles = [
designTokens,
contextMenuStyles,
exploreLinkStyles,
css`
:host {
display: flex;
@@ -977,7 +965,7 @@ export class PlaylistDetails
.track-header,
.track-item {
display: grid;
grid-template-columns: 40px 1fr 1fr 1fr 80px;
grid-template-columns: 40px 36px 1fr 1fr 1fr 80px;
align-items: center;
gap: 0;
}
@@ -993,6 +981,22 @@ export class PlaylistDetails
user-select: none;
}
.track-art {
width: 32px;
height: 32px;
border-radius: 4px;
overflow: hidden;
flex-shrink: 0;
background: var(--yj-bg-overlay, rgba(255, 255, 255, 0.06));
}
.track-art img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.header-cell,
.cell {
overflow: hidden;
@@ -1017,7 +1021,7 @@ export class PlaylistDetails
/* Phantom rows span full grid */
.track-item.phantom {
display: grid;
grid-template-columns: 40px 1fr 1fr 1fr 80px;
grid-template-columns: 40px 36px 1fr 1fr 1fr 80px;
}
.track-item {
@@ -1252,6 +1256,7 @@ export class PlaylistDetails
</div>
<div class="track-header">
<div class="header-cell col-number">#</div>
<div class="header-cell col-art"></div>
<div class="header-cell col-title">Title</div>
<div class="header-cell col-artist">Artist</div>
<div class="header-cell col-album">Album</div>
@@ -1376,9 +1381,14 @@ export class PlaylistDetails
</div>
</div>`
: html`<span class="cell col-number">${trackIndex + 1}</span>
<span class="cell col-title" title="${track.Title || track.FilePath}">${track.Title || track.FilePath}</span>
<span class="cell col-artist" title="${track.Artist}">${track.Artist}</span>
<span class="cell col-album" title="${track.Album}">${track.Album}</span>
<div class="track-art">
${track.CoverArtSmall || track.CoverArtMedium
? html`<img src="${track.CoverArtSmall || track.CoverArtMedium}" alt="" />`
: nothing}
</div>
<span class="cell col-title" title="${track.Title || track.FilePath}">${trackLink(track.Title, track.Album, track.ReleaseGroupMBID, track.RecordingMBID) || track.FilePath}</span>
<span class="cell col-artist" title="${track.Artist}">${artistLink(track.Artist, track.ArtistMBID)}</span>
<span class="cell col-album" title="${track.Album}">${albumLink(track.Album, track.ReleaseGroupMBID)}</span>
<span class="cell col-duration">${formatMilliseconds(track.Duration)}</span>`}
</div>
`;
@@ -44,7 +44,11 @@ import type { library } from '@go/models';
import '@components/track-details/track-details.js';
import type { TrackDetails } from '@components/track-details/track-details.js';
import type { CoverArtUrls } from '@components/track-details/track-details.js';
import {
artistLink,
trackLink,
exploreLinkStyles,
} from '@utils/explore-link';
const MIN_WIDTH = 200;
const MAX_WIDTH = 500;
const DEFAULT_WIDTH = 320;
@@ -207,7 +211,7 @@ export class QueuePanel
return this.playlistSubmenuPopup;
}
static override styles = [designTokens, contextMenuStyles, css`
static override styles = [designTokens, contextMenuStyles, exploreLinkStyles, css`
:host {
flex-shrink: 0;
width: 0;
@@ -353,6 +357,22 @@ export class QueuePanel
color: var(--yj-accent, #ffd43b);
}
.track-art {
width: 32px;
height: 32px;
border-radius: 4px;
overflow: hidden;
flex-shrink: 0;
background: var(--yj-bg-overlay, rgba(255, 255, 255, 0.06));
}
.track-art img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.track-details {
flex: 1;
min-width: 0;
@@ -865,12 +885,18 @@ export class QueuePanel
if (!track) return;
const coverArt =
this.resolveQueueCoverArt(track.Album);
const coverArt = track.CoverArtPath
? {
coverArtPath: track.CoverArtPath,
coverArtSmall: track.CoverArtSmall,
coverArtMedium: track.CoverArtMedium,
coverArtLarge: track.CoverArtLarge,
}
: undefined;
this.trackDetailsDialog?.show(
track,
coverArt ?? undefined,
coverArt,
);
}
@@ -899,17 +925,19 @@ export class QueuePanel
if (tracks.length === 0) return;
const albumNames = new Set(
tracks.map((t) => t.Album),
);
const first = tracks[0]!;
const albumNames = new Set(tracks.map((t) => t.Album));
let coverArt: CoverArtUrls | null = null;
let coverArtMixed = false;
if (albumNames.size === 1) {
const albumName = [...albumNames][0]!;
coverArt =
this.resolveQueueCoverArt(albumName);
} else {
if (albumNames.size === 1 && first.CoverArtPath) {
coverArt = {
coverArtPath: first.CoverArtPath,
coverArtSmall: first.CoverArtSmall,
coverArtMedium: first.CoverArtMedium,
coverArtLarge: first.CoverArtLarge,
};
} else if (albumNames.size > 1) {
coverArtMixed = true;
}
@@ -920,32 +948,6 @@ export class QueuePanel
);
}
private resolveQueueCoverArt(
albumName: string,
): CoverArtUrls | null {
if (!albumName) return null;
const albums =
libraryStore.getCachedAlbums();
if (!albums) return null;
const album = albums.find(
(a) => a.Name === albumName,
);
if (!album || !album.CoverArtPath) {
return null;
}
return {
coverArtPath: album.CoverArtPath,
coverArtSmall: album.CoverArtSmall,
coverArtMedium: album.CoverArtMedium,
coverArtLarge: album.CoverArtLarge,
};
}
private onContextPlaylistActionComplete = () => {
this.selection.clear();
this.ctxMenu.close();
@@ -1396,6 +1398,8 @@ export class QueuePanel
dropIdx === trackCount &&
index === trackCount - 1;
const artUrl = track.coverArtPath || '';
// No inline closures — all events delegated via data-index
// on the virtualizer element (see firstUpdated).
return html`
@@ -1413,12 +1417,13 @@ export class QueuePanel
<span class="track-position">
${index + 1}
</span>
${artUrl ? html`<div class="track-art"><img src="${artUrl}" alt="" loading="lazy" /></div>` : nothing}
<div class="track-details">
<span class="track-title">
${this.getDisplayTitle(track)}
${trackLink(this.getDisplayTitle(track), track.album, track.releaseGroupMbid, track.recordingMbid)}
</span>
<span class="track-artist">
${track.artist || 'Unknown Artist'}
${artistLink(track.artist, track.artistMbid) || 'Unknown Artist'}
</span>
</div>
<button
@@ -0,0 +1,307 @@
import { LitElement, html, css, nothing } from 'lit';
import { customElement, property } from 'lit/decorators.js';
import { designTokens } from '../../styles/tokens.css';
import type { explore } from '@go/models';
import {
GetArtistImageURL,
GetThumbnail,
RecordSearchClick,
} from '@go/explore/Service';
import '../library-status-indicator/library-status-indicator.js';
import type { LibraryStatus } from '../library-status-indicator/library-status-indicator.js';
/** Format milliseconds as m:ss. */
function formatDuration(ms: number | undefined): 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')}`;
}
/** Color for entity type badges. */
function badgeColor(type: string): string {
switch (type) {
case 'artist': return '#7c3aed';
case 'release_group': return '#2563eb';
case 'recording': return '#059669';
default: return '#6b7280';
}
}
function badgeLabel(type: string): string {
switch (type) {
case 'artist': return 'Artist';
case 'release_group': return 'Album';
case 'recording': return 'Track';
default: return type;
}
}
@customElement('top-results-row')
export class TopResultsRow extends LitElement {
@property({ attribute: false })
results: explore.TopResult[] = [];
@property({ type: String })
query = '';
// Per-card state: cover images.
private images = new Map<string, string>();
static styles = [
designTokens,
css`
:host {
display: block;
margin-bottom: 16px;
}
.row {
display: flex;
gap: 12px;
overflow-x: auto;
padding-bottom: 4px;
}
.card {
flex: 0 0 auto;
width: 200px;
background: var(--yj-bg-elevated, rgba(255, 255, 255, 0.06));
border-radius: 10px;
padding: 14px;
cursor: pointer;
transition: background 0.15s ease, transform 0.1s ease;
display: flex;
flex-direction: column;
gap: 8px;
position: relative;
}
.card > library-status-indicator {
position: absolute;
right: 10px;
bottom: 10px;
}
.card:hover {
background: var(--yj-bg-overlay, rgba(255, 255, 255, 0.1));
transform: translateY(-1px);
}
.card:active {
transform: scale(0.98);
}
.card-header {
display: flex;
align-items: center;
gap: 10px;
}
.card-image {
width: 48px;
height: 48px;
border-radius: 6px;
object-fit: cover;
flex-shrink: 0;
background: var(--yj-bg-subtle, rgba(255, 255, 255, 0.04));
}
.card-image.artist {
border-radius: 50%;
}
.card-image-placeholder {
width: 48px;
height: 48px;
border-radius: 6px;
flex-shrink: 0;
background: var(--yj-bg-subtle, rgba(255, 255, 255, 0.08));
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
color: var(--yj-text-secondary, #999);
}
.card-image-placeholder.artist {
border-radius: 50%;
}
.card-info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.card-name {
font-weight: 600;
font-size: var(--yj-text-md);
color: var(--yj-text-primary, #fff);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.card-subtitle {
font-size: var(--yj-text-xs);
color: var(--yj-text-secondary, #999);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.badge {
display: inline-block;
font-size: 10px;
font-weight: 600;
letter-spacing: 0.5px;
text-transform: uppercase;
padding: 2px 6px;
border-radius: 4px;
color: #fff;
width: fit-content;
}
.section-label {
font-size: var(--yj-text-xs);
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--yj-text-secondary, #999);
margin-bottom: 8px;
}
`,
];
updated(changed: Map<string, unknown>) {
if (changed.has('results')) {
this.loadCardData();
}
}
private async loadCardData() {
for (const r of this.results) {
if (!r.mbid || this.images.has(r.mbid)) continue;
if (r.entityType === 'artist') {
// Load artist image.
GetArtistImageURL(r.mbid)
.then((url) => {
if (url) {
this.images.set(r.mbid, url);
this.requestUpdate();
}
})
.catch(() => {});
// Load preview tracks removed — cards are cleaner without them.
} else if (r.entityType === 'release_group') {
// Load album art.
GetThumbnail(r.mbid, r.name, r.artistCredit || '')
.then((url) => {
if (url) {
this.images.set(r.mbid, url);
this.requestUpdate();
}
})
.catch(() => {});
}
}
}
private handleClick(r: explore.TopResult) {
// Record the click for learning.
RecordSearchClick(this.query, r.mbid, r.entityType).catch(() => {});
// Navigate to the appropriate explore page.
this.dispatchEvent(
new CustomEvent('top-result-click', {
detail: r,
bubbles: true,
composed: true,
}),
);
}
render() {
if (!this.results?.length) return nothing;
return html`
<div class="section-label">Top Results</div>
<div class="row">
${this.results.map((r) => this.renderCard(r))}
</div>
`;
}
private renderCard(r: explore.TopResult) {
const imgUrl = this.images.get(r.mbid);
const isArtist = r.entityType === 'artist';
const subtitle = isArtist
? [r.artistType, r.country].filter(Boolean).join(' · ') || ''
: r.entityType === 'release_group'
? [r.artistCredit, r.year].filter(Boolean).join(' · ')
: [r.artistCredit, formatDuration(r.length)]
.filter(Boolean)
.join(' · ');
const status: LibraryStatus = r.inLibrary ? 'in-library' : 'not-in-library';
const entityType: 'artist' | 'album' | 'track' =
r.entityType === 'artist'
? 'artist'
: r.entityType === 'release_group'
? 'album'
: 'track';
return html`
<div class="card" @click=${() => this.handleClick(r)}>
<span
class="badge"
style="background: ${badgeColor(r.entityType)}"
>${badgeLabel(r.entityType)}</span
>
<div class="card-header">
${imgUrl
? html`<img
class="card-image ${isArtist ? 'artist' : ''}"
src="${imgUrl}"
alt=""
loading="lazy"
/>`
: html`<div
class="card-image-placeholder ${isArtist ? 'artist' : ''}"
>
${r.name.charAt(0)}
</div>`}
<div class="card-info">
<span class="card-name">${r.name}</span>
${subtitle
? html`<span class="card-subtitle"
>${subtitle}</span
>`
: nothing}
</div>
</div>
${isArtist
? nothing
: html`<library-status-indicator
status=${status}
entity-type=${entityType}
label=${r.name}
size="22"
></library-status-indicator>`}
</div>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'top-results-row': TopResultsRow;
}
}
@@ -1658,18 +1658,13 @@ export class TrackDetails extends LitElement {
if (updated) {
this.track = updated;
// Re-resolve cover art from the refreshed
// album data (URLs change on new content hash).
const album = albums.find(
(a) => a.Name === updated.Album,
);
if (album?.CoverArtPath) {
// Re-resolve cover art from the refreshed track data.
if (updated.CoverArtPath) {
this.coverArt = {
coverArtPath: album.CoverArtPath,
coverArtSmall: album.CoverArtSmall,
coverArtMedium: album.CoverArtMedium,
coverArtLarge: album.CoverArtLarge,
coverArtPath: updated.CoverArtPath,
coverArtSmall: updated.CoverArtSmall,
coverArtMedium: updated.CoverArtMedium,
coverArtLarge: updated.CoverArtLarge,
};
} else {
this.coverArt = null;
@@ -1795,31 +1790,23 @@ export class TrackDetails extends LitElement {
this.batchTracks = refreshed;
// Re-resolve cover art state.
const albumNames = new Set(
refreshed.map((t) => t.Album),
);
const first = refreshed[0];
const albumNames = new Set(refreshed.map((t) => t.Album));
if (albumNames.size === 1) {
const albumName = [...albumNames][0]!;
const album = albums.find(
(a) => a.Name === albumName,
);
if (album?.CoverArtPath) {
this.coverArt = {
coverArtPath: album.CoverArtPath,
coverArtSmall: album.CoverArtSmall,
coverArtMedium: album.CoverArtMedium,
coverArtLarge: album.CoverArtLarge,
};
this.batchCoverArtMixed = false;
} else {
this.coverArt = null;
this.batchCoverArtMixed = false;
}
if (albumNames.size === 1 && first?.CoverArtPath) {
this.coverArt = {
coverArtPath: first.CoverArtPath,
coverArtSmall: first.CoverArtSmall,
coverArtMedium: first.CoverArtMedium,
coverArtLarge: first.CoverArtLarge,
};
this.batchCoverArtMixed = false;
} else if (albumNames.size > 1) {
this.coverArt = null;
this.batchCoverArtMixed = true;
} else {
this.coverArt = null;
this.batchCoverArtMixed = albumNames.size > 1;
this.batchCoverArtMixed = false;
}
};
@@ -7,6 +7,7 @@ import {
formatFileSize,
} from '@utils/format';
import { formatMilliseconds } from '@utils/time';
import { html, nothing } from 'lit';
/** Compares two strings using locale-aware ordering. */
const compareStr = (
@@ -35,6 +36,8 @@ export interface ColumnDef {
defaultWidth: string;
/** Text alignment. Defaults to left. */
align?: 'left' | 'right';
/** Optional custom render function returning an HTML template. */
renderCell?: (track: library.Track) => unknown;
/**
* Comparison function for sorting two tracks by this column.
* Returns negative if a < b, positive if a > b, zero if equal.
@@ -48,6 +51,16 @@ export interface ColumnDef {
/** Registry of every available column keyed by ID. */
export const COLUMN_DEFS: Record<string, ColumnDef> = {
albumArt: {
id: 'albumArt',
label: 'Art',
accessor: () => '',
defaultWidth: '36px',
renderCell: (track: library.Track) => {
if (!track.CoverArtPath) return nothing;
return html`<img src="${track.CoverArtPath}" alt="" style="width:24px;height:24px;border-radius:3px;object-fit:cover;display:block;" />`;
},
},
trackName: {
id: 'trackName',
label: 'Track Name',
@@ -31,6 +31,12 @@ import {
rankTracks,
highlightText,
} from './search-ranking';
import {
artistLink,
albumLink,
trackLink,
exploreLinkStyles,
} from '@utils/explore-link';
import {
setDragPayload,
emitDragActive,
@@ -347,7 +353,7 @@ export class TrackList extends LitElement implements SelectionHost, ContextMenuH
const cols = this.activeColumns;
const favCol = '24px';
if (this.columnWidths.length === 0) {
if (this.columnWidths.length === 0 || this.columnWidths.length !== cols.length) {
return (
favCol +
' ' +
@@ -388,8 +394,9 @@ export class TrackList extends LitElement implements SelectionHost, ContextMenuH
private initColumnWidths() {
const saved = this.loadColumnWidths();
const cols = this.activeColumns;
if (saved) {
if (saved && saved.length === cols.length) {
this.columnWidths = saved;
return;
@@ -742,7 +749,7 @@ export class TrackList extends LitElement implements SelectionHost, ContextMenuH
this.requestUpdate();
};
static override styles = [designTokens, contextMenuStyles, css`
static override styles = [designTokens, contextMenuStyles, exploreLinkStyles, css`
:host {
display: flex;
flex-direction: column;
@@ -1482,12 +1489,18 @@ export class TrackList extends LitElement implements SelectionHost, ContextMenuH
if (!track) return;
const coverArt =
this.resolveCoverArt(track.Album);
const coverArt = track.CoverArtPath
? {
coverArtPath: track.CoverArtPath,
coverArtSmall: track.CoverArtSmall,
coverArtMedium: track.CoverArtMedium,
coverArtLarge: track.CoverArtLarge,
}
: undefined;
this.trackDetailsDialog?.show(
track,
coverArt ?? undefined,
coverArt,
);
}
@@ -1506,17 +1519,22 @@ export class TrackList extends LitElement implements SelectionHost, ContextMenuH
if (tracks.length === 0) return;
const albumNames = new Set(
tracks.map((t) => t.Album),
);
// Use cover art from the first track. If all tracks share
// the same album, they share the same art.
const first = tracks[0]!;
let coverArt: CoverArtUrls | null = null;
let coverArtMixed = false;
if (albumNames.size === 1) {
const albumName = [...albumNames][0]!;
coverArt =
this.resolveCoverArt(albumName);
} else {
const albumNames = new Set(tracks.map((t) => t.Album));
if (albumNames.size === 1 && first.CoverArtPath) {
coverArt = {
coverArtPath: first.CoverArtPath,
coverArtSmall: first.CoverArtSmall,
coverArtMedium: first.CoverArtMedium,
coverArtLarge: first.CoverArtLarge,
};
} else if (albumNames.size > 1) {
coverArtMixed = true;
}
@@ -1527,29 +1545,6 @@ export class TrackList extends LitElement implements SelectionHost, ContextMenuH
);
}
private resolveCoverArt(
albumName: string,
): CoverArtUrls | null {
if (!albumName) return null;
const albums = this.libraryCtrl.cachedAlbums;
if (!albums) return null;
const album = albums.find(
(a) => a.Name === albumName,
);
if (!album || !album.CoverArtPath) return null;
return {
coverArtPath: album.CoverArtPath,
coverArtSmall: album.CoverArtSmall,
coverArtMedium: album.CoverArtMedium,
coverArtLarge: album.CoverArtLarge,
};
}
// =================================================================
// Sort controls
// =================================================================
@@ -1757,12 +1752,25 @@ export class TrackList extends LitElement implements SelectionHost, ContextMenuH
</svg>
</div>
${cols.map((col) => {
const customCell = col.renderCell?.(track);
if (customCell !== undefined && customCell !== nothing) {
return html`<div class="cell">${customCell}</div>`;
}
const val = col.accessor(track);
const centered = val === '\u2014';
const display = term
let display: unknown = term
? highlightText(val, term)
: val;
// Wrap artist/album/track values in explore links.
if (col.id === 'trackName') {
display = trackLink(track.TrackName, track.Album, track.ReleaseGroupMBID, track.RecordingMBID, display as any);
} else if (col.id === 'artistName') {
display = artistLink(track.ArtistName, track.ArtistMBID, display as any);
} else if (col.id === 'album') {
display = albumLink(track.Album, track.ReleaseGroupMBID, display as any);
}
return html`
<div class=${classMap({
cell: true,
+3
View File
@@ -51,6 +51,9 @@ export const Events = {
// Tag writing events
TrackMetadataChanged: "TrackMetadataChanged",
BatchWriteProgress: "BatchWriteProgress",
// Explore / search index events
IndexStatusChanged: "IndexStatusChanged",
} as const;
export type EventName = (typeof Events)[keyof typeof Events];
+3
View File
@@ -18,6 +18,9 @@ export interface TrackInfo {
coverArtMedium: string; // URL path to medium variant (200px max) or empty string
coverArtLarge: string; // URL path to large variant (400px max) or empty string
trackChangeId: number; // monotonic counter to detect track changes even when the same file plays consecutively
artistMbid: string; // MusicBrainz artist ID or empty string
releaseGroupMbid: string; // MusicBrainz release group ID or empty string
recordingMbid: string; // MusicBrainz recording ID or empty string
}
export interface PlayerState {
+5
View File
@@ -10,6 +10,11 @@ export interface QueueTrack {
position: number;
title: string;
artist: string;
album: string;
coverArtPath: string;
artistMbid: string;
releaseGroupMbid: string;
recordingMbid: string;
}
export type RepeatMode = 'off' | 'all' | 'one';
+174
View File
@@ -0,0 +1,174 @@
/**
* Utility for rendering artist/album names as clickable links
* that navigate to their MusicBrainz explore detail pages.
*
* Links are rendered only when an MBID is provided. If the MBID
* is empty (entity not tagged), the name renders as plain text.
*/
import { html, css } from 'lit';
import type { TemplateResult } from 'lit';
/** Shared CSS for explore link styling. Import into component styles. */
export const exploreLinkStyles = css`
.explore-link {
color: inherit;
text-decoration: none;
cursor: pointer;
}
.explore-link:hover {
text-decoration: underline;
}
`;
/**
* Dispatch a navigate event to the explore-artist-details page.
* The event bubbles through shadow DOM boundaries.
*/
function navigateToArtist(artistName: string, mbid: string, e: Event): void {
e.stopPropagation();
e.preventDefault();
const target = e.currentTarget as HTMLElement;
target.dispatchEvent(
new CustomEvent('navigate', {
bubbles: true,
composed: true,
detail: {
view: 'explore-artist-details',
artistMBID: mbid,
artistName,
},
}),
);
}
/**
* Dispatch a navigate event to the explore-album-details page.
* The event bubbles through shadow DOM boundaries.
*/
function navigateToAlbum(albumName: string, mbid: string, e: Event): void {
e.stopPropagation();
e.preventDefault();
const target = e.currentTarget as HTMLElement;
target.dispatchEvent(
new CustomEvent('navigate', {
bubbles: true,
composed: true,
detail: {
view: 'explore-album-details',
releaseGroupMBID: mbid,
albumName,
},
}),
);
}
/**
* Render an artist name as a clickable link if an MBID is provided,
* or as plain text if not.
*
* @param artistName - The artist name to display.
* @param mbid - The MusicBrainz artist ID. Empty string = no link.
* @param content - Optional custom content to render inside the link
* (e.g. highlighted search result). Defaults to artistName.
*/
export function artistLink(
artistName: string,
mbid: string,
content?: TemplateResult | string,
): TemplateResult | string {
if (!artistName) return artistName;
if (!mbid) return content ?? artistName;
return html`<a
class="explore-link"
@click=${(e: Event) => navigateToArtist(artistName, mbid, e)}
title="View artist on Explore"
>${content ?? artistName}</a>`;
}
/**
* Dispatch a navigate event to the explore-album-details page
* with a highlight on a specific track.
*/
function navigateToTrack(
albumName: string,
releaseGroupMBID: string,
recordingMBID: string,
e: Event,
): void {
e.stopPropagation();
e.preventDefault();
const target = e.currentTarget as HTMLElement;
target.dispatchEvent(
new CustomEvent('navigate', {
bubbles: true,
composed: true,
detail: {
view: 'explore-album-details',
releaseGroupMBID,
albumName,
highlightTrackMBID: recordingMBID,
},
}),
);
}
/**
* Render an album name as a clickable link if an MBID is provided,
* or as plain text if not.
*
* @param albumName - The album name to display.
* @param mbid - The MusicBrainz release group ID. Empty string = no link.
* @param content - Optional custom content to render inside the link
* (e.g. highlighted search result). Defaults to albumName.
*/
export function albumLink(
albumName: string,
mbid: string,
content?: TemplateResult | string,
): TemplateResult | string {
if (!albumName) return albumName;
if (!mbid) return content ?? albumName;
return html`<a
class="explore-link"
@click=${(e: Event) => navigateToAlbum(albumName, mbid, e)}
title="View album on Explore"
>${content ?? albumName}</a>`;
}
/**
* Render a track name as a clickable link that opens the album's
* explore page with the track highlighted. Requires both a
* release group MBID (album) and a recording MBID (track).
*
* @param trackName - The track name to display.
* @param albumName - The album name (for the page title).
* @param releaseGroupMBID - The album's MusicBrainz release group ID.
* @param recordingMBID - The track's MusicBrainz recording ID.
* @param content - Optional custom content (e.g. highlighted text).
*/
export function trackLink(
trackName: string,
albumName: string,
releaseGroupMBID: string,
recordingMBID: string,
content?: TemplateResult | string,
): TemplateResult | string {
if (!trackName) return trackName;
if (!releaseGroupMBID || !recordingMBID) return content ?? trackName;
return html`<a
class="explore-link"
@click=${(e: Event) => navigateToTrack(albumName, releaseGroupMBID, recordingMBID, e)}
title="View track on album page"
>${content ?? trackName}</a>`;
}
+22
View File
@@ -13,6 +13,10 @@ export function CoverArtGroupURL(arg1:string):Promise<string>;
export function CoverArtURL(arg1:string):Promise<string>;
export function GetArtistImageCached(arg1:string):Promise<string>;
export function GetArtistImageCachedPath(arg1:string):Promise<string>;
export function GetArtistImageURL(arg1:string):Promise<string>;
export function GetArtistImages(arg1:Array<string>):Promise<Record<string, string>>;
@@ -21,20 +25,36 @@ export function GetArtistMBID(arg1:string):Promise<string>;
export function GetArtistPlayCount(arg1:string):Promise<number>;
export function GetIndexStatus():Promise<explore.IndexStatus>;
export function GetLibrarySimilarArtists(arg1:string):Promise<Array<explore.LBSimilarArtist>>;
export function GetPopularityBatch(arg1:Array<string>):Promise<Record<string, explore.PersonalizationResult>>;
export function GetThumbnail(arg1:string,arg2:string,arg3:string):Promise<string>;
export function GetThumbnails(arg1:Array<explore.ThumbnailRequest>):Promise<Record<string, string>>;
export function GetTrackThumbnail(arg1:string,arg2:string,arg3:string,arg4:string):Promise<string>;
export function GetTrackThumbnails(arg1:Array<explore.TrackThumbnailRequest>):Promise<Record<string, string>>;
export function IndexNewArtists():Promise<void>;
export function InvalidateIndexDiscographies():Promise<void>;
export function IsIndexReady():Promise<boolean>;
export function LookupArtist(arg1:string):Promise<explore.MBArtist>;
export function LookupReleaseGroup(arg1:string):Promise<explore.MBReleaseGroup>;
export function PopulateLocalCrossReferences():Promise<void>;
export function RecordSearchClick(arg1:string,arg2:string,arg3:string):Promise<void>;
export function ResolveReleaseGroupMBIDs(arg1:Array<string>):Promise<Record<string, string>>;
export function Search(arg1:string):Promise<explore.MBSearchResult>;
export function SearchArtists(arg1:string):Promise<Array<explore.MBArtist>>;
@@ -56,3 +76,5 @@ export function StopIndexBuild():Promise<void>;
export function TopRecordingsForArtist(arg1:string):Promise<Array<explore.LBTopRecording>>;
export function TopReleaseGroupsForArtist(arg1:string):Promise<Array<explore.LBTopReleaseGroup>>;
export function WaitForIndexIdle():Promise<void>;
+44
View File
@@ -22,6 +22,14 @@ export function CoverArtURL(arg1) {
return window['go']['explore']['Service']['CoverArtURL'](arg1);
}
export function GetArtistImageCached(arg1) {
return window['go']['explore']['Service']['GetArtistImageCached'](arg1);
}
export function GetArtistImageCachedPath(arg1) {
return window['go']['explore']['Service']['GetArtistImageCachedPath'](arg1);
}
export function GetArtistImageURL(arg1) {
return window['go']['explore']['Service']['GetArtistImageURL'](arg1);
}
@@ -38,10 +46,18 @@ export function GetArtistPlayCount(arg1) {
return window['go']['explore']['Service']['GetArtistPlayCount'](arg1);
}
export function GetIndexStatus() {
return window['go']['explore']['Service']['GetIndexStatus']();
}
export function GetLibrarySimilarArtists(arg1) {
return window['go']['explore']['Service']['GetLibrarySimilarArtists'](arg1);
}
export function GetPopularityBatch(arg1) {
return window['go']['explore']['Service']['GetPopularityBatch'](arg1);
}
export function GetThumbnail(arg1, arg2, arg3) {
return window['go']['explore']['Service']['GetThumbnail'](arg1, arg2, arg3);
}
@@ -50,6 +66,14 @@ export function GetThumbnails(arg1) {
return window['go']['explore']['Service']['GetThumbnails'](arg1);
}
export function GetTrackThumbnail(arg1, arg2, arg3, arg4) {
return window['go']['explore']['Service']['GetTrackThumbnail'](arg1, arg2, arg3, arg4);
}
export function GetTrackThumbnails(arg1) {
return window['go']['explore']['Service']['GetTrackThumbnails'](arg1);
}
export function IndexNewArtists() {
return window['go']['explore']['Service']['IndexNewArtists']();
}
@@ -58,6 +82,10 @@ export function InvalidateIndexDiscographies() {
return window['go']['explore']['Service']['InvalidateIndexDiscographies']();
}
export function IsIndexReady() {
return window['go']['explore']['Service']['IsIndexReady']();
}
export function LookupArtist(arg1) {
return window['go']['explore']['Service']['LookupArtist'](arg1);
}
@@ -66,6 +94,18 @@ export function LookupReleaseGroup(arg1) {
return window['go']['explore']['Service']['LookupReleaseGroup'](arg1);
}
export function PopulateLocalCrossReferences() {
return window['go']['explore']['Service']['PopulateLocalCrossReferences']();
}
export function RecordSearchClick(arg1, arg2, arg3) {
return window['go']['explore']['Service']['RecordSearchClick'](arg1, arg2, arg3);
}
export function ResolveReleaseGroupMBIDs(arg1) {
return window['go']['explore']['Service']['ResolveReleaseGroupMBIDs'](arg1);
}
export function Search(arg1) {
return window['go']['explore']['Service']['Search'](arg1);
}
@@ -109,3 +149,7 @@ export function TopRecordingsForArtist(arg1) {
export function TopReleaseGroupsForArtist(arg1) {
return window['go']['explore']['Service']['TopReleaseGroupsForArtist'](arg1);
}
export function WaitForIndexIdle() {
return window['go']['explore']['Service']['WaitForIndexIdle']();
}
+194
View File
@@ -1,5 +1,69 @@
export namespace explore {
export class TierStatus {
name: string;
state: string;
total: number;
completed: number;
error?: string;
static createFrom(source: any = {}) {
return new TierStatus(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.name = source["name"];
this.state = source["state"];
this.total = source["total"];
this.completed = source["completed"];
this.error = source["error"];
}
}
export class IndexStatus {
building: boolean;
ready: boolean;
lastBuilt?: string;
tiers: TierStatus[];
artists: number;
recordings: number;
releaseGroups: number;
totalRows: number;
static createFrom(source: any = {}) {
return new IndexStatus(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.building = source["building"];
this.ready = source["ready"];
this.lastBuilt = source["lastBuilt"];
this.tiers = this.convertValues(source["tiers"], TierStatus);
this.artists = source["artists"];
this.recordings = source["recordings"];
this.releaseGroups = source["releaseGroups"];
this.totalRows = source["totalRows"];
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
if (!a) {
return a;
}
if (a.slice && a.map) {
return (a as any[]).map(elem => this.convertValues(elem, classs));
} else if ("object" === typeof a) {
if (asMap) {
for (const key of Object.keys(a)) {
a[key] = new classs(a[key]);
}
return a;
}
return new classs(a);
}
return a;
}
}
export class LBSimilarArtist {
artistMbid: string;
name: string;
@@ -21,6 +85,11 @@ export namespace explore {
artistName: string;
trackName: string;
totalListenCount: number;
caaReleaseMbid: string;
releaseName: string;
length: number;
inLibrary: boolean;
localId?: number;
static createFrom(source: any = {}) {
return new LBTopRecording(source);
@@ -32,6 +101,11 @@ export namespace explore {
this.artistName = source["artistName"];
this.trackName = source["trackName"];
this.totalListenCount = source["totalListenCount"];
this.caaReleaseMbid = source["caaReleaseMbid"];
this.releaseName = source["releaseName"];
this.length = source["length"];
this.inLibrary = source["inLibrary"];
this.localId = source["localId"];
}
}
export class LBTopReleaseGroup {
@@ -41,6 +115,9 @@ export namespace explore {
type: string;
date: string;
totalListenCount: number;
caaReleaseMbid: string;
inLibrary: boolean;
localId?: number;
static createFrom(source: any = {}) {
return new LBTopReleaseGroup(source);
@@ -54,6 +131,9 @@ export namespace explore {
this.type = source["type"];
this.date = source["date"];
this.totalListenCount = source["totalListenCount"];
this.caaReleaseMbid = source["caaReleaseMbid"];
this.inLibrary = source["inLibrary"];
this.localId = source["localId"];
}
}
export class MBArtist {
@@ -65,6 +145,10 @@ export namespace explore {
country: string;
disambiguation: string;
score: number;
popularity: number;
listenerCount: number;
inLibrary: boolean;
localId?: number;
static createFrom(source: any = {}) {
return new MBArtist(source);
@@ -80,6 +164,10 @@ export namespace explore {
this.country = source["country"];
this.disambiguation = source["disambiguation"];
this.score = source["score"];
this.popularity = source["popularity"];
this.listenerCount = source["listenerCount"];
this.inLibrary = source["inLibrary"];
this.localId = source["localId"];
}
}
export class MBRecording {
@@ -88,6 +176,10 @@ export namespace explore {
length: number;
artistCredit: string;
score: number;
popularity: number;
listenerCount: number;
inLibrary: boolean;
localId?: number;
static createFrom(source: any = {}) {
return new MBRecording(source);
@@ -100,6 +192,10 @@ export namespace explore {
this.length = source["length"];
this.artistCredit = source["artistCredit"];
this.score = source["score"];
this.popularity = source["popularity"];
this.listenerCount = source["listenerCount"];
this.inLibrary = source["inLibrary"];
this.localId = source["localId"];
}
}
export class MBTrack {
@@ -108,6 +204,8 @@ export namespace explore {
title: string;
length: number;
mbid: string;
inLibrary: boolean;
localId?: number;
static createFrom(source: any = {}) {
return new MBTrack(source);
@@ -120,6 +218,8 @@ export namespace explore {
this.title = source["title"];
this.length = source["length"];
this.mbid = source["mbid"];
this.inLibrary = source["inLibrary"];
this.localId = source["localId"];
}
}
export class MBRelease {
@@ -169,6 +269,10 @@ export namespace explore {
secondaryTypes?: string[];
firstReleaseDate: string;
artistCredit: string;
popularity: number;
listenerCount: number;
inLibrary: boolean;
localId?: number;
static createFrom(source: any = {}) {
return new MBReleaseGroup(source);
@@ -182,12 +286,49 @@ export namespace explore {
this.secondaryTypes = source["secondaryTypes"];
this.firstReleaseDate = source["firstReleaseDate"];
this.artistCredit = source["artistCredit"];
this.popularity = source["popularity"];
this.listenerCount = source["listenerCount"];
this.inLibrary = source["inLibrary"];
this.localId = source["localId"];
}
}
export class TopResult {
entityType: string;
mbid: string;
name: string;
artistCredit?: string;
intentScore: number;
artistType?: string;
country?: string;
primaryType?: string;
year?: string;
length?: number;
inLibrary: boolean;
static createFrom(source: any = {}) {
return new TopResult(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.entityType = source["entityType"];
this.mbid = source["mbid"];
this.name = source["name"];
this.artistCredit = source["artistCredit"];
this.intentScore = source["intentScore"];
this.artistType = source["artistType"];
this.country = source["country"];
this.primaryType = source["primaryType"];
this.year = source["year"];
this.length = source["length"];
this.inLibrary = source["inLibrary"];
}
}
export class MBSearchResult {
artists?: MBArtist[];
releaseGroups?: MBReleaseGroup[];
recordings?: MBRecording[];
topResults?: TopResult[];
static createFrom(source: any = {}) {
return new MBSearchResult(source);
@@ -198,6 +339,7 @@ export namespace explore {
this.artists = this.convertValues(source["artists"], MBArtist);
this.releaseGroups = this.convertValues(source["releaseGroups"], MBReleaseGroup);
this.recordings = this.convertValues(source["recordings"], MBRecording);
this.topResults = this.convertValues(source["topResults"], TopResult);
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
@@ -235,6 +377,28 @@ export namespace explore {
this.artistName = source["artistName"];
}
}
export class TrackThumbnailRequest {
key: string;
releaseMbid: string;
releaseGroupMbid: string;
albumName: string;
artistName: string;
static createFrom(source: any = {}) {
return new TrackThumbnailRequest(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.key = source["key"];
this.releaseMbid = source["releaseMbid"];
this.releaseGroupMbid = source["releaseGroupMbid"];
this.albumName = source["albumName"];
this.artistName = source["artistName"];
}
}
}
@@ -521,6 +685,10 @@ export namespace library {
RecordingMBID: string;
ArtistMBID: string;
ReleaseGroupMBID: string;
CoverArtPath: string;
CoverArtSmall: string;
CoverArtMedium: string;
CoverArtLarge: string;
static createFrom(source: any = {}) {
return new Track(source);
@@ -549,6 +717,10 @@ export namespace library {
this.RecordingMBID = source["RecordingMBID"];
this.ArtistMBID = source["ArtistMBID"];
this.ReleaseGroupMBID = source["ReleaseGroupMBID"];
this.CoverArtPath = source["CoverArtPath"];
this.CoverArtSmall = source["CoverArtSmall"];
this.CoverArtMedium = source["CoverArtMedium"];
this.CoverArtLarge = source["CoverArtLarge"];
}
}
export class TrackMBIDs {
@@ -586,6 +758,9 @@ export namespace player {
trackLength: number;
seekPosition: number;
trackChangeId: number;
artistMbid: string;
releaseGroupMbid: string;
recordingMbid: string;
static createFrom(source: any = {}) {
return new TrackInfo(source);
@@ -606,6 +781,9 @@ export namespace player {
this.trackLength = source["trackLength"];
this.seekPosition = source["seekPosition"];
this.trackChangeId = source["trackChangeId"];
this.artistMbid = source["artistMbid"];
this.releaseGroupMbid = source["releaseGroupMbid"];
this.recordingMbid = source["recordingMbid"];
}
}
@@ -787,6 +965,9 @@ export namespace playlist {
CoverArtLarge: string;
Duration: string;
Phantom: boolean;
ArtistMBID: string;
ReleaseGroupMBID: string;
RecordingMBID: string;
static createFrom(source: any = {}) {
return new Track(source);
@@ -806,6 +987,9 @@ export namespace playlist {
this.CoverArtLarge = source["CoverArtLarge"];
this.Duration = source["Duration"];
this.Phantom = source["Phantom"];
this.ArtistMBID = source["ArtistMBID"];
this.ReleaseGroupMBID = source["ReleaseGroupMBID"];
this.RecordingMBID = source["RecordingMBID"];
}
}
export class WithTracks {
@@ -852,6 +1036,11 @@ export namespace queue {
position: number;
title: string;
artist: string;
album: string;
coverArtPath: string;
artistMbid: string;
releaseGroupMbid: string;
recordingMbid: string;
static createFrom(source: any = {}) {
return new Track(source);
@@ -865,6 +1054,11 @@ export namespace queue {
this.position = source["position"];
this.title = source["title"];
this.artist = source["artist"];
this.album = source["album"];
this.coverArtPath = source["coverArtPath"];
this.artistMbid = source["artistMbid"];
this.releaseGroupMbid = source["releaseGroupMbid"];
this.recordingMbid = source["recordingMbid"];
}
}
export class State {
+2
View File
@@ -52,6 +52,8 @@ export function RemoveTracksFromPlaylist(arg1:number,arg2:Array<number>):Promise
export function RenamePlaylist(arg1:number,arg2:string):Promise<void>;
export function RepopulateFromM3U():Promise<void>;
export function ResolvePhantomTracks(arg1:number,arg2:Record<string, string>):Promise<void>;
export function ResolvePhantomTracksAfterScan():Promise<void>;
+4
View File
@@ -98,6 +98,10 @@ export function RenamePlaylist(arg1, arg2) {
return window['go']['playlist']['Service']['RenamePlaylist'](arg1, arg2);
}
export function RepopulateFromM3U() {
return window['go']['playlist']['Service']['RepopulateFromM3U']();
}
export function ResolvePhantomTracks(arg1, arg2) {
return window['go']['playlist']['Service']['ResolvePhantomTracks'](arg1, arg2);
}