refactor(frontend): adopt the lifecycle and the notification surface
The remaining views, brought onto the two mechanisms added earlier in this series. The lifecycle: every cached primary view moves its document listeners, intervals and event subscriptions off connect/disconnect and onto `viewActivated`/`viewDeactivated`, so `autotag-view` stops fielding keystrokes from Settings, `downloads-view`'s 30 s clock stops ticking for the session, and an off-screen view stops rendering on every search keystroke. `autotag-view` keeps a document listener only for Escape, whose dialogs Phase 5 migrates to wa-dialog anyway. The voice: the silent failures now speak — scan and full rescan (with a guard against the double-click the coalescing window allowed), job pause/resume/cancel, playlist delete, download request pause/remove/ clear, add and rename library, add-to-playlist, playlist track removal, autotag's dialogs and its apply, and favourite reverts. Both private toasts are gone, along with their CSS and keyframes. Playlist delete (single and the multi-select loop), download-request removal, download-client removal and a queue clear over 20 tracks ask first. Loading, empty and failed become three states rather than one, in `track-list` and `genre-details` — the first is on the first screen a new user ever sees — and the Settings index panel seeds itself with `GetIndexStatus()` instead of waiting forever for a change event. `smart-playlist-editor` and `download-picker` take the request-version guard `explore-view` already had. `track-details` loads through one memoised dynamic import in all ten openers, which is what takes its 42 kB out of the startup chunk: an un-upgraded custom element is a real HTMLElement on which `?.show()` throws, so each opener awaits it before touching the element its template already rendered.
This commit is contained in:
@@ -3,13 +3,17 @@ import { customElement, state, query as litQuery } from 'lit/decorators.js';
|
||||
import { designTokens } from '../../styles/tokens.css';
|
||||
import { SearchLocal, SearchLyrics, GetThumbnail, GetThumbnails, GetArtistImageURL, RecordSearchClick } from '@go/explore/Service';
|
||||
import { libraryStore } from '../../store/library-store';
|
||||
import { exploreCache } from '../../store/explore-cache';
|
||||
import { exploreCache, ARTIST_IMAGE_CACHE_LIMIT } from '../../store/explore-cache';
|
||||
import { queueStore } from '../../store/queue-store';
|
||||
import { artistLink, trackLink, exploreLinkStyles } from '../../utils/explore-link';
|
||||
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 { explore } from '@go/models';
|
||||
import { ViewLifecycleMixin } from '../../utils/view-lifecycle';
|
||||
import { registerCacheProbe } from '../../utils/cache-stats';
|
||||
import { LRUMap } from '../../utils/lru-map';
|
||||
type ThumbnailRequest = explore.ThumbnailRequest;
|
||||
type MBSearchResult = explore.MBSearchResult;
|
||||
type LyricsResult = explore.LyricsResult;
|
||||
@@ -25,6 +29,32 @@ 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;
|
||||
|
||||
|
||||
/** Hash a string to a hue value 0–360 for avatar coloring. */
|
||||
function nameToHue(name: string): number {
|
||||
@@ -79,7 +109,7 @@ function getArtistAlbumArt(artistName: string): string {
|
||||
}
|
||||
|
||||
@customElement('explore-view')
|
||||
export class ExploreView extends LitElement {
|
||||
export class ExploreView extends ViewLifecycleMixin(LitElement) {
|
||||
/* ── State ── */
|
||||
|
||||
@state() private searchQuery = '';
|
||||
@@ -96,10 +126,29 @@ export class ExploreView extends LitElement {
|
||||
private searchVersion = 0;
|
||||
/** Debounce timer for live search-as-you-type. */
|
||||
private searchDebounceTimer?: ReturnType<typeof setTimeout>;
|
||||
private thumbnailCache = new Map<string, string>();
|
||||
private artistImageCache = new Map<string, string>();
|
||||
private thumbnailCache = new LRUMap<string, string>(THUMBNAIL_CACHE_LIMIT);
|
||||
private artistImageCache = new LRUMap<string, string>(ARTIST_IMAGE_CACHE_LIMIT);
|
||||
private libraryMBIDs = new Set<string>();
|
||||
|
||||
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;
|
||||
|
||||
/* ── Styles ── */
|
||||
@@ -592,9 +641,13 @@ export class ExploreView extends LitElement {
|
||||
|
||||
override disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
if (this.searchDebounceTimer) {
|
||||
clearTimeout(this.searchDebounceTimer);
|
||||
}
|
||||
this.cancelPendingSearch();
|
||||
}
|
||||
|
||||
/** 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();
|
||||
}
|
||||
|
||||
/* ── Search Logic ── */
|
||||
@@ -707,19 +760,16 @@ export class ExploreView extends LitElement {
|
||||
this.loading = true;
|
||||
this.error = '';
|
||||
|
||||
const startTime = performance.now();
|
||||
console.log(`[explore] search started: "${query}" (${this.searchMode})`);
|
||||
|
||||
// Lyrics mode: a single FTS lyric search over the library.
|
||||
if (this.searchMode === 'lyrics') {
|
||||
void this.executeLyricsSearch(version, query, startTime);
|
||||
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, startTime);
|
||||
void this.executeIndexSearch(version, query);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -775,7 +825,7 @@ export class ExploreView extends LitElement {
|
||||
return result;
|
||||
}
|
||||
|
||||
private async executeIndexSearch(version: number, query: string, startTime: number) {
|
||||
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
|
||||
@@ -809,19 +859,16 @@ export class ExploreView extends LitElement {
|
||||
this.loadThumbnails();
|
||||
this.loadArtistImages();
|
||||
this.checkLibrary();
|
||||
|
||||
const elapsed = (performance.now() - startTime).toFixed(0);
|
||||
console.log(
|
||||
`[explore] search completed: "${query}" in ${elapsed}ms — ` +
|
||||
`artists=${result.artists?.length ?? 0}, ` +
|
||||
`albums=${result.releaseGroups?.length ?? 0}, ` +
|
||||
`tracks=${result.recordings?.length ?? 0}`,
|
||||
);
|
||||
} catch (err) {
|
||||
if (version !== this.searchVersion) return;
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
this.error = message;
|
||||
console.error(`[explore] search error: "${query}" — ${message}`);
|
||||
|
||||
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;
|
||||
@@ -831,22 +878,20 @@ export class ExploreView extends LitElement {
|
||||
|
||||
/* ── Lyrics Search ── */
|
||||
|
||||
private async executeLyricsSearch(version: number, query: string, startTime: number) {
|
||||
private async executeLyricsSearch(version: number, query: string) {
|
||||
try {
|
||||
const hits = await SearchLyrics(query);
|
||||
if (version !== this.searchVersion) return;
|
||||
|
||||
this.lyricsResults = hits ?? [];
|
||||
|
||||
const elapsed = (performance.now() - startTime).toFixed(0);
|
||||
console.log(
|
||||
`[explore] lyrics search: "${query}" in ${elapsed}ms — ` +
|
||||
`hits=${this.lyricsResults.length}`,
|
||||
);
|
||||
} catch (err) {
|
||||
if (version !== this.searchVersion) return;
|
||||
this.error = err instanceof Error ? err.message : String(err);
|
||||
console.error(`[explore] lyrics search error: "${query}" — ${this.error}`);
|
||||
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user