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:
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user