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:
2026-08-12 01:19:47 -04:00
parent c8bc6db9fa
commit 2518385330
18 changed files with 930 additions and 375 deletions
@@ -23,6 +23,9 @@ import { EventsOn } from '@runtime/runtime';
import { Events } from '../../events';
import { inlineDiff, normalizeStrict, isCosmeticDiff } from '../../utils/text-diff';
import { libraryStore } from '../../store/library-store';
import { notificationStore } from '../../store/notification-store';
import { describeError, explainError } from '../../utils/describe-error';
import { ViewLifecycleMixin } from '../../utils/view-lifecycle';
type PendingItem = autotagservice.PendingItem;
type ScoreView = autotagservice.ScoreView;
@@ -97,7 +100,13 @@ interface LengthDiffDetail {
* leave · U paste URL · ↑↓ navigate folders · Esc close dialogs.
*/
@customElement('autotag-view')
export class AutotagView extends LitElement {
export class AutotagView extends ViewLifecycleMixin(LitElement) {
/* The A/S/L/U/F and arrow keys are registered shortcuts in the
* `autotag` panel scope, not a document listener of this view's own.
* Two document keydown handlers with no arbitration is finding H-2:
* on this page `s` both skipped the album and toggled shuffle. */
protected override shortcutScope = 'autotag';
static override readonly styles = [
designTokens,
css`
@@ -1179,14 +1188,38 @@ export class AutotagView extends LitElement {
// render the default gray question-mark icon.
@state() private applyJobs: Map<string, ApplyJobState> = new Map();
private queueStarted = false;
private unsubscribeLibraryStore?: () => void;
private unsubscribeApplyEvents: Array<() => void> = [];
private currentLibraryFilter: number | null = null;
override connectedCallback(): void {
super.connectedCallback();
document.addEventListener('keydown', this.onKeydown);
document.addEventListener('mousedown', this.onDocumentClickForMenu);
/** Everything here is torn down when the view leaves the screen, not
* when it is disconnected — which never happens, because the view
* is cached (see utils/view-lifecycle.ts). */
protected override onViewActivate(): void {
this.listenWhileActive(document, 'keydown', this.onKeydown as EventListener);
this.listenWhileActive(
document,
'mousedown',
this.onDocumentClickForMenu as EventListener,
);
for (const [event, handler] of [
['shortcut:autotag-apply', () => void this.onApply()],
['shortcut:autotag-skip', () => void this.onSkip()],
['shortcut:autotag-leave', () => void this.onLeave()],
['shortcut:autotag-paste', () => { this.dialog = 'paste'; }],
['shortcut:autotag-search', () => this.openSearch()],
['shortcut:autotag-next', () => void this.navigateFolder(1)],
['shortcut:autotag-previous', () => void this.navigateFolder(-1)],
] as Array<[string, () => void]>) {
this.listenWhileActive(document, event, () => {
// A dialog owns the keyboard while it is open.
if (this.dialog !== 'none') return;
handler();
});
}
// Track the global library filter so the autotag queue
// shows only folders from the currently-selected library.
// null means "all libraries", which the backend treats as
@@ -1201,7 +1234,7 @@ export class AutotagView extends LitElement {
// Listen for per-job progress events so the sidebar can
// render running/completed/failed indicators. EventsOn
// returns an unsubscribe; collected and called on disconnect.
// returns an unsubscribe; collected and called on deactivate.
this.unsubscribeApplyEvents.push(
EventsOn(Events.AutotagApplyStarted, (data: { groupKey: string; total: number }) => {
this.onApplyStarted(data);
@@ -1228,14 +1261,21 @@ export class AutotagView extends LitElement {
}),
);
void this.startQueue();
// Starting the queue resets the selection and refetches
// candidates over the network, so it happens once. Returning to
// the page only needs the folder list, which is local and may
// have moved while the page was away.
if (this.queueStarted) {
void this.loadFolders();
} else {
this.queueStarted = true;
void this.startQueue();
}
}
override disconnectedCallback(): void {
super.disconnectedCallback();
document.removeEventListener('keydown', this.onKeydown);
document.removeEventListener('mousedown', this.onDocumentClickForMenu);
protected override onViewDeactivate(): void {
this.unsubscribeLibraryStore?.();
this.unsubscribeLibraryStore = undefined;
for (const fn of this.unsubscribeApplyEvents) fn();
this.unsubscribeApplyEvents = [];
if (this.prefetchRefreshTimer !== undefined) {
@@ -1279,17 +1319,44 @@ export class AutotagView extends LitElement {
private async onApplyFinished({ groupKey, succeeded, failed, error }:
{ groupKey: string; succeeded: number; failed: number; error: string }): Promise<void> {
const allFailed = error !== '' || (succeeded === 0 && failed > 0);
// Some files carry the new tags and some the old, and there is
// no way to discover that later: Blocking, by the plan's rule
// (errors.C3).
if (succeeded > 0 && failed > 0) {
this.updateApplyJob(groupKey, {
state: 'failed',
error: `${failed} of ${succeeded + failed} tracks failed`,
});
notificationStore.blocking({
key: `autotag-partial:${groupKey}`,
title: 'This folder was only partly retagged',
text: `${succeeded} of ${succeeded + failed} tracks were written; ${failed} were not. The folder now holds a mix of old and new tags.`,
detail: error || undefined,
});
await this.loadFolders();
return;
}
if (allFailed) {
this.updateApplyJob(groupKey, {
state: 'failed',
error: error || `${failed} of ${failed} tracks failed`,
});
// Toast the error so the user sees it.
this.errorMessage = error
? `Apply failed: ${error}`
: `Apply failed: ${failed} of ${failed} tracks could not be written.`;
// Nothing was written, so nothing is inconsistent: this is
// something to retry, not something to interrupt for.
notificationStore.persistent({
key: 'autotag-apply',
title: 'Tags not written',
text: error
? explainError(error, 'That folder could not be retagged.')
: `None of the ${failed} tracks in that folder could be written.`,
detail: error || undefined,
});
// Refresh so the row picks up any DB state change.
await this.loadFolders();
return;
}
@@ -1327,16 +1394,44 @@ export class AutotagView extends LitElement {
private onWarningCancel = () => { this.dialog = 'none'; };
private onWarningContinue = async () => {
if (!this.current) return;
this.markWarningAcked(this.current.libraryId);
await AckLibraryWarning(this.current.libraryId);
this.dialog = 'none';
// Unwrapped, a rejection here meant the lines after it — the
// one that closes the dialog — never ran, and the dialog sat
// there forever (errors.m6).
try {
await AckLibraryWarning(this.current.libraryId);
} catch (err) {
console.error('autotag: could not record the warning ack', err);
this.errorMessage = describeError(
err,
'That acknowledgement could not be saved.',
);
} finally {
this.dialog = 'none';
}
await this.executeApply();
};
private onLeaveCancel = () => { this.dialog = 'none'; };
private onLeaveConfirm = async () => {
if (!this.current) return;
this.dialog = 'none';
await LeaveAsIs(this.current.groupKey);
try {
await LeaveAsIs(this.current.groupKey);
} catch (err) {
console.error('autotag: leave-as-is failed', err);
this.errorMessage = describeError(
err,
'That folder could not be left as it is.',
);
return;
}
await this.refreshAfterAction();
};
@@ -1364,7 +1459,11 @@ export class AutotagView extends LitElement {
this.searchKind, query, this.searchArtist.trim(),
);
} catch (err) {
this.searchError = `Search failed: ${(err as Error).message}`;
console.error('autotag: candidate search failed', err);
this.searchError = describeError(
err,
'The catalog search did not answer.',
);
this.searchResults = [];
} finally {
this.searchLoading = false;
@@ -1380,7 +1479,11 @@ export class AutotagView extends LitElement {
this.score = await SelectSearchCandidate(groupKey, hit.kind, hit.mbid);
this.selectedCandidateIdx = 0;
} catch (err) {
this.errorMessage = `Failed to load candidate: ${(err as Error).message}`;
console.error('autotag: could not load candidate', err);
this.errorMessage = describeError(
err,
'That candidate could not be loaded.',
);
} finally {
this.loading = false;
}
@@ -1399,7 +1502,11 @@ export class AutotagView extends LitElement {
await this.loadFolders();
await this.reconcileSelection();
} catch (err) {
this.errorMessage = `Clear completed failed: ${(err as Error).message}`;
console.error('autotag: clear completed failed', err);
this.errorMessage = describeError(
err,
'The completed folders could not be cleared.',
);
}
};
@@ -1470,7 +1577,11 @@ export class AutotagView extends LitElement {
const list = await ListPendingFolders(this.libraryFilterID());
this.folders = list ?? [];
} catch (e) {
this.errorMessage = `Failed to load pending folders: ${(e as Error).message}`;
console.error('autotag: could not load pending folders', e);
this.errorMessage = describeError(
e,
'The pending folders could not be loaded.',
);
}
}
@@ -1517,7 +1628,11 @@ export class AutotagView extends LitElement {
await this.loadCandidates(this.current.groupKey);
}
} catch (e) {
this.errorMessage = `Failed to load folder: ${(e as Error).message}`;
console.error('autotag: could not load folder', e);
this.errorMessage = describeError(
e,
'That folder could not be loaded.',
);
}
}
@@ -1548,7 +1663,11 @@ export class AutotagView extends LitElement {
this.score = result;
} catch (e) {
if (this.current?.groupKey !== groupKey) return;
this.errorMessage = `Failed to score candidates: ${(e as Error).message}`;
console.error('autotag: could not score candidates', e);
this.errorMessage = describeError(
e,
'The candidates for this folder could not be scored.',
);
} finally {
this.loading = false;
}
@@ -1647,8 +1766,14 @@ export class AutotagView extends LitElement {
// from the UI, but defensive); other errors clear the
// running state and surface a toast.
if (!msg.includes('apply already in flight')) {
console.error('autotag: apply failed to start', e);
this.updateApplyJob(groupKey, { state: 'failed', error: msg });
this.errorMessage = `Apply failed: ${msg}`;
notificationStore.persistent({
key: 'autotag-apply',
title: 'Tags not written',
text: explainError(e, 'That folder could not be retagged.'),
detail: msg,
});
}
});
@@ -1659,17 +1784,43 @@ export class AutotagView extends LitElement {
private async onSkip(): Promise<void> {
if (!this.current) return;
await Skip(this.current.groupKey);
try {
await Skip(this.current.groupKey);
} catch (err) {
console.error('autotag: skip failed', err);
this.errorMessage = describeError(
err,
'That folder could not be skipped.',
);
return;
}
await this.refreshAfterAction();
}
private async onLeave(): Promise<void> {
if (!this.current) return;
if (this.topScore() < CONFIDENT_SCORE) {
this.dialog = 'leave';
return;
}
await LeaveAsIs(this.current.groupKey);
try {
await LeaveAsIs(this.current.groupKey);
} catch (err) {
console.error('autotag: leave-as-is failed', err);
this.errorMessage = describeError(
err,
'That folder could not be left as it is.',
);
return;
}
await this.refreshAfterAction();
}
@@ -1684,7 +1835,11 @@ export class AutotagView extends LitElement {
this.dialog = 'none';
this.pasteURL = '';
} catch (e) {
this.errorMessage = `Paste URL failed: ${(e as Error).message}`;
console.error('autotag: paste URL failed', e);
this.errorMessage = describeError(
e,
'That release URL could not be used.',
);
} finally {
this.loading = false;
}
@@ -1703,47 +1858,16 @@ export class AutotagView extends LitElement {
}
}
/** Escape closes this view's hand-rolled dialogs. Everything else
* the page binds is a registered shortcut in the `autotag` panel
* scope, so the shortcut service is the only thing deciding what a
* key means — including on the pages this view is not on. */
private onKeydown = (e: KeyboardEvent): void => {
const target = e.target as HTMLElement;
if (target && (
target.tagName === 'INPUT' ||
target.tagName === 'TEXTAREA' ||
target.isContentEditable
)) {
if (e.key === 'Escape' && this.dialog !== 'none') {
e.preventDefault();
this.dialog = 'none';
this.pasteURL = '';
}
return;
}
if (e.key !== 'Escape' || this.dialog === 'none') return;
if (e.key === 'Escape') {
if (this.dialog !== 'none') {
e.preventDefault();
this.dialog = 'none';
this.pasteURL = '';
}
return;
}
if (this.dialog !== 'none') return;
switch (e.key.toLowerCase()) {
case 'a': e.preventDefault(); void this.onApply(); break;
case 's': e.preventDefault(); void this.onSkip(); break;
case 'l': e.preventDefault(); void this.onLeave(); break;
case 'u': e.preventDefault(); this.dialog = 'paste'; break;
case 'f': e.preventDefault(); this.openSearch(); break;
case 'arrowdown':
e.preventDefault();
void this.navigateFolder(1);
break;
case 'arrowup':
e.preventDefault();
void this.navigateFolder(-1);
break;
}
e.preventDefault();
this.dialog = 'none';
this.pasteURL = '';
};
/* ── Version clustering ── */
@@ -14,7 +14,10 @@ import {
GetScanConcurrency,
SetScanConcurrency,
} from '@go/config/Config';
import { GetIndexStatus } from '@go/explore/Service';
import { DirectoryPicker } from '@go/frontendutil/FrontendUtil';
import { notificationStore } from '@store/notification-store';
import { describeError, explainError } from '@utils/describe-error';
import type { library } from '@go/models';
import { ThemeController } from '@store/controllers/theme-controller';
import { TrackListController } from '@store/controllers/tracklist-controller';
@@ -22,6 +25,7 @@ import { FavoritesController } from '@store/controllers/favorites-controller';
import { GetAllPlaylists } from '@go/playlist/Service';
import type { playlist } from '@go/models';
import { Events } from '../../events';
import { ViewLifecycleMixin } from '../../utils/view-lifecycle';
import type { ConfigFieldChangeEvent } from './config-field';
import type { BackgroundShade } from '@store/theme-store';
import type { IconStyle } from '@store/favorites-store';
@@ -45,7 +49,7 @@ const SCROLL_CHANGE_EVENT = 'yj-scroll-mode-changed';
// ===================================================================
@customElement('config-page')
export class ConfigPage extends LitElement {
export class ConfigPage extends ViewLifecycleMixin(LitElement) {
// --- Theme controller for reading/writing theme state ---
private themeCtrl = new ThemeController(this);
@@ -164,6 +168,48 @@ export class ConfigPage extends LitElement {
scope: 'panel:track-list',
defaultKey: 'Delete',
},
'autotag.apply': {
label: 'Apply Match',
category: 'Autotag',
scope: 'panel:autotag',
defaultKey: 'A',
},
'autotag.skip': {
label: 'Skip Folder',
category: 'Autotag',
scope: 'panel:autotag',
defaultKey: 'S',
},
'autotag.leave': {
label: 'Leave As Is',
category: 'Autotag',
scope: 'panel:autotag',
defaultKey: 'L',
},
'autotag.paste': {
label: 'Paste Release URL',
category: 'Autotag',
scope: 'panel:autotag',
defaultKey: 'U',
},
'autotag.search': {
label: 'Search Candidates',
category: 'Autotag',
scope: 'panel:autotag',
defaultKey: 'F',
},
'autotag.next': {
label: 'Next Folder',
category: 'Autotag',
scope: 'panel:autotag',
defaultKey: 'Down',
},
'autotag.previous': {
label: 'Previous Folder',
category: 'Autotag',
scope: 'panel:autotag',
defaultKey: 'Up',
},
};
// --- Now Playing state ---
@@ -179,20 +225,20 @@ export class ConfigPage extends LitElement {
@state() private removingLibraryId: number | null = null;
@state() private removalImpact: library.RemovalImpact | null = null;
@state() private isRemoving = false;
@state() private toastMessage = '';
@state() private toastVisible = false;
@state() private activeMenuId: number | null = null;
@state() private concurrencyMode = 'auto';
@state() private indexStatus: explore.IndexStatus | null = null;
/** Three states, not one: the panel used to say "Loading status…"
* for the entire session, because the only thing that ever set
* `indexStatus` was an event that fires on *change* (errors.M3). */
@state() private indexStatusFailed = false;
@state() private shortcutConflict: {
newAction: string;
newKey: string;
existingAction: string;
} | null = null;
private toastTimer?: ReturnType<typeof setTimeout>;
private cancelIndexStatus?: () => void;
private indexPollTimer?: ReturnType<typeof setInterval>;
private cancelLibraryAdded?: () => void;
private cancelLibraryRenamed?: () => void;
private cancelLibraryRemoved?: () => void;
@@ -805,35 +851,6 @@ export class ConfigPage extends LitElement {
);
}
.toast {
position: fixed;
bottom: 80px;
left: 50%;
transform: translateX(-50%);
background: var(--yj-bg-surface, #2a2a2a);
color: var(--yj-text-primary, #fff);
padding: 0.75em 1.5em;
border-radius: 8px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4);
font-size: var(--yj-text-sm, 13px);
z-index: 2000;
border: 1px solid var(--yj-border, #444);
animation: toast-in 0.2s ease-out;
}
@keyframes toast-in {
from {
opacity: 0;
transform: translateX(-50%)
translateY(10px);
}
to {
opacity: 1;
transform: translateX(-50%)
translateY(0);
}
}
.spinner {
display: inline-block;
width: 14px;
@@ -982,14 +999,31 @@ export class ConfigPage extends LitElement {
color: var(--yj-text-tertiary, #888);
}
.index-status-failed {
align-items: center;
color: var(--yj-text-secondary, #b3b3b3);
display: flex;
font-size: var(--yj-text-sm);
gap: 0.5em;
}
.index-status-failed .link {
background: none;
border: none;
color: var(--yj-accent, #ffd43b);
cursor: pointer;
font: inherit;
padding: 0;
text-decoration: underline;
}
`;
// ===================================================================
// LIFECYCLE
// ===================================================================
override connectedCallback(): void {
super.connectedCallback();
protected override onViewActivate(): void {
void this.loadLibraries();
void this.loadPlaylists();
this.scrollMode =
@@ -1010,28 +1044,27 @@ export class ConfigPage extends LitElement {
() => void this.loadLibraries(),
);
document.addEventListener('click', this.handleDocumentClick);
this.listenWhileActive(document, '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;
this.indexStatusFailed = false;
},
);
// …and pull the current one, because that event only fires when
// a build *changes* state, and the steady state is no build.
void this.loadIndexStatus();
}
override disconnectedCallback(): void {
super.disconnectedCallback();
protected override onViewDeactivate(): void {
this.cancelLibraryAdded?.();
this.cancelLibraryRenamed?.();
this.cancelLibraryRemoved?.();
document.removeEventListener('click', this.handleDocumentClick);
if (this.toastTimer) clearTimeout(this.toastTimer);
if (this.indexPollTimer) clearInterval(this.indexPollTimer);
this.cancelIndexStatus?.();
}
@@ -1058,17 +1091,29 @@ export class ConfigPage extends LitElement {
// ===================================================================
private handleAddLibrary = async (): Promise<void> => {
try {
const dir = await DirectoryPicker();
let dir = '';
if (dir) {
await AddLibrary(dir);
}
try {
dir = await DirectoryPicker();
if (!dir) return;
await AddLibrary(dir);
} catch (err) {
console.error(
'Failed to add library:',
err,
);
console.error('Failed to add library:', err);
notificationStore.persistent({
key: 'library-add',
title: 'Library not added',
text: explainError(
err,
'That folder could not be added as a library.',
),
detail: dir,
action: {
label: 'Try again',
run: () => void this.handleAddLibrary(),
},
});
}
};
@@ -1083,10 +1128,21 @@ export class ConfigPage extends LitElement {
e.preventDefault();
if (this.editingLibraryId !== null && this.editingName.trim()) {
const name = this.editingName.trim();
try {
await RenameLibrary(this.editingLibraryId, this.editingName.trim());
await RenameLibrary(this.editingLibraryId, name);
} catch (err) {
console.error('Failed to rename library:', err);
notificationStore.persistent({
key: 'library-rename',
title: 'Library not renamed',
text: explainError(
err,
`${name}” could not be used as a library name.`,
),
detail: String(err),
});
}
}
@@ -1130,16 +1186,23 @@ export class ConfigPage extends LitElement {
this.removingLibraryId = null;
this.removalImpact = null;
this.isRemoving = false;
this.showToast(
`Removed '${libName}' (${summary?.tracksDeleted ?? 0} tracks deleted)`,
);
notificationStore.transient({
tone: 'success',
key: 'library-remove',
text: `Removed “${libName}” — ${summary?.tracksDeleted ?? 0} tracks deleted.`,
});
void this.loadLibraries();
} catch (err) {
this.isRemoving = false;
this.removingLibraryId = null;
this.removalImpact = null;
console.error('Failed to remove library:', err);
this.showToast(`Failed to remove '${libName}': ${String(err)}`);
notificationStore.persistent({
key: 'library-remove',
title: 'Library not removed',
text: `Could not remove “${libName}”. ${describeError(err)}`,
detail: String(err),
});
}
};
@@ -1165,15 +1228,14 @@ export class ConfigPage extends LitElement {
}
};
private showToast(message: string): void {
this.toastMessage = message;
this.toastVisible = true;
if (this.toastTimer) clearTimeout(this.toastTimer);
this.toastTimer = setTimeout(() => {
this.toastVisible = false;
}, 8000);
private async loadIndexStatus(): Promise<void> {
try {
this.indexStatus = await GetIndexStatus();
this.indexStatusFailed = false;
} catch (err) {
console.error('Failed to read index status:', err);
this.indexStatusFailed = true;
}
}
private handleConcurrencyChange = (
@@ -1184,12 +1246,19 @@ export class ConfigPage extends LitElement {
SetScanConcurrency(mode)
.then(() => {
this.concurrencyMode = mode;
this.showToast(
'Storage type saved. Takes effect on next scan.',
);
notificationStore.transient({
tone: 'success',
key: 'storage-type',
text: 'Storage type saved. Takes effect on next scan.',
});
})
.catch((err: unknown) => {
this.showToast(`Failed to save storage type: ${err}`);
console.error('Failed to save storage type:', err);
notificationStore.transient({
key: 'storage-type',
text: `Could not save the storage type. ${describeError(err)}`,
detail: String(err),
});
});
};
@@ -1511,7 +1580,7 @@ export class ConfigPage extends LitElement {
? html`<span class="tier-detail">${t.detail}</span>`
: nothing}
${t.state === 'error'
? html`<span class="tier-error">${t.error}</span>`
? html`<span class="tier-error">${describeError(t.error, 'This part of the index could not be built.')}</span>`
: nothing}
</div>
`,
@@ -1527,7 +1596,18 @@ export class ConfigPage extends LitElement {
? html`<div class="index-waiting">Waiting for index build…</div>`
: nothing}
`
: html`<div class="index-loading">Loading status…</div>`}
: this.indexStatusFailed
? html`<div class="index-status-failed">
<span>The index status could not be read.</span>
<button
type="button"
class="link"
@click=${() => void this.loadIndexStatus()}
>
Retry
</button>
</div>`
: html`<div class="index-loading">Loading status…</div>`}
</div>
</config-section>
`;
@@ -2133,10 +2213,6 @@ export class ConfigPage extends LitElement {
`
: nothing}
</config-section>
${this.toastVisible
? html`<div class="toast">${this.toastMessage}</div>`
: nothing}
`;
}
@@ -18,6 +18,8 @@ import { DirectoryPicker } from '@go/frontendutil/FrontendUtil';
import { GetDownloadPreferences, SetDownloadPreferences } from '@go/config/Config';
import { SetPreferences } from '@go/download/Service';
import type { download } from '@go/models';
import { describeError, explainError } from '@utils/describe-error';
import { confirmAction } from '@components/confirm-dialog/confirm-dialog';
import './config-section';
/**
@@ -416,7 +418,7 @@ export class DownloadClients extends LitElement {
size="small"
appearance="plain"
variant="danger"
@click=${() => this.deleteProvider(provider)}
@click=${() => void this.deleteProvider(provider)}
>
Remove
</wa-button>
@@ -643,7 +645,11 @@ export class DownloadClients extends LitElement {
this.cancelEdit();
} catch (err) {
this.errorMessage = String(err);
console.error('Failed to add download client:', err);
this.errorMessage = explainError(
err,
'That client could not be saved.',
);
}
};
@@ -670,7 +676,11 @@ export class DownloadClients extends LitElement {
this.cancelEdit();
} catch (err) {
this.errorMessage = String(err);
console.error('Failed to save download client:', err);
this.errorMessage = explainError(
err,
'Those changes could not be saved.',
);
}
}
@@ -685,13 +695,33 @@ export class DownloadClients extends LitElement {
return out;
}
/**
* Removing a client discards its stored credentials, which cannot
* be recovered — and it used to happen on one click (errors.m4).
*/
private async deleteProvider(provider: DownloadProvider) {
const ok = await confirmAction({
title: `Remove “${provider.name}”?`,
message:
'YellowJacket will stop using this client for downloads.',
impact:
'Its stored credentials are deleted and cannot be recovered.',
confirmLabel: 'Remove client',
danger: true,
});
if (!ok) return;
this.errorMessage = '';
try {
await downloadStore.deleteProvider(provider.id);
} catch (err) {
this.errorMessage = String(err);
console.error('Failed to remove download client:', err);
this.errorMessage = describeError(
err,
'That client could not be removed.',
);
}
}
@@ -706,6 +736,9 @@ export class DownloadClients extends LitElement {
[provider.id]: { ok: true, message: 'Connected.' },
};
} catch (err) {
// Deliberately verbatim: a connection test's error is the
// user's debugging tool for a misconfigured client, and is
// the documented exception to describeError() (errors.M9).
this.testResults = {
...this.testResults,
[provider.id]: { ok: false, message: String(err) },
@@ -740,7 +773,11 @@ export class DownloadClients extends LitElement {
await SetPreferences(this.prefs);
this.prefsSaved = true;
} catch (err) {
this.prefsError = String(err);
console.error('Failed to save download preferences:', err);
this.prefsError = describeError(
err,
'Those preferences could not be saved.',
);
} finally {
this.prefsSaving = false;
}
@@ -9,6 +9,7 @@ import type { DownloadCandidate } from '@store/download-store';
import { downloadStore } from '@store/download-store';
import type { download } from '@go/models';
import './candidate-row';
import { explainError } from '@utils/describe-error';
/**
* The "find this album" dialog: searches every enabled download client,
@@ -123,8 +124,17 @@ export class DownloadPicker extends LitElement {
}
}
/**
* Monotonic search version. Closing and reopening the dialog for a
* different album used to let the first search's result overwrite
* the second's (errors.m8); the guard is `explore-view`'s.
*/
private searchVersion = 0;
/** Runs the search that populates the dialog. */
private async search(): Promise<void> {
const version = ++this.searchVersion;
this.searching = true;
this.errorMessage = '';
this.candidates = [];
@@ -141,13 +151,23 @@ export class DownloadPicker extends LitElement {
expected: this.expected ?? [],
} as download.SearchRequest);
if (version !== this.searchVersion) return;
this.downloadId = result.downloadId;
this.candidates = result.candidates ?? [];
this.autoPicked = result.autoPicked;
} catch (err) {
this.errorMessage = String(err);
if (version !== this.searchVersion) return;
console.error('download search failed', err);
this.errorMessage = explainError(
err,
'The search did not finish.',
);
} finally {
this.searching = false;
if (version === this.searchVersion) {
this.searching = false;
}
}
}
@@ -161,7 +181,11 @@ export class DownloadPicker extends LitElement {
await downloadStore.pick(this.downloadId, event.detail.candidateId);
this.close();
} catch (err) {
this.errorMessage = String(err);
console.error('download pick failed', err);
this.errorMessage = explainError(
err,
'That download could not be started.',
);
} finally {
this.picking = false;
}
@@ -6,6 +6,10 @@ import { designTokens } from '../../styles/tokens.css';
import { downloadStore, stateLabel } from '@store/download-store';
import type { Request, RequestSummary, DownloadView as DownloadRecord } from '@store/download-store';
import { libraryStore } from '@store/library-store';
import { notificationStore } from '@store/notification-store';
import { describeError } from '@utils/describe-error';
import { confirmAction } from '../confirm-dialog/confirm-dialog';
import { ViewLifecycleMixin } from '../../utils/view-lifecycle';
type Tab = 'requests' | 'downloads';
@@ -22,7 +26,7 @@ type Tab = 'requests' | 'downloads';
* attempt history nothing rendered before this page existed.
*/
@customElement('downloads-view')
export class DownloadsView extends LitElement {
export class DownloadsView extends ViewLifecycleMixin(LitElement) {
@state() private tab: Tab = 'requests';
@state() private requests: Request[] = [];
@@ -39,7 +43,6 @@ export class DownloadsView extends LitElement {
/** Ticks so "next check in …" ages while the page is open. */
@state() private nowMs = Date.now();
private clockTimer?: ReturnType<typeof setInterval>;
private unsubscribe: (() => void) | null = null;
@@ -196,9 +199,7 @@ export class DownloadsView extends LitElement {
`,
];
override connectedCallback(): void {
super.connectedCallback();
protected override onViewActivate(): void {
this.unsubscribe = downloadStore.subscribe(() => {
this.requests = downloadStore.requests;
this.downloads = downloadStore.downloads;
@@ -212,18 +213,16 @@ export class DownloadsView extends LitElement {
});
// A "next check" that never moves reads as a stuck page, so the
// relative times re-render on their own.
this.clockTimer = setInterval(() => {
// relative times re-render on their own — while the page is on
// screen, where a re-render can be seen.
this.intervalWhileActive(() => {
this.nowMs = Date.now();
}, 30_000);
}
override disconnectedCallback(): void {
super.disconnectedCallback();
protected override onViewDeactivate(): void {
this.unsubscribe?.();
this.unsubscribe = null;
clearInterval(this.clockTimer);
}
override render() {
@@ -295,8 +294,7 @@ export class DownloadsView extends LitElement {
<wa-button
size="small"
appearance="plain"
@click=${() =>
void downloadStore.clearSatisfiedRequests()}
@click=${() => void this.clearSatisfied()}
>
Clear found
</wa-button>
@@ -450,11 +448,7 @@ export class DownloadsView extends LitElement {
<wa-button
size="small"
appearance="plain"
@click=${() =>
void downloadStore.pauseRequest(
request.id,
request.state !== 'paused',
)}
@click=${() => void this.pause(request)}
>
${request.state === 'paused' ? 'Resume' : 'Pause'}
</wa-button>
@@ -470,13 +464,77 @@ export class DownloadsView extends LitElement {
<wa-button
size="small"
appearance="plain"
@click=${() => void downloadStore.removeRequest(request.id)}
aria-label="Stop following"
@click=${() => void this.removeRequest(request)}
>
<wa-icon name="xmark"></wa-icon>
</wa-button>
`;
}
/** What to call a request in a sentence. */
private static describeRequest(request: Request): string {
return request.artist || request.title || request.mbid || 'that request';
}
/**
* Removing a durable request used to be one unconfirmed click with
* the promise thrown away (errors.M7) — on a subscription the user
* may have been building for months.
*/
private async removeRequest(request: Request): Promise<void> {
const name = DownloadsView.describeRequest(request);
const ok = await confirmAction({
title: `Stop following “${name}”?`,
message:
request.scope === 'all'
? 'YellowJacket will stop looking for this artists releases.'
: 'YellowJacket will stop looking for this release.',
impact: 'Anything already downloaded stays in your library.',
confirmLabel: 'Stop following',
danger: true,
});
if (!ok) return;
try {
await downloadStore.removeRequest(request.id);
} catch (err) {
this.report(`remove “${name}`, err);
}
}
private async pause(request: Request): Promise<void> {
const paused = request.state !== 'paused';
try {
await downloadStore.pauseRequest(request.id, paused);
} catch (err) {
this.report(
`${paused ? 'pause' : 'resume'}${DownloadsView.describeRequest(request)}`,
err,
);
}
}
private async clearSatisfied(): Promise<void> {
try {
await downloadStore.clearSatisfiedRequests();
} catch (err) {
this.report('clear the found requests', err);
}
}
/** Persistent: the row is still there, and retrying is the point. */
private report(what: string, err: unknown): void {
console.error(`downloads: could not ${what}`, err);
notificationStore.persistent({
key: 'download-request',
text: `Could not ${what}. ${describeError(err)}`,
detail: String(err),
});
}
/** Widens or narrows what an artist subscription covers. */
private async toggleScope(request: Request): Promise<void> {
try {
@@ -15,6 +15,7 @@ type MBTrack = explore.MBTrack;
import { exploreCache } from '../../store/explore-cache';
import { libraryStore } from '../../store/library-store';
import { artistLink, exploreLinkStyles } from '../../utils/explore-link';
import { describeError } from '../../utils/describe-error';
import { EventsOn } from '@runtime/runtime';
import { Events } from '../../events';
import '@awesome.me/webawesome/dist/components/icon/icon.js';
@@ -664,22 +665,13 @@ export class ExploreAlbumDetails extends LitElement {
// Local-only album (no MBID) — populate entirely from library.
if (!mbid && this.localAlbumId) {
console.log(
`[explore-album] loading local-only: "${this.albumName}" (id=${this.localAlbumId})`,
);
await this.hydrateLocalOnly();
console.log(
`[explore-album] loaded (local-only): "${this.albumName}"`,
);
return;
}
console.log(
`[explore-album] loading: "${this.albumName}" (${mbid})`,
);
// Phase 0: hydrate from explore cache (instant).
const cached = exploreCache.getAlbum(mbid);
@@ -692,7 +684,6 @@ export class ExploreAlbumDetails extends LitElement {
primaryType: 'Album',
} as MBReleaseGroup;
this.loadingInfo = false;
console.log(`[explore-album] hydrated from cache: "${cached.title}"`);
}
// Phase 1: hydrate tracklist from local library if available.
@@ -712,9 +703,6 @@ export class ExploreAlbumDetails extends LitElement {
// Fire cover art resolution immediately — doesn't wait for release group.
this.resolveCoverArt();
console.log(
`[explore-album] data requests fired: "${this.albumName}" (${mbid})`,
);
}
/**
@@ -874,9 +862,6 @@ export class ExploreAlbumDetails extends LitElement {
this.buildClusters();
this.loadingReleases = false;
console.log(
`[explore-album] hydrated ${localTracks.length} tracks from library`,
);
return true;
}
@@ -908,9 +893,11 @@ export class ExploreAlbumDetails extends LitElement {
this.releaseGroup = await LookupReleaseGroup(mbid);
this.resolveCoverArt();
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
this.errorInfo = msg;
console.error(`[explore-album] LookupReleaseGroup error: ${msg}`);
console.error('[explore-album] LookupReleaseGroup error', err);
this.errorInfo = describeError(
err,
'The catalog did not answer for this album.',
);
} finally {
this.loadingInfo = false;
}
@@ -965,9 +952,11 @@ export class ExploreAlbumDetails extends LitElement {
this.catalogPending = false;
}
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
this.errorReleases = msg;
console.error(`[explore-album] BrowseReleases error: ${msg}`);
console.error('[explore-album] BrowseReleases error', err);
this.errorReleases = describeError(
err,
'The catalog did not answer for this album\u2019s versions.',
);
this.loadingReleases = false;
this.catalogPending = false;
}
@@ -27,6 +27,7 @@ import { libraryStore } from '../../store/library-store';
import { downloadStore } from '../../store/download-store';
import '@awesome.me/webawesome/dist/components/button/button.js';
import { trackLink, exploreLinkStyles } from '../../utils/explore-link';
import { describeError } from '../../utils/describe-error';
import { GetAlbumsByArtist } from '@go/library/Library';
import { EventsOn } from '@runtime/runtime';
import { Events } from '../../events';
@@ -1014,9 +1015,6 @@ export class ExploreArtistDetails extends LitElement {
// Local-only artist (no MBID) — populate from library store.
if (!mbid && this.localArtistId) {
console.log(
`[explore-artist] loading local-only: "${this.artistName}" (id=${this.localArtistId})`,
);
this.loadingArtist = false;
this.loadingTracks = false;
@@ -1027,16 +1025,10 @@ export class ExploreArtistDetails extends LitElement {
this.hydrateLocalOnly();
console.log(
`[explore-artist] loaded (local-only): "${this.artistName}"`,
);
return;
}
console.log(
`[explore-artist] loading: "${this.artistName}" (${mbid})`,
);
// Phase 0: hydrate from caches (instant, no Go calls).
this.hydrateFromCache(mbid);
@@ -1068,9 +1060,6 @@ export class ExploreArtistDetails extends LitElement {
// checkLibrary now runs from fetchReleaseGroups when data arrives.
console.log(
`[explore-artist] data requests fired: "${this.artistName}" (${mbid})`,
);
}
/**
@@ -1290,9 +1279,11 @@ export class ExploreArtistDetails extends LitElement {
try {
this.artist = await LookupArtist(mbid);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
this.errorArtist = msg;
console.error(`[explore-artist] LookupArtist error: ${msg}`);
console.error('[explore-artist] LookupArtist error', err);
this.errorArtist = describeError(
err,
'The catalog did not answer.',
);
} finally {
this.loadingArtist = false;
}
@@ -1326,10 +1317,7 @@ export class ExploreArtistDetails extends LitElement {
void this.batchResolveTrackThumbnails(tracks, mapping);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
console.error(
`[explore-artist] TopRecordingsForArtist error: ${msg}`,
);
console.error('[explore-artist] TopRecordingsForArtist error', err);
} finally {
// An empty first pass may mean a background discography fetch
// is still in flight (the artist wasn't indexed yet). Keep
@@ -1419,10 +1407,7 @@ export class ExploreArtistDetails extends LitElement {
// are the most likely to be clicked from the artist page.
this.prefetchReleases(rgs?.map((r) => r.releaseGroupMbid) ?? []);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
console.error(
`[explore-artist] TopReleaseGroupsForArtist error: ${msg}`,
);
console.error('[explore-artist] TopReleaseGroupsForArtist error', err);
this.topReleaseGroups = [];
} finally {
// See fetchTopTracks: hold the spinner while a background
@@ -1462,12 +1447,12 @@ export class ExploreArtistDetails extends LitElement {
// one from here is instant instead of a cold MB browse.
this.prefetchReleases(rgs?.map((r) => r.mbid) ?? []);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
this.errorReleases = msg;
this.catalogPending = false;
console.error(
`[explore-artist] BrowseReleaseGroups error: ${msg}`,
console.error('[explore-artist] BrowseReleaseGroups error', err);
this.errorReleases = describeError(
err,
'The catalog did not answer for this artist\u2019s albums.',
);
this.catalogPending = false;
} finally {
// BrowseReleaseGroups is index-first + async: an empty result on
// a cold artist means the discography is still being fetched in
@@ -1498,10 +1483,7 @@ export class ExploreArtistDetails extends LitElement {
this.similarArtists = artists ?? [];
} catch (err) {
// D024: graceful degradation — silently omit similar artists on failure.
const msg = err instanceof Error ? err.message : String(err);
console.error(
`[explore-artist] SimilarArtists error: ${msg}`,
);
console.error('[explore-artist] SimilarArtists error', err);
this.similarArtists = [];
} finally {
// SimilarArtists is DB-first + async: an empty result on the
@@ -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 0360 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;
@@ -7,6 +7,7 @@ import {
GetAllLibrariesWithTrackCounts,
} from '@go/library/Library';
import { DirectoryPicker } from '@go/frontendutil/FrontendUtil';
import { describeError, explainError } from '@utils/describe-error';
/**
* First-run setup wizard.
@@ -236,7 +237,10 @@ export class FirstRunWizard extends LitElement {
if (dir) this.selectedDirectory = dir;
} catch (err) {
this.errorMessage = `Could not open folder picker: ${err}`;
this.errorMessage = describeError(
err,
'The folder picker could not be opened.',
);
console.error('First-run wizard: directory picker failed:', err);
}
};
@@ -256,7 +260,10 @@ export class FirstRunWizard extends LitElement {
this.active = false;
} catch (err) {
this.errorMessage = `Could not add the folder: ${err}`;
this.errorMessage = explainError(
err,
'That folder could not be added.',
);
console.error('First-run wizard: add library failed:', err);
} finally {
this.saving = false;
@@ -12,6 +12,7 @@ import {
import { EventsOn } from '@runtime/runtime';
import { Events } from '../../events';
import { libraryStore } from '@store/library-store';
import { describeError } from '@utils/describe-error';
import '@awesome.me/webawesome/dist/components/icon/icon.js';
import '@components/track-list/track-list.js';
import { designTokens } from '../../styles/tokens.css';
@@ -27,6 +28,12 @@ export class GenreDetails extends LitElement {
@state()
private loading = true;
/** A failed genre query used to be handed to `<track-list>` as an
* empty array, so it was indistinguishable from a slow one
* (errors.M2). */
@state()
private loadError = '';
private scanCompleteCleanup: (() => void) | null =
null;
@@ -38,6 +45,21 @@ export class GenreDetails extends LitElement {
height: 100%;
}
.load-error {
color: var(--yj-text-secondary, #b3b3b3);
padding: 1em;
}
.load-error button {
background: none;
border: 1px solid var(--yj-border, #495057);
border-radius: 4px;
color: inherit;
cursor: pointer;
font: inherit;
padding: 4px 10px;
}
/* ====================================
* Header
* ==================================== */
@@ -179,6 +201,8 @@ export class GenreDetails extends LitElement {
private async loadTracks() {
if (!this.genreName) return;
this.loadError = '';
try {
const libId =
libraryStore.getSelectedLibraryId();
@@ -192,11 +216,12 @@ export class GenreDetails extends LitElement {
this.genreName,
);
} catch (error) {
console.error(
'Error loading genre tracks:',
error,
);
console.error('Error loading genre tracks:', error);
this.tracks = [];
this.loadError = describeError(
error,
`The tracks for “${this.genreName}” could not be loaded.`,
);
} finally {
this.loading = false;
}
@@ -274,9 +299,19 @@ export class GenreDetails extends LitElement {
</div>
</div>
<div class="content">
<track-list
.externalTracks=${this.tracks}
></track-list>
${this.loadError
? html`<div class="load-error" data-testid="genre-error">
<p>${this.loadError}</p>
<button
type="button"
@click=${() => void this.loadTracks()}
>
Try again
</button>
</div>`
: html`<track-list
.externalTracks=${this.tracks}
></track-list>`}
</div>
`;
}
+10 -9
View File
@@ -10,6 +10,7 @@ import { libraryStore } from '@store/library-store';
import { EventsOn } from '@runtime/runtime';
import { Events } from '../../events';
import { designTokens } from '../../styles/tokens.css';
import { ViewLifecycleMixin } from '../../utils/view-lifecycle';
type Shelf = home.Shelf;
@@ -36,7 +37,7 @@ const KIND_ICONS: Record<string, string> = {
* cover do the two things a cover should: open the album, or play it.
*/
@customElement('home-view')
export class HomeView extends LitElement {
export class HomeView extends ViewLifecycleMixin(LitElement) {
@state() private shelves: Shelf[] = [];
@state() private loading = true;
@@ -194,8 +195,10 @@ export class HomeView extends LitElement {
`,
];
override connectedCallback(): void {
super.connectedCallback();
protected override onViewActivate(): void {
// Reloaded on arrival rather than kept live: the shelves are a
// judgement about the whole library, so the answer while the
// page is off screen is of no interest to anyone.
void this.load();
// A finished scan changes what every shelf would say, and the
@@ -204,12 +207,10 @@ export class HomeView extends LitElement {
this.unsubScan = EventsOn(Events.LibraryScanComplete, () => {
void this.load();
});
}
override disconnectedCallback(): void {
super.disconnectedCallback();
this.unsubScan?.();
this.unsubScan = undefined;
this.whileActive(() => {
this.unsubScan?.();
this.unsubScan = undefined;
});
}
/**
+56 -21
View File
@@ -1,4 +1,8 @@
import { jobStore } from '@store/job-store';
import { notificationStore } from '@store/notification-store';
import { describeError } from '@utils/describe-error';
import { confirmAction } from '../confirm-dialog/confirm-dialog';
export type JobControlAction = 'pause' | 'resume' | 'cancel' | 'dismiss';
@@ -7,31 +11,57 @@ export interface JobControlDetail {
action: JobControlAction;
}
const VERB: Record<JobControlAction, string> = {
pause: 'pause',
resume: 'resume',
cancel: 'cancel',
dismiss: 'dismiss',
};
/**
* Applies a `job-control` event emitted by a `<job-row>`.
*
* Shared by every host that renders job rows — the top-bar popover, the
* jobs page, and the details drawer — so a control behaves identically
* wherever it is pressed, and so no host can forget to wire one up.
*
* This is used directly as a DOM listener, so its promise is discarded:
* a rejection has to be caught *here* or it is an unhandled rejection
* and a button that silently does nothing (errors.M4).
*/
export async function applyJobControl(e: Event): Promise<void> {
const { id, action } = (e as CustomEvent).detail as JobControlDetail;
if (action === 'cancel' && !confirmCancel(id)) return;
if (action === 'cancel' && !(await confirmCancel(id))) return;
switch (action) {
case 'pause':
await jobStore.pause(id);
break;
case 'resume':
await jobStore.resume(id);
break;
case 'cancel':
await jobStore.cancel(id);
break;
case 'dismiss':
await jobStore.dismiss(id);
break;
try {
switch (action) {
case 'pause':
await jobStore.pause(id);
break;
case 'resume':
await jobStore.resume(id);
break;
case 'cancel':
await jobStore.cancel(id);
break;
case 'dismiss':
await jobStore.dismiss(id);
break;
}
} catch (err) {
console.error(`job ${action} failed`, err);
const job = jobStore.getJob(id);
const name = job?.title ?? 'that job';
// Transient: the button visibly did not take, and the next
// JobsChanged snapshot says what is actually true.
notificationStore.transient({
key: `job-${action}`,
text: `Could not ${VERB[action]} ${name}. ${describeError(err)}`,
detail: String(err),
});
}
}
@@ -40,15 +70,20 @@ export async function applyJobControl(e: Event): Promise<void> {
* so it is worth a confirmation even though the checkpoint survives. A
* library scan is cheap to re-run — don't nag for that one.
*/
function confirmCancel(id: string): boolean {
function confirmCancel(id: string): Promise<boolean> {
const job = jobStore.getJob(id);
if (job?.kind !== 'index-build') return true;
if (job?.kind !== 'index-build') return Promise.resolve(true);
return window.confirm(
'Stop building the search index?\n\n' +
return confirmAction({
title: 'Stop building the search index?',
message:
'Progress is checkpointed, so you can resume later without ' +
're-downloading. Until it finishes, search results stay ' +
'limited to your own library.',
);
're-downloading.',
impact:
'Until it finishes, search results stay limited to your own ' +
'library.',
confirmLabel: 'Stop building',
danger: true,
});
}
@@ -9,6 +9,8 @@ export function jobIcon(job: Job): string {
return 'folder';
case 'index-build':
return 'database';
case 'autotag-apply':
return 'tags';
default:
return 'gear';
}
+81 -30
View File
@@ -13,10 +13,14 @@ import { EventsOn } from '@runtime/runtime';
import { Events } from '../../events';
import { jobStore } from '@store/job-store';
import type { Job } from '@store/job-store';
import { notificationStore } from '@store/notification-store';
import { describeError } from '@utils/describe-error';
import { confirmAction } from '../confirm-dialog/confirm-dialog';
import './job-row';
import './job-details-drawer';
import { applyJobControl } from './job-controls';
import { jobStateStyles } from './job-format';
import { ViewLifecycleMixin } from '../../utils/view-lifecycle';
type LibraryInfo = library.Info;
@@ -36,7 +40,7 @@ const TERMINAL_STATES: ReadonlySet<string> = new Set([
* one implementation, two placements, so the two can never disagree.
*/
@customElement('jobs-view')
export class JobsView extends LitElement {
export class JobsView extends ViewLifecycleMixin(LitElement) {
@state()
private jobs: Job[] = [];
@@ -49,6 +53,15 @@ export class JobsView extends LitElement {
@state()
private drawerOpen = false;
/**
* Set between pressing a scan button and the job snapshot that
* proves it started. `anyScanning` is derived from `JobsChanged`,
* which is coalesced at 250 ms — long enough for a second click to
* start a second scan (errors.M5).
*/
@state()
private starting = false;
private unsubscribe: (() => void) | null = null;
private eventCleanups: Array<() => void> = [];
@@ -221,8 +234,7 @@ export class JobsView extends LitElement {
`,
];
override connectedCallback(): void {
super.connectedCallback();
protected override onViewActivate(): void {
this.unsubscribe = jobStore.subscribe(() => {
this.jobs = jobStore.jobs;
});
@@ -243,8 +255,7 @@ export class JobsView extends LitElement {
}
}
override disconnectedCallback(): void {
super.disconnectedCallback();
protected override onViewDeactivate(): void {
this.unsubscribe?.();
this.unsubscribe = null;
this.eventCleanups.forEach((off) => off());
@@ -273,20 +284,52 @@ export class JobsView extends LitElement {
this.drawerOpen = false;
};
private async startScan(id: number) {
/**
* Run something that starts a job, holding the buttons until the
* snapshot lands and saying so when it does not start at all.
*
* Persistent, not a toast: the user asked for work to happen, it
* did not, and retrying is exactly the useful response.
*/
private async startJob(
what: string,
start: () => Promise<unknown>,
retry: () => void,
): Promise<void> {
if (this.starting) return;
this.starting = true;
try {
await ScanLibrary(id);
await start();
} catch (err) {
console.error('Failed to start scan:', err);
console.error(`${what} failed:`, err);
notificationStore.persistent({
key: 'scan-start',
title: 'Scan did not start',
text: `${what} failed. ${describeError(err)}`,
detail: String(err),
action: { label: 'Try again', run: retry },
});
} finally {
this.starting = false;
}
}
private async startScan(id: number) {
await this.startJob(
'Scanning that library',
() => ScanLibrary(id),
() => void this.startScan(id),
);
}
private async startAllScans() {
try {
await ScanAllLibraries();
} catch (err) {
console.error('Failed to start scans:', err);
}
await this.startJob(
'Scanning your libraries',
() => ScanAllLibraries(),
() => void this.startAllScans(),
);
}
private async clearFinished() {
@@ -294,22 +337,25 @@ export class JobsView extends LitElement {
}
private async fullRescan() {
if (
!window.confirm(
'Full rescan deletes ALL library data — including ' +
'downloaded cover art — and rebuilds it from your ' +
'files.\n\nThis is not the same as "Scan now", which ' +
'only picks up what changed. Continue?',
)
) {
return;
}
const ok = await confirmAction({
title: 'Full rescan',
message:
'This deletes all library data — including downloaded ' +
'cover art — and rebuilds it from your files.',
impact:
'It is not the same as “Scan now”, which only picks up ' +
'what changed.',
confirmLabel: 'Rebuild everything',
danger: true,
});
try {
await FullRescan();
} catch (err) {
console.error('Full rescan failed:', err);
}
if (!ok) return;
await this.startJob(
'The full rescan',
() => FullRescan(),
() => void this.fullRescan(),
);
}
private renderJobList(list: Job[], emptyText: string) {
@@ -393,6 +439,7 @@ export class JobsView extends LitElement {
: html`
<button
class="action"
?disabled=${this.starting}
@click=${() => this.startScan(lib.id)}
>
<wa-icon name="arrows-rotate"></wa-icon>
@@ -431,7 +478,9 @@ export class JobsView extends LitElement {
<h2>Libraries</h2>
<button
class="action"
?disabled=${anyScanning || this.libraries.length === 0}
?disabled=${anyScanning ||
this.starting ||
this.libraries.length === 0}
@click=${this.startAllScans}
>
<wa-icon name="arrows-rotate"></wa-icon>
@@ -467,7 +516,9 @@ export class JobsView extends LitElement {
</div>
<button
class="action danger"
?disabled=${anyScanning}
?disabled=${anyScanning ||
this.starting ||
this.libraries.length === 0}
@click=${this.fullRescan}
>
<wa-icon name="triangle-exclamation"></wa-icon>
@@ -14,6 +14,8 @@ import {
import { Events } from '../../events';
import type { playlist } from '@go/models';
import '@components/duplicate-tracks-dialog/duplicate-tracks-dialog.js';
import { notificationStore } from '@store/notification-store';
import { describeError } from '@utils/describe-error';
import type { DuplicateTracksDialog } from '@components/duplicate-tracks-dialog/duplicate-tracks-dialog.js';
/**
@@ -188,6 +190,14 @@ export class PlaylistPicker extends LitElement {
this.dispatchComplete();
} catch (err) {
console.error('Failed to add tracks to playlist:', err);
// Transient: the picker closes either way, and the tracks
// simply are not there — nothing to undo, only to retry
// (errors.m7).
notificationStore.transient({
key: 'playlist-add',
text: `Could not add ${this.filePaths.length === 1 ? 'that track' : `those ${this.filePaths.length} tracks`} to the playlist. ${describeError(err)}`,
detail: String(err),
});
} finally {
this.loading = false;
}
@@ -223,6 +233,11 @@ export class PlaylistPicker extends LitElement {
this.dispatchComplete();
} catch (err) {
console.error('Failed to create playlist:', err);
notificationStore.transient({
key: 'playlist-create',
text: `Could not create “${name}”. ${describeError(err)}`,
detail: String(err),
});
} finally {
this.loading = false;
}
@@ -26,6 +26,10 @@ import {
getActiveDragPlaylistId,
} from '@utils/drag-controller';
import { contextMenuStyles } from '@utils/context-menu-controller.js';
import { describeError } from '@utils/describe-error';
import { notificationStore } from '@store/notification-store';
import { confirmAction } from '@components/confirm-dialog/confirm-dialog';
import { ViewLifecycleMixin } from '@utils/view-lifecycle';
import { FavoritesController } from '@store/controllers/favorites-controller';
import '@components/duplicate-tracks-dialog/duplicate-tracks-dialog.js';
import type { DuplicateTracksDialog } from '@components/duplicate-tracks-dialog/duplicate-tracks-dialog.js';
@@ -51,7 +55,7 @@ interface PlaylistEntry {
}
@customElement('playlist-view')
export class PlaylistView extends LitElement {
export class PlaylistView extends ViewLifecycleMixin(LitElement) {
private playlistCtrl = new PlaylistController(this);
private searchCtrl = new SearchController(this);
private favCtrl = new FavoritesController(this);
@@ -801,23 +805,31 @@ export class PlaylistView extends LitElement {
super.connectedCallback();
this.restoreSortPreferences();
this.loadPlaylists();
document.addEventListener(
}
protected override onViewActivate(): void {
this.listenWhileActive(
document,
'click',
this.closePlaylistCtxMenuHandler,
);
document.addEventListener(
this.listenWhileActive(
document,
'contextmenu',
this.closePlaylistCtxMenuHandler,
);
document.addEventListener(
this.listenWhileActive(
document,
'mousedown',
this.playlistCtxMenuMousedownHandler,
);
document.addEventListener(
this.listenWhileActive(
document,
'click',
this.clearSelectionHandler,
);
document.addEventListener(
this.listenWhileActive(
document,
'mousedown',
this.sortDropdownCloseHandler,
);
@@ -830,27 +842,6 @@ export class PlaylistView extends LitElement {
clearTimeout(this.scrollDebounceTimer);
this.scrollDebounceTimer = null;
}
document.removeEventListener(
'click',
this.closePlaylistCtxMenuHandler,
);
document.removeEventListener(
'contextmenu',
this.closePlaylistCtxMenuHandler,
);
document.removeEventListener(
'mousedown',
this.playlistCtxMenuMousedownHandler,
);
document.removeEventListener(
'click',
this.clearSelectionHandler,
);
document.removeEventListener(
'mousedown',
this.sortDropdownCloseHandler,
);
}
override updated() {
@@ -1351,13 +1342,33 @@ export class PlaylistView extends LitElement {
break;
case 'delete': {
if (this.selectedPlaylists.size > 1) {
const ids = [...this.selectedPlaylists]
const entries = [...this.selectedPlaylists]
.map(i => this.entries[i])
.filter((e): e is PlaylistEntry => e !== undefined)
.map(e => e.summary.ID);
.filter((e): e is PlaylistEntry => e !== undefined);
const tracks = entries.reduce(
(sum, e) => sum + e.tracks.length,
0,
);
for (const id of ids) {
await DeletePlaylist(id);
// The loop used to delete every selected playlist
// with no prompt at all (errors.M6).
const ok = await confirmAction({
title: `Delete ${entries.length} playlists?`,
message: entries
.map((e) => e.summary.Name)
.join(', '),
impact: `${tracks.toLocaleString()} track entries will be removed. The audio files are not touched.`,
confirmLabel: 'Delete playlists',
danger: true,
});
if (!ok) break;
for (const e of entries) {
await this.deletePlaylist(
e.summary.ID,
e.summary.Name,
);
}
this.selectedPlaylists = new Set();
@@ -1366,6 +1377,8 @@ export class PlaylistView extends LitElement {
} else {
await this.handleDeletePlaylist(
entry.summary.ID,
entry.summary.Name,
entry.tracks.length,
);
}
@@ -1380,15 +1393,54 @@ export class PlaylistView extends LitElement {
private async handleDeletePlaylist(
playlistID: number,
name = 'this playlist',
trackCount = 0,
) {
const ok = await confirmAction({
title: `Delete “${name}”?`,
message: 'The playlist is deleted; the audio files are not.',
impact:
trackCount > 0
? `${trackCount.toLocaleString()} track entries will be removed.`
: undefined,
confirmLabel: 'Delete playlist',
danger: true,
});
if (!ok) return;
await this.deletePlaylist(playlistID, name);
await this.refreshPlaylists();
}
/**
* The delete itself, already confirmed. A partial failure used to
* look exactly like a success until the refresh put the playlist
* back (errors.M6), so it is Persistent: the thing the user asked
* for did not happen and retrying means something.
*/
private async deletePlaylist(
playlistID: number,
name: string,
): Promise<void> {
try {
await DeletePlaylist(playlistID);
await this.refreshPlaylists();
} catch (err) {
console.error(
'Failed to delete playlist:',
err,
);
console.error('Failed to delete playlist:', err);
notificationStore.persistent({
key: 'playlist-delete',
text: `Could not delete “${name}”. ${describeError(err)}`,
detail: String(err),
coalescedText: (count) =>
`Could not delete ${count} playlists.`,
action: {
label: 'Try again',
run: () =>
void this.deletePlaylist(playlistID, name).then(() =>
this.refreshPlaylists(),
),
},
});
}
}
@@ -1466,10 +1518,10 @@ export class PlaylistView extends LitElement {
'Failed to import playlist:',
err,
);
this.importError =
err instanceof Error
? err.message
: String(err);
this.importError = describeError(
err,
'That playlist file could not be imported.',
);
setTimeout(() => {
this.importError = '';
}, 6000);
@@ -3,6 +3,7 @@ import { customElement, property, state } from 'lit/decorators.js';
import { library } from '@go/models';
import { PreviewSmartPlaylist } from '@go/playlist/Service';
import { libraryStore } from '@store/library-store';
import { describeError } from '@utils/describe-error';
import { designTokens } from '../../styles/tokens.css';
import '@components/combobox/combobox.ts';
@@ -675,6 +676,14 @@ export class SmartPlaylistEditor extends LitElement {
}, 300);
}
/**
* Monotonic request version. Debouncing only coalesces keystrokes
* *within* its window; a query slower than 300 ms overlaps the next
* one and whichever answers last used to win (errors.M8). This is
* `explore-view`'s guard, checked on all three paths.
*/
private previewVersion = 0;
private async runPreview() {
// Skip preview if any rule is incomplete
const incomplete = this.ruleRows.some(
@@ -690,19 +699,30 @@ export class SmartPlaylistEditor extends LitElement {
}
const json = this.buildRulesJSON();
const version = ++this.previewVersion;
this.previewLoading = true;
this.previewError = '';
try {
const tracks = await PreviewSmartPlaylist(json);
if (version !== this.previewVersion) return;
this.previewTracks = tracks ?? [];
} catch (error) {
if (version !== this.previewVersion) return;
console.error('Smart playlist preview failed:', error);
this.previewError =
error instanceof Error ? error.message : String(error);
this.previewError = describeError(
error,
'The preview could not be built for these rules.',
);
this.previewTracks = [];
} finally {
this.previewLoading = false;
if (version === this.previewVersion) {
this.previewLoading = false;
}
}
}
@@ -22,7 +22,7 @@ import { GetTrackMBIDs } from '@go/library/Library';
type TrackMBIDs = library.TrackMBIDs;
import { ImageFilePicker, ReadFile } from '@go/frontendutil/FrontendUtil';
import { libraryStore } from '../../store/library-store';
import { EventsOn, EventsOff } from '@runtime/runtime';
import { EventsOn } from '@runtime/runtime';
import { Events } from '../../events';
import '@awesome.me/webawesome/dist/components/dialog/dialog.js';
@@ -1714,8 +1714,10 @@ export class TrackDetails extends LitElement {
const changes = this.buildBatchChanges();
// Listen for progress events.
EventsOn(
// Listen for progress events. Kept as the unsubscribe function
// EventsOn returns, rather than EventsOff(name), which removes
// *every* listener for that event (errors.p3).
const stopProgress = EventsOn(
Events.BatchWriteProgress,
(data: {
current: number;
@@ -1762,7 +1764,7 @@ export class TrackDetails extends LitElement {
],
};
} finally {
EventsOff(Events.BatchWriteProgress);
stopProgress();
this.batchProgress = null;
}
};