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:
@@ -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 artist’s 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 {
|
||||
|
||||
Reference in New Issue
Block a user