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
+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>