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:
@@ -23,6 +23,9 @@ import { EventsOn } from '@runtime/runtime';
|
||||
import { Events } from '../../events';
|
||||
import { inlineDiff, normalizeStrict, isCosmeticDiff } from '../../utils/text-diff';
|
||||
import { libraryStore } from '../../store/library-store';
|
||||
import { notificationStore } from '../../store/notification-store';
|
||||
import { describeError, explainError } from '../../utils/describe-error';
|
||||
import { ViewLifecycleMixin } from '../../utils/view-lifecycle';
|
||||
|
||||
type PendingItem = autotagservice.PendingItem;
|
||||
type ScoreView = autotagservice.ScoreView;
|
||||
@@ -97,7 +100,13 @@ interface LengthDiffDetail {
|
||||
* leave · U paste URL · ↑↓ navigate folders · Esc close dialogs.
|
||||
*/
|
||||
@customElement('autotag-view')
|
||||
export class AutotagView extends LitElement {
|
||||
export class AutotagView extends ViewLifecycleMixin(LitElement) {
|
||||
/* The A/S/L/U/F and arrow keys are registered shortcuts in the
|
||||
* `autotag` panel scope, not a document listener of this view's own.
|
||||
* Two document keydown handlers with no arbitration is finding H-2:
|
||||
* on this page `s` both skipped the album and toggled shuffle. */
|
||||
protected override shortcutScope = 'autotag';
|
||||
|
||||
static override readonly styles = [
|
||||
designTokens,
|
||||
css`
|
||||
@@ -1179,14 +1188,38 @@ export class AutotagView extends LitElement {
|
||||
// render the default gray question-mark icon.
|
||||
@state() private applyJobs: Map<string, ApplyJobState> = new Map();
|
||||
|
||||
private queueStarted = false;
|
||||
private unsubscribeLibraryStore?: () => void;
|
||||
private unsubscribeApplyEvents: Array<() => void> = [];
|
||||
private currentLibraryFilter: number | null = null;
|
||||
|
||||
override connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
document.addEventListener('keydown', this.onKeydown);
|
||||
document.addEventListener('mousedown', this.onDocumentClickForMenu);
|
||||
/** Everything here is torn down when the view leaves the screen, not
|
||||
* when it is disconnected — which never happens, because the view
|
||||
* is cached (see utils/view-lifecycle.ts). */
|
||||
protected override onViewActivate(): void {
|
||||
this.listenWhileActive(document, 'keydown', this.onKeydown as EventListener);
|
||||
this.listenWhileActive(
|
||||
document,
|
||||
'mousedown',
|
||||
this.onDocumentClickForMenu as EventListener,
|
||||
);
|
||||
|
||||
for (const [event, handler] of [
|
||||
['shortcut:autotag-apply', () => void this.onApply()],
|
||||
['shortcut:autotag-skip', () => void this.onSkip()],
|
||||
['shortcut:autotag-leave', () => void this.onLeave()],
|
||||
['shortcut:autotag-paste', () => { this.dialog = 'paste'; }],
|
||||
['shortcut:autotag-search', () => this.openSearch()],
|
||||
['shortcut:autotag-next', () => void this.navigateFolder(1)],
|
||||
['shortcut:autotag-previous', () => void this.navigateFolder(-1)],
|
||||
] as Array<[string, () => void]>) {
|
||||
this.listenWhileActive(document, event, () => {
|
||||
// A dialog owns the keyboard while it is open.
|
||||
if (this.dialog !== 'none') return;
|
||||
handler();
|
||||
});
|
||||
}
|
||||
|
||||
// Track the global library filter so the autotag queue
|
||||
// shows only folders from the currently-selected library.
|
||||
// null means "all libraries", which the backend treats as
|
||||
@@ -1201,7 +1234,7 @@ export class AutotagView extends LitElement {
|
||||
|
||||
// Listen for per-job progress events so the sidebar can
|
||||
// render running/completed/failed indicators. EventsOn
|
||||
// returns an unsubscribe; collected and called on disconnect.
|
||||
// returns an unsubscribe; collected and called on deactivate.
|
||||
this.unsubscribeApplyEvents.push(
|
||||
EventsOn(Events.AutotagApplyStarted, (data: { groupKey: string; total: number }) => {
|
||||
this.onApplyStarted(data);
|
||||
@@ -1228,14 +1261,21 @@ export class AutotagView extends LitElement {
|
||||
}),
|
||||
);
|
||||
|
||||
void this.startQueue();
|
||||
// Starting the queue resets the selection and refetches
|
||||
// candidates over the network, so it happens once. Returning to
|
||||
// the page only needs the folder list, which is local and may
|
||||
// have moved while the page was away.
|
||||
if (this.queueStarted) {
|
||||
void this.loadFolders();
|
||||
} else {
|
||||
this.queueStarted = true;
|
||||
void this.startQueue();
|
||||
}
|
||||
}
|
||||
|
||||
override disconnectedCallback(): void {
|
||||
super.disconnectedCallback();
|
||||
document.removeEventListener('keydown', this.onKeydown);
|
||||
document.removeEventListener('mousedown', this.onDocumentClickForMenu);
|
||||
protected override onViewDeactivate(): void {
|
||||
this.unsubscribeLibraryStore?.();
|
||||
this.unsubscribeLibraryStore = undefined;
|
||||
for (const fn of this.unsubscribeApplyEvents) fn();
|
||||
this.unsubscribeApplyEvents = [];
|
||||
if (this.prefetchRefreshTimer !== undefined) {
|
||||
@@ -1279,17 +1319,44 @@ export class AutotagView extends LitElement {
|
||||
private async onApplyFinished({ groupKey, succeeded, failed, error }:
|
||||
{ groupKey: string; succeeded: number; failed: number; error: string }): Promise<void> {
|
||||
const allFailed = error !== '' || (succeeded === 0 && failed > 0);
|
||||
|
||||
// Some files carry the new tags and some the old, and there is
|
||||
// no way to discover that later: Blocking, by the plan's rule
|
||||
// (errors.C3).
|
||||
if (succeeded > 0 && failed > 0) {
|
||||
this.updateApplyJob(groupKey, {
|
||||
state: 'failed',
|
||||
error: `${failed} of ${succeeded + failed} tracks failed`,
|
||||
});
|
||||
notificationStore.blocking({
|
||||
key: `autotag-partial:${groupKey}`,
|
||||
title: 'This folder was only partly retagged',
|
||||
text: `${succeeded} of ${succeeded + failed} tracks were written; ${failed} were not. The folder now holds a mix of old and new tags.`,
|
||||
detail: error || undefined,
|
||||
});
|
||||
await this.loadFolders();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (allFailed) {
|
||||
this.updateApplyJob(groupKey, {
|
||||
state: 'failed',
|
||||
error: error || `${failed} of ${failed} tracks failed`,
|
||||
});
|
||||
// Toast the error so the user sees it.
|
||||
this.errorMessage = error
|
||||
? `Apply failed: ${error}`
|
||||
: `Apply failed: ${failed} of ${failed} tracks could not be written.`;
|
||||
// Nothing was written, so nothing is inconsistent: this is
|
||||
// something to retry, not something to interrupt for.
|
||||
notificationStore.persistent({
|
||||
key: 'autotag-apply',
|
||||
title: 'Tags not written',
|
||||
text: error
|
||||
? explainError(error, 'That folder could not be retagged.')
|
||||
: `None of the ${failed} tracks in that folder could be written.`,
|
||||
detail: error || undefined,
|
||||
});
|
||||
// Refresh so the row picks up any DB state change.
|
||||
await this.loadFolders();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1327,16 +1394,44 @@ export class AutotagView extends LitElement {
|
||||
private onWarningCancel = () => { this.dialog = 'none'; };
|
||||
private onWarningContinue = async () => {
|
||||
if (!this.current) return;
|
||||
|
||||
this.markWarningAcked(this.current.libraryId);
|
||||
await AckLibraryWarning(this.current.libraryId);
|
||||
this.dialog = 'none';
|
||||
|
||||
// Unwrapped, a rejection here meant the lines after it — the
|
||||
// one that closes the dialog — never ran, and the dialog sat
|
||||
// there forever (errors.m6).
|
||||
try {
|
||||
await AckLibraryWarning(this.current.libraryId);
|
||||
} catch (err) {
|
||||
console.error('autotag: could not record the warning ack', err);
|
||||
this.errorMessage = describeError(
|
||||
err,
|
||||
'That acknowledgement could not be saved.',
|
||||
);
|
||||
} finally {
|
||||
this.dialog = 'none';
|
||||
}
|
||||
|
||||
await this.executeApply();
|
||||
};
|
||||
private onLeaveCancel = () => { this.dialog = 'none'; };
|
||||
private onLeaveConfirm = async () => {
|
||||
if (!this.current) return;
|
||||
|
||||
this.dialog = 'none';
|
||||
await LeaveAsIs(this.current.groupKey);
|
||||
|
||||
try {
|
||||
await LeaveAsIs(this.current.groupKey);
|
||||
} catch (err) {
|
||||
console.error('autotag: leave-as-is failed', err);
|
||||
this.errorMessage = describeError(
|
||||
err,
|
||||
'That folder could not be left as it is.',
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
await this.refreshAfterAction();
|
||||
};
|
||||
|
||||
@@ -1364,7 +1459,11 @@ export class AutotagView extends LitElement {
|
||||
this.searchKind, query, this.searchArtist.trim(),
|
||||
);
|
||||
} catch (err) {
|
||||
this.searchError = `Search failed: ${(err as Error).message}`;
|
||||
console.error('autotag: candidate search failed', err);
|
||||
this.searchError = describeError(
|
||||
err,
|
||||
'The catalog search did not answer.',
|
||||
);
|
||||
this.searchResults = [];
|
||||
} finally {
|
||||
this.searchLoading = false;
|
||||
@@ -1380,7 +1479,11 @@ export class AutotagView extends LitElement {
|
||||
this.score = await SelectSearchCandidate(groupKey, hit.kind, hit.mbid);
|
||||
this.selectedCandidateIdx = 0;
|
||||
} catch (err) {
|
||||
this.errorMessage = `Failed to load candidate: ${(err as Error).message}`;
|
||||
console.error('autotag: could not load candidate', err);
|
||||
this.errorMessage = describeError(
|
||||
err,
|
||||
'That candidate could not be loaded.',
|
||||
);
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
@@ -1399,7 +1502,11 @@ export class AutotagView extends LitElement {
|
||||
await this.loadFolders();
|
||||
await this.reconcileSelection();
|
||||
} catch (err) {
|
||||
this.errorMessage = `Clear completed failed: ${(err as Error).message}`;
|
||||
console.error('autotag: clear completed failed', err);
|
||||
this.errorMessage = describeError(
|
||||
err,
|
||||
'The completed folders could not be cleared.',
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1470,7 +1577,11 @@ export class AutotagView extends LitElement {
|
||||
const list = await ListPendingFolders(this.libraryFilterID());
|
||||
this.folders = list ?? [];
|
||||
} catch (e) {
|
||||
this.errorMessage = `Failed to load pending folders: ${(e as Error).message}`;
|
||||
console.error('autotag: could not load pending folders', e);
|
||||
this.errorMessage = describeError(
|
||||
e,
|
||||
'The pending folders could not be loaded.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1517,7 +1628,11 @@ export class AutotagView extends LitElement {
|
||||
await this.loadCandidates(this.current.groupKey);
|
||||
}
|
||||
} catch (e) {
|
||||
this.errorMessage = `Failed to load folder: ${(e as Error).message}`;
|
||||
console.error('autotag: could not load folder', e);
|
||||
this.errorMessage = describeError(
|
||||
e,
|
||||
'That folder could not be loaded.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1548,7 +1663,11 @@ export class AutotagView extends LitElement {
|
||||
this.score = result;
|
||||
} catch (e) {
|
||||
if (this.current?.groupKey !== groupKey) return;
|
||||
this.errorMessage = `Failed to score candidates: ${(e as Error).message}`;
|
||||
console.error('autotag: could not score candidates', e);
|
||||
this.errorMessage = describeError(
|
||||
e,
|
||||
'The candidates for this folder could not be scored.',
|
||||
);
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
@@ -1647,8 +1766,14 @@ export class AutotagView extends LitElement {
|
||||
// from the UI, but defensive); other errors clear the
|
||||
// running state and surface a toast.
|
||||
if (!msg.includes('apply already in flight')) {
|
||||
console.error('autotag: apply failed to start', e);
|
||||
this.updateApplyJob(groupKey, { state: 'failed', error: msg });
|
||||
this.errorMessage = `Apply failed: ${msg}`;
|
||||
notificationStore.persistent({
|
||||
key: 'autotag-apply',
|
||||
title: 'Tags not written',
|
||||
text: explainError(e, 'That folder could not be retagged.'),
|
||||
detail: msg,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1659,17 +1784,43 @@ export class AutotagView extends LitElement {
|
||||
|
||||
private async onSkip(): Promise<void> {
|
||||
if (!this.current) return;
|
||||
await Skip(this.current.groupKey);
|
||||
|
||||
try {
|
||||
await Skip(this.current.groupKey);
|
||||
} catch (err) {
|
||||
console.error('autotag: skip failed', err);
|
||||
this.errorMessage = describeError(
|
||||
err,
|
||||
'That folder could not be skipped.',
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
await this.refreshAfterAction();
|
||||
}
|
||||
|
||||
private async onLeave(): Promise<void> {
|
||||
if (!this.current) return;
|
||||
|
||||
if (this.topScore() < CONFIDENT_SCORE) {
|
||||
this.dialog = 'leave';
|
||||
|
||||
return;
|
||||
}
|
||||
await LeaveAsIs(this.current.groupKey);
|
||||
|
||||
try {
|
||||
await LeaveAsIs(this.current.groupKey);
|
||||
} catch (err) {
|
||||
console.error('autotag: leave-as-is failed', err);
|
||||
this.errorMessage = describeError(
|
||||
err,
|
||||
'That folder could not be left as it is.',
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
await this.refreshAfterAction();
|
||||
}
|
||||
|
||||
@@ -1684,7 +1835,11 @@ export class AutotagView extends LitElement {
|
||||
this.dialog = 'none';
|
||||
this.pasteURL = '';
|
||||
} catch (e) {
|
||||
this.errorMessage = `Paste URL failed: ${(e as Error).message}`;
|
||||
console.error('autotag: paste URL failed', e);
|
||||
this.errorMessage = describeError(
|
||||
e,
|
||||
'That release URL could not be used.',
|
||||
);
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
@@ -1703,47 +1858,16 @@ export class AutotagView extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
/** Escape closes this view's hand-rolled dialogs. Everything else
|
||||
* the page binds is a registered shortcut in the `autotag` panel
|
||||
* scope, so the shortcut service is the only thing deciding what a
|
||||
* key means — including on the pages this view is not on. */
|
||||
private onKeydown = (e: KeyboardEvent): void => {
|
||||
const target = e.target as HTMLElement;
|
||||
if (target && (
|
||||
target.tagName === 'INPUT' ||
|
||||
target.tagName === 'TEXTAREA' ||
|
||||
target.isContentEditable
|
||||
)) {
|
||||
if (e.key === 'Escape' && this.dialog !== 'none') {
|
||||
e.preventDefault();
|
||||
this.dialog = 'none';
|
||||
this.pasteURL = '';
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (e.key !== 'Escape' || this.dialog === 'none') return;
|
||||
|
||||
if (e.key === 'Escape') {
|
||||
if (this.dialog !== 'none') {
|
||||
e.preventDefault();
|
||||
this.dialog = 'none';
|
||||
this.pasteURL = '';
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.dialog !== 'none') return;
|
||||
|
||||
switch (e.key.toLowerCase()) {
|
||||
case 'a': e.preventDefault(); void this.onApply(); break;
|
||||
case 's': e.preventDefault(); void this.onSkip(); break;
|
||||
case 'l': e.preventDefault(); void this.onLeave(); break;
|
||||
case 'u': e.preventDefault(); this.dialog = 'paste'; break;
|
||||
case 'f': e.preventDefault(); this.openSearch(); break;
|
||||
case 'arrowdown':
|
||||
e.preventDefault();
|
||||
void this.navigateFolder(1);
|
||||
break;
|
||||
case 'arrowup':
|
||||
e.preventDefault();
|
||||
void this.navigateFolder(-1);
|
||||
break;
|
||||
}
|
||||
e.preventDefault();
|
||||
this.dialog = 'none';
|
||||
this.pasteURL = '';
|
||||
};
|
||||
|
||||
/* ── Version clustering ── */
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import type { DownloadCandidate } from '@store/download-store';
|
||||
import { downloadStore } from '@store/download-store';
|
||||
import type { download } from '@go/models';
|
||||
import './candidate-row';
|
||||
import { explainError } from '@utils/describe-error';
|
||||
|
||||
/**
|
||||
* The "find this album" dialog: searches every enabled download client,
|
||||
@@ -123,8 +124,17 @@ export class DownloadPicker extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Monotonic search version. Closing and reopening the dialog for a
|
||||
* different album used to let the first search's result overwrite
|
||||
* the second's (errors.m8); the guard is `explore-view`'s.
|
||||
*/
|
||||
private searchVersion = 0;
|
||||
|
||||
/** Runs the search that populates the dialog. */
|
||||
private async search(): Promise<void> {
|
||||
const version = ++this.searchVersion;
|
||||
|
||||
this.searching = true;
|
||||
this.errorMessage = '';
|
||||
this.candidates = [];
|
||||
@@ -141,13 +151,23 @@ export class DownloadPicker extends LitElement {
|
||||
expected: this.expected ?? [],
|
||||
} as download.SearchRequest);
|
||||
|
||||
if (version !== this.searchVersion) return;
|
||||
|
||||
this.downloadId = result.downloadId;
|
||||
this.candidates = result.candidates ?? [];
|
||||
this.autoPicked = result.autoPicked;
|
||||
} catch (err) {
|
||||
this.errorMessage = String(err);
|
||||
if (version !== this.searchVersion) return;
|
||||
|
||||
console.error('download search failed', err);
|
||||
this.errorMessage = explainError(
|
||||
err,
|
||||
'The search did not finish.',
|
||||
);
|
||||
} finally {
|
||||
this.searching = false;
|
||||
if (version === this.searchVersion) {
|
||||
this.searching = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -161,7 +181,11 @@ export class DownloadPicker extends LitElement {
|
||||
await downloadStore.pick(this.downloadId, event.detail.candidateId);
|
||||
this.close();
|
||||
} catch (err) {
|
||||
this.errorMessage = String(err);
|
||||
console.error('download pick failed', err);
|
||||
this.errorMessage = explainError(
|
||||
err,
|
||||
'That download could not be started.',
|
||||
);
|
||||
} finally {
|
||||
this.picking = false;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -15,6 +15,7 @@ type MBTrack = explore.MBTrack;
|
||||
import { exploreCache } from '../../store/explore-cache';
|
||||
import { libraryStore } from '../../store/library-store';
|
||||
import { artistLink, exploreLinkStyles } from '../../utils/explore-link';
|
||||
import { describeError } from '../../utils/describe-error';
|
||||
import { EventsOn } from '@runtime/runtime';
|
||||
import { Events } from '../../events';
|
||||
import '@awesome.me/webawesome/dist/components/icon/icon.js';
|
||||
@@ -664,22 +665,13 @@ export class ExploreAlbumDetails extends LitElement {
|
||||
|
||||
// Local-only album (no MBID) — populate entirely from library.
|
||||
if (!mbid && this.localAlbumId) {
|
||||
console.log(
|
||||
`[explore-album] loading local-only: "${this.albumName}" (id=${this.localAlbumId})`,
|
||||
);
|
||||
|
||||
await this.hydrateLocalOnly();
|
||||
|
||||
console.log(
|
||||
`[explore-album] loaded (local-only): "${this.albumName}"`,
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[explore-album] loading: "${this.albumName}" (${mbid})`,
|
||||
);
|
||||
|
||||
// Phase 0: hydrate from explore cache (instant).
|
||||
const cached = exploreCache.getAlbum(mbid);
|
||||
@@ -692,7 +684,6 @@ export class ExploreAlbumDetails extends LitElement {
|
||||
primaryType: 'Album',
|
||||
} as MBReleaseGroup;
|
||||
this.loadingInfo = false;
|
||||
console.log(`[explore-album] hydrated from cache: "${cached.title}"`);
|
||||
}
|
||||
|
||||
// Phase 1: hydrate tracklist from local library if available.
|
||||
@@ -712,9 +703,6 @@ export class ExploreAlbumDetails extends LitElement {
|
||||
// Fire cover art resolution immediately — doesn't wait for release group.
|
||||
this.resolveCoverArt();
|
||||
|
||||
console.log(
|
||||
`[explore-album] data requests fired: "${this.albumName}" (${mbid})`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -874,9 +862,6 @@ export class ExploreAlbumDetails extends LitElement {
|
||||
this.buildClusters();
|
||||
this.loadingReleases = false;
|
||||
|
||||
console.log(
|
||||
`[explore-album] hydrated ${localTracks.length} tracks from library`,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -908,9 +893,11 @@ export class ExploreAlbumDetails extends LitElement {
|
||||
this.releaseGroup = await LookupReleaseGroup(mbid);
|
||||
this.resolveCoverArt();
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
this.errorInfo = msg;
|
||||
console.error(`[explore-album] LookupReleaseGroup error: ${msg}`);
|
||||
console.error('[explore-album] LookupReleaseGroup error', err);
|
||||
this.errorInfo = describeError(
|
||||
err,
|
||||
'The catalog did not answer for this album.',
|
||||
);
|
||||
} finally {
|
||||
this.loadingInfo = false;
|
||||
}
|
||||
@@ -965,9 +952,11 @@ export class ExploreAlbumDetails extends LitElement {
|
||||
this.catalogPending = false;
|
||||
}
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
this.errorReleases = msg;
|
||||
console.error(`[explore-album] BrowseReleases error: ${msg}`);
|
||||
console.error('[explore-album] BrowseReleases error', err);
|
||||
this.errorReleases = describeError(
|
||||
err,
|
||||
'The catalog did not answer for this album\u2019s versions.',
|
||||
);
|
||||
this.loadingReleases = false;
|
||||
this.catalogPending = false;
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ import { libraryStore } from '../../store/library-store';
|
||||
import { downloadStore } from '../../store/download-store';
|
||||
import '@awesome.me/webawesome/dist/components/button/button.js';
|
||||
import { trackLink, exploreLinkStyles } from '../../utils/explore-link';
|
||||
import { describeError } from '../../utils/describe-error';
|
||||
import { GetAlbumsByArtist } from '@go/library/Library';
|
||||
import { EventsOn } from '@runtime/runtime';
|
||||
import { Events } from '../../events';
|
||||
@@ -1014,9 +1015,6 @@ export class ExploreArtistDetails extends LitElement {
|
||||
|
||||
// Local-only artist (no MBID) — populate from library store.
|
||||
if (!mbid && this.localArtistId) {
|
||||
console.log(
|
||||
`[explore-artist] loading local-only: "${this.artistName}" (id=${this.localArtistId})`,
|
||||
);
|
||||
|
||||
this.loadingArtist = false;
|
||||
this.loadingTracks = false;
|
||||
@@ -1027,16 +1025,10 @@ export class ExploreArtistDetails extends LitElement {
|
||||
|
||||
this.hydrateLocalOnly();
|
||||
|
||||
console.log(
|
||||
`[explore-artist] loaded (local-only): "${this.artistName}"`,
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[explore-artist] loading: "${this.artistName}" (${mbid})`,
|
||||
);
|
||||
|
||||
// Phase 0: hydrate from caches (instant, no Go calls).
|
||||
this.hydrateFromCache(mbid);
|
||||
@@ -1068,9 +1060,6 @@ export class ExploreArtistDetails extends LitElement {
|
||||
|
||||
// checkLibrary now runs from fetchReleaseGroups when data arrives.
|
||||
|
||||
console.log(
|
||||
`[explore-artist] data requests fired: "${this.artistName}" (${mbid})`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1290,9 +1279,11 @@ export class ExploreArtistDetails extends LitElement {
|
||||
try {
|
||||
this.artist = await LookupArtist(mbid);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
this.errorArtist = msg;
|
||||
console.error(`[explore-artist] LookupArtist error: ${msg}`);
|
||||
console.error('[explore-artist] LookupArtist error', err);
|
||||
this.errorArtist = describeError(
|
||||
err,
|
||||
'The catalog did not answer.',
|
||||
);
|
||||
} finally {
|
||||
this.loadingArtist = false;
|
||||
}
|
||||
@@ -1326,10 +1317,7 @@ export class ExploreArtistDetails extends LitElement {
|
||||
|
||||
void this.batchResolveTrackThumbnails(tracks, mapping);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
console.error(
|
||||
`[explore-artist] TopRecordingsForArtist error: ${msg}`,
|
||||
);
|
||||
console.error('[explore-artist] TopRecordingsForArtist error', err);
|
||||
} finally {
|
||||
// An empty first pass may mean a background discography fetch
|
||||
// is still in flight (the artist wasn't indexed yet). Keep
|
||||
@@ -1419,10 +1407,7 @@ export class ExploreArtistDetails extends LitElement {
|
||||
// are the most likely to be clicked from the artist page.
|
||||
this.prefetchReleases(rgs?.map((r) => r.releaseGroupMbid) ?? []);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
console.error(
|
||||
`[explore-artist] TopReleaseGroupsForArtist error: ${msg}`,
|
||||
);
|
||||
console.error('[explore-artist] TopReleaseGroupsForArtist error', err);
|
||||
this.topReleaseGroups = [];
|
||||
} finally {
|
||||
// See fetchTopTracks: hold the spinner while a background
|
||||
@@ -1462,12 +1447,12 @@ export class ExploreArtistDetails extends LitElement {
|
||||
// one from here is instant instead of a cold MB browse.
|
||||
this.prefetchReleases(rgs?.map((r) => r.mbid) ?? []);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
this.errorReleases = msg;
|
||||
this.catalogPending = false;
|
||||
console.error(
|
||||
`[explore-artist] BrowseReleaseGroups error: ${msg}`,
|
||||
console.error('[explore-artist] BrowseReleaseGroups error', err);
|
||||
this.errorReleases = describeError(
|
||||
err,
|
||||
'The catalog did not answer for this artist\u2019s albums.',
|
||||
);
|
||||
this.catalogPending = false;
|
||||
} finally {
|
||||
// BrowseReleaseGroups is index-first + async: an empty result on
|
||||
// a cold artist means the discography is still being fetched in
|
||||
@@ -1498,10 +1483,7 @@ export class ExploreArtistDetails extends LitElement {
|
||||
this.similarArtists = artists ?? [];
|
||||
} catch (err) {
|
||||
// D024: graceful degradation — silently omit similar artists on failure.
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
console.error(
|
||||
`[explore-artist] SimilarArtists error: ${msg}`,
|
||||
);
|
||||
console.error('[explore-artist] SimilarArtists error', err);
|
||||
this.similarArtists = [];
|
||||
} finally {
|
||||
// SimilarArtists is DB-first + async: an empty result on the
|
||||
|
||||
@@ -3,13 +3,17 @@ import { customElement, state, query as litQuery } from 'lit/decorators.js';
|
||||
import { designTokens } from '../../styles/tokens.css';
|
||||
import { SearchLocal, SearchLyrics, GetThumbnail, GetThumbnails, GetArtistImageURL, RecordSearchClick } from '@go/explore/Service';
|
||||
import { libraryStore } from '../../store/library-store';
|
||||
import { exploreCache } from '../../store/explore-cache';
|
||||
import { exploreCache, ARTIST_IMAGE_CACHE_LIMIT } from '../../store/explore-cache';
|
||||
import { queueStore } from '../../store/queue-store';
|
||||
import { artistLink, trackLink, exploreLinkStyles } from '../../utils/explore-link';
|
||||
import { describeError } from '../../utils/describe-error';
|
||||
import '@awesome.me/webawesome/dist/components/icon/icon.js';
|
||||
import '../library-status-indicator/library-status-indicator.js';
|
||||
import '../top-results-row/top-results-row.js';
|
||||
import { explore } from '@go/models';
|
||||
import { ViewLifecycleMixin } from '../../utils/view-lifecycle';
|
||||
import { registerCacheProbe } from '../../utils/cache-stats';
|
||||
import { LRUMap } from '../../utils/lru-map';
|
||||
type ThumbnailRequest = explore.ThumbnailRequest;
|
||||
type MBSearchResult = explore.MBSearchResult;
|
||||
type LyricsResult = explore.LyricsResult;
|
||||
@@ -25,6 +29,32 @@ const SEARCH_DEBOUNCE_MS = 180;
|
||||
|
||||
const MAX_SECTION_RESULTS = 10;
|
||||
|
||||
/*
|
||||
* Caps for the two art caches (`perf.M7`).
|
||||
*
|
||||
* Both were unbounded, on a view that never unmounts: twelve searches
|
||||
* retained 8.48 MB and were still climbing 0.7 MB per search. The caps
|
||||
* come from the measured cost of an entry — a cover thumbnail is ~27 kB
|
||||
* of base64, an artist photo ~128 kB — so each is set to hold roughly a
|
||||
* dozen searches' worth and then stop:
|
||||
*
|
||||
* thumbnails 96 × ~27 kB ≈ 2.6 MB ceiling
|
||||
* artist images 32 × ~128 kB ≈ 4.1 MB ceiling
|
||||
*
|
||||
* Both are comfortably larger than one screen of results, which matters:
|
||||
* a cap below the visible count would evict art that is still rendered,
|
||||
* and the re-render would fetch it again immediately. A search shows at
|
||||
* most `MAX_SECTION_RESULTS` artists and ~15 release groups, so these
|
||||
* are ~3× and ~6× a screenful — six searches of history for covers,
|
||||
* which is far more than the "go back to the previous search" the cache
|
||||
* exists to serve.
|
||||
*
|
||||
* `ARTIST_IMAGE_CACHE_LIMIT` is shared with `explore-cache.ts`, which
|
||||
* holds the *same* data URLs for the detail pages. Bounding one and
|
||||
* not the other frees nothing.
|
||||
*/
|
||||
const THUMBNAIL_CACHE_LIMIT = 96;
|
||||
|
||||
|
||||
/** Hash a string to a hue value 0–360 for avatar coloring. */
|
||||
function nameToHue(name: string): number {
|
||||
@@ -79,7 +109,7 @@ function getArtistAlbumArt(artistName: string): string {
|
||||
}
|
||||
|
||||
@customElement('explore-view')
|
||||
export class ExploreView extends LitElement {
|
||||
export class ExploreView extends ViewLifecycleMixin(LitElement) {
|
||||
/* ── State ── */
|
||||
|
||||
@state() private searchQuery = '';
|
||||
@@ -96,10 +126,29 @@ export class ExploreView extends LitElement {
|
||||
private searchVersion = 0;
|
||||
/** Debounce timer for live search-as-you-type. */
|
||||
private searchDebounceTimer?: ReturnType<typeof setTimeout>;
|
||||
private thumbnailCache = new Map<string, string>();
|
||||
private artistImageCache = new Map<string, string>();
|
||||
private thumbnailCache = new LRUMap<string, string>(THUMBNAIL_CACHE_LIMIT);
|
||||
private artistImageCache = new LRUMap<string, string>(ARTIST_IMAGE_CACHE_LIMIT);
|
||||
private libraryMBIDs = new Set<string>();
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
// Registered from the constructor rather than on connect: this is
|
||||
// a cached primary view, so it connects once, and a measurement
|
||||
// must be able to read the caches whether or not Explore is the
|
||||
// view currently on screen.
|
||||
const stat = (m: LRUMap<string, string>) => () => {
|
||||
let chars = 0;
|
||||
|
||||
for (const v of m.values()) chars += v.length;
|
||||
|
||||
return { entries: m.size, chars, limit: m.limit };
|
||||
};
|
||||
|
||||
registerCacheProbe('explore.thumbnails', stat(this.thumbnailCache));
|
||||
registerCacheProbe('explore.artistImages', stat(this.artistImageCache));
|
||||
}
|
||||
|
||||
@litQuery('input') private inputEl!: HTMLInputElement;
|
||||
|
||||
/* ── Styles ── */
|
||||
@@ -592,9 +641,13 @@ export class ExploreView extends LitElement {
|
||||
|
||||
override disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
if (this.searchDebounceTimer) {
|
||||
clearTimeout(this.searchDebounceTimer);
|
||||
}
|
||||
this.cancelPendingSearch();
|
||||
}
|
||||
|
||||
/** A debounced search that lands after the user has left the page is
|
||||
* a query nobody asked for, against a 1.1 M-row index. */
|
||||
protected override onViewDeactivate(): void {
|
||||
this.cancelPendingSearch();
|
||||
}
|
||||
|
||||
/* ── Search Logic ── */
|
||||
@@ -707,19 +760,16 @@ export class ExploreView extends LitElement {
|
||||
this.loading = true;
|
||||
this.error = '';
|
||||
|
||||
const startTime = performance.now();
|
||||
console.log(`[explore] search started: "${query}" (${this.searchMode})`);
|
||||
|
||||
// Lyrics mode: a single FTS lyric search over the library.
|
||||
if (this.searchMode === 'lyrics') {
|
||||
void this.executeLyricsSearch(version, query, startTime);
|
||||
void this.executeLyricsSearch(version, query);
|
||||
return;
|
||||
}
|
||||
|
||||
// Offline search over the local popularity index via Wails RPC.
|
||||
// No network, and no owned-library seed — the index is the sole
|
||||
// source of catalog results.
|
||||
void this.executeIndexSearch(version, query, startTime);
|
||||
void this.executeIndexSearch(version, query);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -775,7 +825,7 @@ export class ExploreView extends LitElement {
|
||||
return result;
|
||||
}
|
||||
|
||||
private async executeIndexSearch(version: number, query: string, startTime: number) {
|
||||
private async executeIndexSearch(version: number, query: string) {
|
||||
try {
|
||||
// Local FTS index only — no network. Returns null when the
|
||||
// index has no hits, in which case we keep the owned-library
|
||||
@@ -809,19 +859,16 @@ export class ExploreView extends LitElement {
|
||||
this.loadThumbnails();
|
||||
this.loadArtistImages();
|
||||
this.checkLibrary();
|
||||
|
||||
const elapsed = (performance.now() - startTime).toFixed(0);
|
||||
console.log(
|
||||
`[explore] search completed: "${query}" in ${elapsed}ms — ` +
|
||||
`artists=${result.artists?.length ?? 0}, ` +
|
||||
`albums=${result.releaseGroups?.length ?? 0}, ` +
|
||||
`tracks=${result.recordings?.length ?? 0}`,
|
||||
);
|
||||
} catch (err) {
|
||||
if (version !== this.searchVersion) return;
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
this.error = message;
|
||||
console.error(`[explore] search error: "${query}" — ${message}`);
|
||||
|
||||
console.error(`[explore] search error: "${query}"`, err);
|
||||
// Inline: this failure belongs to the results panel, and
|
||||
// the panel is what the user is looking at (errors.M9).
|
||||
this.error = describeError(
|
||||
err,
|
||||
'The catalog search did not answer.',
|
||||
);
|
||||
} finally {
|
||||
if (version === this.searchVersion) {
|
||||
this.loading = false;
|
||||
@@ -831,22 +878,20 @@ export class ExploreView extends LitElement {
|
||||
|
||||
/* ── Lyrics Search ── */
|
||||
|
||||
private async executeLyricsSearch(version: number, query: string, startTime: number) {
|
||||
private async executeLyricsSearch(version: number, query: string) {
|
||||
try {
|
||||
const hits = await SearchLyrics(query);
|
||||
if (version !== this.searchVersion) return;
|
||||
|
||||
this.lyricsResults = hits ?? [];
|
||||
|
||||
const elapsed = (performance.now() - startTime).toFixed(0);
|
||||
console.log(
|
||||
`[explore] lyrics search: "${query}" in ${elapsed}ms — ` +
|
||||
`hits=${this.lyricsResults.length}`,
|
||||
);
|
||||
} catch (err) {
|
||||
if (version !== this.searchVersion) return;
|
||||
this.error = err instanceof Error ? err.message : String(err);
|
||||
console.error(`[explore] lyrics search error: "${query}" — ${this.error}`);
|
||||
|
||||
console.error(`[explore] lyrics search error: "${query}"`, err);
|
||||
this.error = describeError(
|
||||
err,
|
||||
'The lyric search did not answer.',
|
||||
);
|
||||
} finally {
|
||||
if (version === this.searchVersion) {
|
||||
this.loading = false;
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
GetAllLibrariesWithTrackCounts,
|
||||
} from '@go/library/Library';
|
||||
import { DirectoryPicker } from '@go/frontendutil/FrontendUtil';
|
||||
import { describeError, explainError } from '@utils/describe-error';
|
||||
|
||||
/**
|
||||
* First-run setup wizard.
|
||||
@@ -236,7 +237,10 @@ export class FirstRunWizard extends LitElement {
|
||||
|
||||
if (dir) this.selectedDirectory = dir;
|
||||
} catch (err) {
|
||||
this.errorMessage = `Could not open folder picker: ${err}`;
|
||||
this.errorMessage = describeError(
|
||||
err,
|
||||
'The folder picker could not be opened.',
|
||||
);
|
||||
console.error('First-run wizard: directory picker failed:', err);
|
||||
}
|
||||
};
|
||||
@@ -256,7 +260,10 @@ export class FirstRunWizard extends LitElement {
|
||||
|
||||
this.active = false;
|
||||
} catch (err) {
|
||||
this.errorMessage = `Could not add the folder: ${err}`;
|
||||
this.errorMessage = explainError(
|
||||
err,
|
||||
'That folder could not be added.',
|
||||
);
|
||||
console.error('First-run wizard: add library failed:', err);
|
||||
} finally {
|
||||
this.saving = false;
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
import { EventsOn } from '@runtime/runtime';
|
||||
import { Events } from '../../events';
|
||||
import { libraryStore } from '@store/library-store';
|
||||
import { describeError } from '@utils/describe-error';
|
||||
import '@awesome.me/webawesome/dist/components/icon/icon.js';
|
||||
import '@components/track-list/track-list.js';
|
||||
import { designTokens } from '../../styles/tokens.css';
|
||||
@@ -27,6 +28,12 @@ export class GenreDetails extends LitElement {
|
||||
@state()
|
||||
private loading = true;
|
||||
|
||||
/** A failed genre query used to be handed to `<track-list>` as an
|
||||
* empty array, so it was indistinguishable from a slow one
|
||||
* (errors.M2). */
|
||||
@state()
|
||||
private loadError = '';
|
||||
|
||||
private scanCompleteCleanup: (() => void) | null =
|
||||
null;
|
||||
|
||||
@@ -38,6 +45,21 @@ export class GenreDetails extends LitElement {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.load-error {
|
||||
color: var(--yj-text-secondary, #b3b3b3);
|
||||
padding: 1em;
|
||||
}
|
||||
|
||||
.load-error button {
|
||||
background: none;
|
||||
border: 1px solid var(--yj-border, #495057);
|
||||
border-radius: 4px;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
padding: 4px 10px;
|
||||
}
|
||||
|
||||
/* ====================================
|
||||
* Header
|
||||
* ==================================== */
|
||||
@@ -179,6 +201,8 @@ export class GenreDetails extends LitElement {
|
||||
private async loadTracks() {
|
||||
if (!this.genreName) return;
|
||||
|
||||
this.loadError = '';
|
||||
|
||||
try {
|
||||
const libId =
|
||||
libraryStore.getSelectedLibraryId();
|
||||
@@ -192,11 +216,12 @@ export class GenreDetails extends LitElement {
|
||||
this.genreName,
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
'Error loading genre tracks:',
|
||||
error,
|
||||
);
|
||||
console.error('Error loading genre tracks:', error);
|
||||
this.tracks = [];
|
||||
this.loadError = describeError(
|
||||
error,
|
||||
`The tracks for “${this.genreName}” could not be loaded.`,
|
||||
);
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
@@ -274,9 +299,19 @@ export class GenreDetails extends LitElement {
|
||||
</div>
|
||||
</div>
|
||||
<div class="content">
|
||||
<track-list
|
||||
.externalTracks=${this.tracks}
|
||||
></track-list>
|
||||
${this.loadError
|
||||
? html`<div class="load-error" data-testid="genre-error">
|
||||
<p>${this.loadError}</p>
|
||||
<button
|
||||
type="button"
|
||||
@click=${() => void this.loadTracks()}
|
||||
>
|
||||
Try again
|
||||
</button>
|
||||
</div>`
|
||||
: html`<track-list
|
||||
.externalTracks=${this.tracks}
|
||||
></track-list>`}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import { libraryStore } from '@store/library-store';
|
||||
import { EventsOn } from '@runtime/runtime';
|
||||
import { Events } from '../../events';
|
||||
import { designTokens } from '../../styles/tokens.css';
|
||||
import { ViewLifecycleMixin } from '../../utils/view-lifecycle';
|
||||
|
||||
type Shelf = home.Shelf;
|
||||
|
||||
@@ -36,7 +37,7 @@ const KIND_ICONS: Record<string, string> = {
|
||||
* cover do the two things a cover should: open the album, or play it.
|
||||
*/
|
||||
@customElement('home-view')
|
||||
export class HomeView extends LitElement {
|
||||
export class HomeView extends ViewLifecycleMixin(LitElement) {
|
||||
@state() private shelves: Shelf[] = [];
|
||||
|
||||
@state() private loading = true;
|
||||
@@ -194,8 +195,10 @@ export class HomeView extends LitElement {
|
||||
`,
|
||||
];
|
||||
|
||||
override connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
protected override onViewActivate(): void {
|
||||
// Reloaded on arrival rather than kept live: the shelves are a
|
||||
// judgement about the whole library, so the answer while the
|
||||
// page is off screen is of no interest to anyone.
|
||||
void this.load();
|
||||
|
||||
// A finished scan changes what every shelf would say, and the
|
||||
@@ -204,12 +207,10 @@ export class HomeView extends LitElement {
|
||||
this.unsubScan = EventsOn(Events.LibraryScanComplete, () => {
|
||||
void this.load();
|
||||
});
|
||||
}
|
||||
|
||||
override disconnectedCallback(): void {
|
||||
super.disconnectedCallback();
|
||||
this.unsubScan?.();
|
||||
this.unsubScan = undefined;
|
||||
this.whileActive(() => {
|
||||
this.unsubScan?.();
|
||||
this.unsubScan = undefined;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -14,6 +14,8 @@ import {
|
||||
import { Events } from '../../events';
|
||||
import type { playlist } from '@go/models';
|
||||
import '@components/duplicate-tracks-dialog/duplicate-tracks-dialog.js';
|
||||
import { notificationStore } from '@store/notification-store';
|
||||
import { describeError } from '@utils/describe-error';
|
||||
import type { DuplicateTracksDialog } from '@components/duplicate-tracks-dialog/duplicate-tracks-dialog.js';
|
||||
|
||||
/**
|
||||
@@ -188,6 +190,14 @@ export class PlaylistPicker extends LitElement {
|
||||
this.dispatchComplete();
|
||||
} catch (err) {
|
||||
console.error('Failed to add tracks to playlist:', err);
|
||||
// Transient: the picker closes either way, and the tracks
|
||||
// simply are not there — nothing to undo, only to retry
|
||||
// (errors.m7).
|
||||
notificationStore.transient({
|
||||
key: 'playlist-add',
|
||||
text: `Could not add ${this.filePaths.length === 1 ? 'that track' : `those ${this.filePaths.length} tracks`} to the playlist. ${describeError(err)}`,
|
||||
detail: String(err),
|
||||
});
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
@@ -223,6 +233,11 @@ export class PlaylistPicker extends LitElement {
|
||||
this.dispatchComplete();
|
||||
} catch (err) {
|
||||
console.error('Failed to create playlist:', err);
|
||||
notificationStore.transient({
|
||||
key: 'playlist-create',
|
||||
text: `Could not create “${name}”. ${describeError(err)}`,
|
||||
detail: String(err),
|
||||
});
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -3,6 +3,7 @@ import { customElement, property, state } from 'lit/decorators.js';
|
||||
import { library } from '@go/models';
|
||||
import { PreviewSmartPlaylist } from '@go/playlist/Service';
|
||||
import { libraryStore } from '@store/library-store';
|
||||
import { describeError } from '@utils/describe-error';
|
||||
import { designTokens } from '../../styles/tokens.css';
|
||||
import '@components/combobox/combobox.ts';
|
||||
|
||||
@@ -675,6 +676,14 @@ export class SmartPlaylistEditor extends LitElement {
|
||||
}, 300);
|
||||
}
|
||||
|
||||
/**
|
||||
* Monotonic request version. Debouncing only coalesces keystrokes
|
||||
* *within* its window; a query slower than 300 ms overlaps the next
|
||||
* one and whichever answers last used to win (errors.M8). This is
|
||||
* `explore-view`'s guard, checked on all three paths.
|
||||
*/
|
||||
private previewVersion = 0;
|
||||
|
||||
private async runPreview() {
|
||||
// Skip preview if any rule is incomplete
|
||||
const incomplete = this.ruleRows.some(
|
||||
@@ -690,19 +699,30 @@ export class SmartPlaylistEditor extends LitElement {
|
||||
}
|
||||
|
||||
const json = this.buildRulesJSON();
|
||||
const version = ++this.previewVersion;
|
||||
|
||||
this.previewLoading = true;
|
||||
this.previewError = '';
|
||||
|
||||
try {
|
||||
const tracks = await PreviewSmartPlaylist(json);
|
||||
|
||||
if (version !== this.previewVersion) return;
|
||||
|
||||
this.previewTracks = tracks ?? [];
|
||||
} catch (error) {
|
||||
if (version !== this.previewVersion) return;
|
||||
|
||||
console.error('Smart playlist preview failed:', error);
|
||||
this.previewError =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
this.previewError = describeError(
|
||||
error,
|
||||
'The preview could not be built for these rules.',
|
||||
);
|
||||
this.previewTracks = [];
|
||||
} finally {
|
||||
this.previewLoading = false;
|
||||
if (version === this.previewVersion) {
|
||||
this.previewLoading = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ import { GetTrackMBIDs } from '@go/library/Library';
|
||||
type TrackMBIDs = library.TrackMBIDs;
|
||||
import { ImageFilePicker, ReadFile } from '@go/frontendutil/FrontendUtil';
|
||||
import { libraryStore } from '../../store/library-store';
|
||||
import { EventsOn, EventsOff } from '@runtime/runtime';
|
||||
import { EventsOn } from '@runtime/runtime';
|
||||
import { Events } from '../../events';
|
||||
|
||||
import '@awesome.me/webawesome/dist/components/dialog/dialog.js';
|
||||
@@ -1714,8 +1714,10 @@ export class TrackDetails extends LitElement {
|
||||
|
||||
const changes = this.buildBatchChanges();
|
||||
|
||||
// Listen for progress events.
|
||||
EventsOn(
|
||||
// Listen for progress events. Kept as the unsubscribe function
|
||||
// EventsOn returns, rather than EventsOff(name), which removes
|
||||
// *every* listener for that event (errors.p3).
|
||||
const stopProgress = EventsOn(
|
||||
Events.BatchWriteProgress,
|
||||
(data: {
|
||||
current: number;
|
||||
@@ -1762,7 +1764,7 @@ export class TrackDetails extends LitElement {
|
||||
],
|
||||
};
|
||||
} finally {
|
||||
EventsOff(Events.BatchWriteProgress);
|
||||
stopProgress();
|
||||
this.batchProgress = null;
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user