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
@@ -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);