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