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 { Events } from '../../events';
|
||||||
import { inlineDiff, normalizeStrict, isCosmeticDiff } from '../../utils/text-diff';
|
import { inlineDiff, normalizeStrict, isCosmeticDiff } from '../../utils/text-diff';
|
||||||
import { libraryStore } from '../../store/library-store';
|
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 PendingItem = autotagservice.PendingItem;
|
||||||
type ScoreView = autotagservice.ScoreView;
|
type ScoreView = autotagservice.ScoreView;
|
||||||
@@ -97,7 +100,13 @@ interface LengthDiffDetail {
|
|||||||
* leave · U paste URL · ↑↓ navigate folders · Esc close dialogs.
|
* leave · U paste URL · ↑↓ navigate folders · Esc close dialogs.
|
||||||
*/
|
*/
|
||||||
@customElement('autotag-view')
|
@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 = [
|
static override readonly styles = [
|
||||||
designTokens,
|
designTokens,
|
||||||
css`
|
css`
|
||||||
@@ -1179,14 +1188,38 @@ export class AutotagView extends LitElement {
|
|||||||
// render the default gray question-mark icon.
|
// render the default gray question-mark icon.
|
||||||
@state() private applyJobs: Map<string, ApplyJobState> = new Map();
|
@state() private applyJobs: Map<string, ApplyJobState> = new Map();
|
||||||
|
|
||||||
|
private queueStarted = false;
|
||||||
private unsubscribeLibraryStore?: () => void;
|
private unsubscribeLibraryStore?: () => void;
|
||||||
private unsubscribeApplyEvents: Array<() => void> = [];
|
private unsubscribeApplyEvents: Array<() => void> = [];
|
||||||
private currentLibraryFilter: number | null = null;
|
private currentLibraryFilter: number | null = null;
|
||||||
|
|
||||||
override connectedCallback(): void {
|
/** Everything here is torn down when the view leaves the screen, not
|
||||||
super.connectedCallback();
|
* when it is disconnected — which never happens, because the view
|
||||||
document.addEventListener('keydown', this.onKeydown);
|
* is cached (see utils/view-lifecycle.ts). */
|
||||||
document.addEventListener('mousedown', this.onDocumentClickForMenu);
|
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
|
// Track the global library filter so the autotag queue
|
||||||
// shows only folders from the currently-selected library.
|
// shows only folders from the currently-selected library.
|
||||||
// null means "all libraries", which the backend treats as
|
// 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
|
// Listen for per-job progress events so the sidebar can
|
||||||
// render running/completed/failed indicators. EventsOn
|
// 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(
|
this.unsubscribeApplyEvents.push(
|
||||||
EventsOn(Events.AutotagApplyStarted, (data: { groupKey: string; total: number }) => {
|
EventsOn(Events.AutotagApplyStarted, (data: { groupKey: string; total: number }) => {
|
||||||
this.onApplyStarted(data);
|
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 {
|
protected override onViewDeactivate(): void {
|
||||||
super.disconnectedCallback();
|
|
||||||
document.removeEventListener('keydown', this.onKeydown);
|
|
||||||
document.removeEventListener('mousedown', this.onDocumentClickForMenu);
|
|
||||||
this.unsubscribeLibraryStore?.();
|
this.unsubscribeLibraryStore?.();
|
||||||
|
this.unsubscribeLibraryStore = undefined;
|
||||||
for (const fn of this.unsubscribeApplyEvents) fn();
|
for (const fn of this.unsubscribeApplyEvents) fn();
|
||||||
this.unsubscribeApplyEvents = [];
|
this.unsubscribeApplyEvents = [];
|
||||||
if (this.prefetchRefreshTimer !== undefined) {
|
if (this.prefetchRefreshTimer !== undefined) {
|
||||||
@@ -1279,17 +1319,44 @@ export class AutotagView extends LitElement {
|
|||||||
private async onApplyFinished({ groupKey, succeeded, failed, error }:
|
private async onApplyFinished({ groupKey, succeeded, failed, error }:
|
||||||
{ groupKey: string; succeeded: number; failed: number; error: string }): Promise<void> {
|
{ groupKey: string; succeeded: number; failed: number; error: string }): Promise<void> {
|
||||||
const allFailed = error !== '' || (succeeded === 0 && failed > 0);
|
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) {
|
if (allFailed) {
|
||||||
this.updateApplyJob(groupKey, {
|
this.updateApplyJob(groupKey, {
|
||||||
state: 'failed',
|
state: 'failed',
|
||||||
error: error || `${failed} of ${failed} tracks failed`,
|
error: error || `${failed} of ${failed} tracks failed`,
|
||||||
});
|
});
|
||||||
// Toast the error so the user sees it.
|
// Nothing was written, so nothing is inconsistent: this is
|
||||||
this.errorMessage = error
|
// something to retry, not something to interrupt for.
|
||||||
? `Apply failed: ${error}`
|
notificationStore.persistent({
|
||||||
: `Apply failed: ${failed} of ${failed} tracks could not be written.`;
|
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.
|
// Refresh so the row picks up any DB state change.
|
||||||
await this.loadFolders();
|
await this.loadFolders();
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1327,16 +1394,44 @@ export class AutotagView extends LitElement {
|
|||||||
private onWarningCancel = () => { this.dialog = 'none'; };
|
private onWarningCancel = () => { this.dialog = 'none'; };
|
||||||
private onWarningContinue = async () => {
|
private onWarningContinue = async () => {
|
||||||
if (!this.current) return;
|
if (!this.current) return;
|
||||||
|
|
||||||
this.markWarningAcked(this.current.libraryId);
|
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();
|
await this.executeApply();
|
||||||
};
|
};
|
||||||
private onLeaveCancel = () => { this.dialog = 'none'; };
|
private onLeaveCancel = () => { this.dialog = 'none'; };
|
||||||
private onLeaveConfirm = async () => {
|
private onLeaveConfirm = async () => {
|
||||||
if (!this.current) return;
|
if (!this.current) return;
|
||||||
|
|
||||||
this.dialog = 'none';
|
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();
|
await this.refreshAfterAction();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1364,7 +1459,11 @@ export class AutotagView extends LitElement {
|
|||||||
this.searchKind, query, this.searchArtist.trim(),
|
this.searchKind, query, this.searchArtist.trim(),
|
||||||
);
|
);
|
||||||
} catch (err) {
|
} 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 = [];
|
this.searchResults = [];
|
||||||
} finally {
|
} finally {
|
||||||
this.searchLoading = false;
|
this.searchLoading = false;
|
||||||
@@ -1380,7 +1479,11 @@ export class AutotagView extends LitElement {
|
|||||||
this.score = await SelectSearchCandidate(groupKey, hit.kind, hit.mbid);
|
this.score = await SelectSearchCandidate(groupKey, hit.kind, hit.mbid);
|
||||||
this.selectedCandidateIdx = 0;
|
this.selectedCandidateIdx = 0;
|
||||||
} catch (err) {
|
} 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 {
|
} finally {
|
||||||
this.loading = false;
|
this.loading = false;
|
||||||
}
|
}
|
||||||
@@ -1399,7 +1502,11 @@ export class AutotagView extends LitElement {
|
|||||||
await this.loadFolders();
|
await this.loadFolders();
|
||||||
await this.reconcileSelection();
|
await this.reconcileSelection();
|
||||||
} catch (err) {
|
} 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());
|
const list = await ListPendingFolders(this.libraryFilterID());
|
||||||
this.folders = list ?? [];
|
this.folders = list ?? [];
|
||||||
} catch (e) {
|
} 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);
|
await this.loadCandidates(this.current.groupKey);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} 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;
|
this.score = result;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (this.current?.groupKey !== groupKey) return;
|
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 {
|
} finally {
|
||||||
this.loading = false;
|
this.loading = false;
|
||||||
}
|
}
|
||||||
@@ -1647,8 +1766,14 @@ export class AutotagView extends LitElement {
|
|||||||
// from the UI, but defensive); other errors clear the
|
// from the UI, but defensive); other errors clear the
|
||||||
// running state and surface a toast.
|
// running state and surface a toast.
|
||||||
if (!msg.includes('apply already in flight')) {
|
if (!msg.includes('apply already in flight')) {
|
||||||
|
console.error('autotag: apply failed to start', e);
|
||||||
this.updateApplyJob(groupKey, { state: 'failed', error: msg });
|
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> {
|
private async onSkip(): Promise<void> {
|
||||||
if (!this.current) return;
|
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();
|
await this.refreshAfterAction();
|
||||||
}
|
}
|
||||||
|
|
||||||
private async onLeave(): Promise<void> {
|
private async onLeave(): Promise<void> {
|
||||||
if (!this.current) return;
|
if (!this.current) return;
|
||||||
|
|
||||||
if (this.topScore() < CONFIDENT_SCORE) {
|
if (this.topScore() < CONFIDENT_SCORE) {
|
||||||
this.dialog = 'leave';
|
this.dialog = 'leave';
|
||||||
|
|
||||||
return;
|
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();
|
await this.refreshAfterAction();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1684,7 +1835,11 @@ export class AutotagView extends LitElement {
|
|||||||
this.dialog = 'none';
|
this.dialog = 'none';
|
||||||
this.pasteURL = '';
|
this.pasteURL = '';
|
||||||
} catch (e) {
|
} 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 {
|
} finally {
|
||||||
this.loading = false;
|
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 => {
|
private onKeydown = (e: KeyboardEvent): void => {
|
||||||
const target = e.target as HTMLElement;
|
if (e.key !== 'Escape' || this.dialog === 'none') return;
|
||||||
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') {
|
e.preventDefault();
|
||||||
if (this.dialog !== 'none') {
|
this.dialog = 'none';
|
||||||
e.preventDefault();
|
this.pasteURL = '';
|
||||||
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;
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/* ── Version clustering ── */
|
/* ── Version clustering ── */
|
||||||
|
|||||||
@@ -14,7 +14,10 @@ import {
|
|||||||
GetScanConcurrency,
|
GetScanConcurrency,
|
||||||
SetScanConcurrency,
|
SetScanConcurrency,
|
||||||
} from '@go/config/Config';
|
} from '@go/config/Config';
|
||||||
|
import { GetIndexStatus } from '@go/explore/Service';
|
||||||
import { DirectoryPicker } from '@go/frontendutil/FrontendUtil';
|
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 type { library } from '@go/models';
|
||||||
import { ThemeController } from '@store/controllers/theme-controller';
|
import { ThemeController } from '@store/controllers/theme-controller';
|
||||||
import { TrackListController } from '@store/controllers/tracklist-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 { GetAllPlaylists } from '@go/playlist/Service';
|
||||||
import type { playlist } from '@go/models';
|
import type { playlist } from '@go/models';
|
||||||
import { Events } from '../../events';
|
import { Events } from '../../events';
|
||||||
|
import { ViewLifecycleMixin } from '../../utils/view-lifecycle';
|
||||||
import type { ConfigFieldChangeEvent } from './config-field';
|
import type { ConfigFieldChangeEvent } from './config-field';
|
||||||
import type { BackgroundShade } from '@store/theme-store';
|
import type { BackgroundShade } from '@store/theme-store';
|
||||||
import type { IconStyle } from '@store/favorites-store';
|
import type { IconStyle } from '@store/favorites-store';
|
||||||
@@ -45,7 +49,7 @@ const SCROLL_CHANGE_EVENT = 'yj-scroll-mode-changed';
|
|||||||
// ===================================================================
|
// ===================================================================
|
||||||
|
|
||||||
@customElement('config-page')
|
@customElement('config-page')
|
||||||
export class ConfigPage extends LitElement {
|
export class ConfigPage extends ViewLifecycleMixin(LitElement) {
|
||||||
// --- Theme controller for reading/writing theme state ---
|
// --- Theme controller for reading/writing theme state ---
|
||||||
private themeCtrl = new ThemeController(this);
|
private themeCtrl = new ThemeController(this);
|
||||||
|
|
||||||
@@ -164,6 +168,48 @@ export class ConfigPage extends LitElement {
|
|||||||
scope: 'panel:track-list',
|
scope: 'panel:track-list',
|
||||||
defaultKey: 'Delete',
|
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 ---
|
// --- Now Playing state ---
|
||||||
@@ -179,20 +225,20 @@ export class ConfigPage extends LitElement {
|
|||||||
@state() private removingLibraryId: number | null = null;
|
@state() private removingLibraryId: number | null = null;
|
||||||
@state() private removalImpact: library.RemovalImpact | null = null;
|
@state() private removalImpact: library.RemovalImpact | null = null;
|
||||||
@state() private isRemoving = false;
|
@state() private isRemoving = false;
|
||||||
@state() private toastMessage = '';
|
|
||||||
@state() private toastVisible = false;
|
|
||||||
@state() private activeMenuId: number | null = null;
|
@state() private activeMenuId: number | null = null;
|
||||||
@state() private concurrencyMode = 'auto';
|
@state() private concurrencyMode = 'auto';
|
||||||
@state() private indexStatus: explore.IndexStatus | null = null;
|
@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: {
|
@state() private shortcutConflict: {
|
||||||
newAction: string;
|
newAction: string;
|
||||||
newKey: string;
|
newKey: string;
|
||||||
existingAction: string;
|
existingAction: string;
|
||||||
} | null = null;
|
} | null = null;
|
||||||
|
|
||||||
private toastTimer?: ReturnType<typeof setTimeout>;
|
|
||||||
private cancelIndexStatus?: () => void;
|
private cancelIndexStatus?: () => void;
|
||||||
private indexPollTimer?: ReturnType<typeof setInterval>;
|
|
||||||
private cancelLibraryAdded?: () => void;
|
private cancelLibraryAdded?: () => void;
|
||||||
private cancelLibraryRenamed?: () => void;
|
private cancelLibraryRenamed?: () => void;
|
||||||
private cancelLibraryRemoved?: () => 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 {
|
.spinner {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
width: 14px;
|
width: 14px;
|
||||||
@@ -982,14 +999,31 @@ export class ConfigPage extends LitElement {
|
|||||||
color: var(--yj-text-tertiary, #888);
|
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
|
// LIFECYCLE
|
||||||
// ===================================================================
|
// ===================================================================
|
||||||
|
|
||||||
override connectedCallback(): void {
|
protected override onViewActivate(): void {
|
||||||
super.connectedCallback();
|
|
||||||
void this.loadLibraries();
|
void this.loadLibraries();
|
||||||
void this.loadPlaylists();
|
void this.loadPlaylists();
|
||||||
this.scrollMode =
|
this.scrollMode =
|
||||||
@@ -1010,28 +1044,27 @@ export class ConfigPage extends LitElement {
|
|||||||
() => void this.loadLibraries(),
|
() => 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).
|
// Listen for index status events (pushed from Go, no binding calls).
|
||||||
this.cancelIndexStatus = EventsOn(
|
this.cancelIndexStatus = EventsOn(
|
||||||
Events.IndexStatusChanged,
|
Events.IndexStatusChanged,
|
||||||
(status: explore.IndexStatus) => {
|
(status: explore.IndexStatus) => {
|
||||||
console.log('IndexStatusChanged event received', status);
|
|
||||||
this.indexStatus = 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 {
|
protected override onViewDeactivate(): void {
|
||||||
super.disconnectedCallback();
|
|
||||||
this.cancelLibraryAdded?.();
|
this.cancelLibraryAdded?.();
|
||||||
this.cancelLibraryRenamed?.();
|
this.cancelLibraryRenamed?.();
|
||||||
this.cancelLibraryRemoved?.();
|
this.cancelLibraryRemoved?.();
|
||||||
|
|
||||||
document.removeEventListener('click', this.handleDocumentClick);
|
|
||||||
|
|
||||||
if (this.toastTimer) clearTimeout(this.toastTimer);
|
|
||||||
if (this.indexPollTimer) clearInterval(this.indexPollTimer);
|
|
||||||
this.cancelIndexStatus?.();
|
this.cancelIndexStatus?.();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1058,17 +1091,29 @@ export class ConfigPage extends LitElement {
|
|||||||
// ===================================================================
|
// ===================================================================
|
||||||
|
|
||||||
private handleAddLibrary = async (): Promise<void> => {
|
private handleAddLibrary = async (): Promise<void> => {
|
||||||
try {
|
let dir = '';
|
||||||
const dir = await DirectoryPicker();
|
|
||||||
|
|
||||||
if (dir) {
|
try {
|
||||||
await AddLibrary(dir);
|
dir = await DirectoryPicker();
|
||||||
}
|
|
||||||
|
if (!dir) return;
|
||||||
|
|
||||||
|
await AddLibrary(dir);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(
|
console.error('Failed to add library:', err);
|
||||||
'Failed to add library:',
|
notificationStore.persistent({
|
||||||
err,
|
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();
|
e.preventDefault();
|
||||||
|
|
||||||
if (this.editingLibraryId !== null && this.editingName.trim()) {
|
if (this.editingLibraryId !== null && this.editingName.trim()) {
|
||||||
|
const name = this.editingName.trim();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await RenameLibrary(this.editingLibraryId, this.editingName.trim());
|
await RenameLibrary(this.editingLibraryId, name);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to rename library:', 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.removingLibraryId = null;
|
||||||
this.removalImpact = null;
|
this.removalImpact = null;
|
||||||
this.isRemoving = false;
|
this.isRemoving = false;
|
||||||
this.showToast(
|
notificationStore.transient({
|
||||||
`Removed '${libName}' (${summary?.tracksDeleted ?? 0} tracks deleted)`,
|
tone: 'success',
|
||||||
);
|
key: 'library-remove',
|
||||||
|
text: `Removed “${libName}” — ${summary?.tracksDeleted ?? 0} tracks deleted.`,
|
||||||
|
});
|
||||||
void this.loadLibraries();
|
void this.loadLibraries();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.isRemoving = false;
|
this.isRemoving = false;
|
||||||
this.removingLibraryId = null;
|
this.removingLibraryId = null;
|
||||||
this.removalImpact = null;
|
this.removalImpact = null;
|
||||||
console.error('Failed to remove library:', err);
|
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 {
|
private async loadIndexStatus(): Promise<void> {
|
||||||
this.toastMessage = message;
|
try {
|
||||||
this.toastVisible = true;
|
this.indexStatus = await GetIndexStatus();
|
||||||
|
this.indexStatusFailed = false;
|
||||||
if (this.toastTimer) clearTimeout(this.toastTimer);
|
} catch (err) {
|
||||||
|
console.error('Failed to read index status:', err);
|
||||||
this.toastTimer = setTimeout(() => {
|
this.indexStatusFailed = true;
|
||||||
this.toastVisible = false;
|
}
|
||||||
}, 8000);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private handleConcurrencyChange = (
|
private handleConcurrencyChange = (
|
||||||
@@ -1184,12 +1246,19 @@ export class ConfigPage extends LitElement {
|
|||||||
SetScanConcurrency(mode)
|
SetScanConcurrency(mode)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
this.concurrencyMode = mode;
|
this.concurrencyMode = mode;
|
||||||
this.showToast(
|
notificationStore.transient({
|
||||||
'Storage type saved. Takes effect on next scan.',
|
tone: 'success',
|
||||||
);
|
key: 'storage-type',
|
||||||
|
text: 'Storage type saved. Takes effect on next scan.',
|
||||||
|
});
|
||||||
})
|
})
|
||||||
.catch((err: unknown) => {
|
.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>`
|
? html`<span class="tier-detail">${t.detail}</span>`
|
||||||
: nothing}
|
: nothing}
|
||||||
${t.state === 'error'
|
${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}
|
: nothing}
|
||||||
</div>
|
</div>
|
||||||
`,
|
`,
|
||||||
@@ -1527,7 +1596,18 @@ export class ConfigPage extends LitElement {
|
|||||||
? html`<div class="index-waiting">Waiting for index build…</div>`
|
? html`<div class="index-waiting">Waiting for index build…</div>`
|
||||||
: nothing}
|
: 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>
|
</div>
|
||||||
</config-section>
|
</config-section>
|
||||||
`;
|
`;
|
||||||
@@ -2133,10 +2213,6 @@ export class ConfigPage extends LitElement {
|
|||||||
`
|
`
|
||||||
: nothing}
|
: nothing}
|
||||||
</config-section>
|
</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 { GetDownloadPreferences, SetDownloadPreferences } from '@go/config/Config';
|
||||||
import { SetPreferences } from '@go/download/Service';
|
import { SetPreferences } from '@go/download/Service';
|
||||||
import type { download } from '@go/models';
|
import type { download } from '@go/models';
|
||||||
|
import { describeError, explainError } from '@utils/describe-error';
|
||||||
|
import { confirmAction } from '@components/confirm-dialog/confirm-dialog';
|
||||||
import './config-section';
|
import './config-section';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -416,7 +418,7 @@ export class DownloadClients extends LitElement {
|
|||||||
size="small"
|
size="small"
|
||||||
appearance="plain"
|
appearance="plain"
|
||||||
variant="danger"
|
variant="danger"
|
||||||
@click=${() => this.deleteProvider(provider)}
|
@click=${() => void this.deleteProvider(provider)}
|
||||||
>
|
>
|
||||||
Remove
|
Remove
|
||||||
</wa-button>
|
</wa-button>
|
||||||
@@ -643,7 +645,11 @@ export class DownloadClients extends LitElement {
|
|||||||
|
|
||||||
this.cancelEdit();
|
this.cancelEdit();
|
||||||
} catch (err) {
|
} 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();
|
this.cancelEdit();
|
||||||
} catch (err) {
|
} 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;
|
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) {
|
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 = '';
|
this.errorMessage = '';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await downloadStore.deleteProvider(provider.id);
|
await downloadStore.deleteProvider(provider.id);
|
||||||
} catch (err) {
|
} 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.' },
|
[provider.id]: { ok: true, message: 'Connected.' },
|
||||||
};
|
};
|
||||||
} catch (err) {
|
} 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 = {
|
||||||
...this.testResults,
|
...this.testResults,
|
||||||
[provider.id]: { ok: false, message: String(err) },
|
[provider.id]: { ok: false, message: String(err) },
|
||||||
@@ -740,7 +773,11 @@ export class DownloadClients extends LitElement {
|
|||||||
await SetPreferences(this.prefs);
|
await SetPreferences(this.prefs);
|
||||||
this.prefsSaved = true;
|
this.prefsSaved = true;
|
||||||
} catch (err) {
|
} 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 {
|
} finally {
|
||||||
this.prefsSaving = false;
|
this.prefsSaving = false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import type { DownloadCandidate } from '@store/download-store';
|
|||||||
import { downloadStore } from '@store/download-store';
|
import { downloadStore } from '@store/download-store';
|
||||||
import type { download } from '@go/models';
|
import type { download } from '@go/models';
|
||||||
import './candidate-row';
|
import './candidate-row';
|
||||||
|
import { explainError } from '@utils/describe-error';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The "find this album" dialog: searches every enabled download client,
|
* 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. */
|
/** Runs the search that populates the dialog. */
|
||||||
private async search(): Promise<void> {
|
private async search(): Promise<void> {
|
||||||
|
const version = ++this.searchVersion;
|
||||||
|
|
||||||
this.searching = true;
|
this.searching = true;
|
||||||
this.errorMessage = '';
|
this.errorMessage = '';
|
||||||
this.candidates = [];
|
this.candidates = [];
|
||||||
@@ -141,13 +151,23 @@ export class DownloadPicker extends LitElement {
|
|||||||
expected: this.expected ?? [],
|
expected: this.expected ?? [],
|
||||||
} as download.SearchRequest);
|
} as download.SearchRequest);
|
||||||
|
|
||||||
|
if (version !== this.searchVersion) return;
|
||||||
|
|
||||||
this.downloadId = result.downloadId;
|
this.downloadId = result.downloadId;
|
||||||
this.candidates = result.candidates ?? [];
|
this.candidates = result.candidates ?? [];
|
||||||
this.autoPicked = result.autoPicked;
|
this.autoPicked = result.autoPicked;
|
||||||
} catch (err) {
|
} 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 {
|
} 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);
|
await downloadStore.pick(this.downloadId, event.detail.candidateId);
|
||||||
this.close();
|
this.close();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.errorMessage = String(err);
|
console.error('download pick failed', err);
|
||||||
|
this.errorMessage = explainError(
|
||||||
|
err,
|
||||||
|
'That download could not be started.',
|
||||||
|
);
|
||||||
} finally {
|
} finally {
|
||||||
this.picking = false;
|
this.picking = false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,10 @@ import { designTokens } from '../../styles/tokens.css';
|
|||||||
import { downloadStore, stateLabel } from '@store/download-store';
|
import { downloadStore, stateLabel } from '@store/download-store';
|
||||||
import type { Request, RequestSummary, DownloadView as DownloadRecord } from '@store/download-store';
|
import type { Request, RequestSummary, DownloadView as DownloadRecord } from '@store/download-store';
|
||||||
import { libraryStore } from '@store/library-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';
|
type Tab = 'requests' | 'downloads';
|
||||||
|
|
||||||
@@ -22,7 +26,7 @@ type Tab = 'requests' | 'downloads';
|
|||||||
* attempt history nothing rendered before this page existed.
|
* attempt history nothing rendered before this page existed.
|
||||||
*/
|
*/
|
||||||
@customElement('downloads-view')
|
@customElement('downloads-view')
|
||||||
export class DownloadsView extends LitElement {
|
export class DownloadsView extends ViewLifecycleMixin(LitElement) {
|
||||||
@state() private tab: Tab = 'requests';
|
@state() private tab: Tab = 'requests';
|
||||||
|
|
||||||
@state() private requests: Request[] = [];
|
@state() private requests: Request[] = [];
|
||||||
@@ -39,7 +43,6 @@ export class DownloadsView extends LitElement {
|
|||||||
/** Ticks so "next check in …" ages while the page is open. */
|
/** Ticks so "next check in …" ages while the page is open. */
|
||||||
@state() private nowMs = Date.now();
|
@state() private nowMs = Date.now();
|
||||||
|
|
||||||
private clockTimer?: ReturnType<typeof setInterval>;
|
|
||||||
|
|
||||||
private unsubscribe: (() => void) | null = null;
|
private unsubscribe: (() => void) | null = null;
|
||||||
|
|
||||||
@@ -196,9 +199,7 @@ export class DownloadsView extends LitElement {
|
|||||||
`,
|
`,
|
||||||
];
|
];
|
||||||
|
|
||||||
override connectedCallback(): void {
|
protected override onViewActivate(): void {
|
||||||
super.connectedCallback();
|
|
||||||
|
|
||||||
this.unsubscribe = downloadStore.subscribe(() => {
|
this.unsubscribe = downloadStore.subscribe(() => {
|
||||||
this.requests = downloadStore.requests;
|
this.requests = downloadStore.requests;
|
||||||
this.downloads = downloadStore.downloads;
|
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
|
// A "next check" that never moves reads as a stuck page, so the
|
||||||
// relative times re-render on their own.
|
// relative times re-render on their own — while the page is on
|
||||||
this.clockTimer = setInterval(() => {
|
// screen, where a re-render can be seen.
|
||||||
|
this.intervalWhileActive(() => {
|
||||||
this.nowMs = Date.now();
|
this.nowMs = Date.now();
|
||||||
}, 30_000);
|
}, 30_000);
|
||||||
}
|
}
|
||||||
|
|
||||||
override disconnectedCallback(): void {
|
protected override onViewDeactivate(): void {
|
||||||
super.disconnectedCallback();
|
|
||||||
|
|
||||||
this.unsubscribe?.();
|
this.unsubscribe?.();
|
||||||
this.unsubscribe = null;
|
this.unsubscribe = null;
|
||||||
clearInterval(this.clockTimer);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override render() {
|
override render() {
|
||||||
@@ -295,8 +294,7 @@ export class DownloadsView extends LitElement {
|
|||||||
<wa-button
|
<wa-button
|
||||||
size="small"
|
size="small"
|
||||||
appearance="plain"
|
appearance="plain"
|
||||||
@click=${() =>
|
@click=${() => void this.clearSatisfied()}
|
||||||
void downloadStore.clearSatisfiedRequests()}
|
|
||||||
>
|
>
|
||||||
Clear found
|
Clear found
|
||||||
</wa-button>
|
</wa-button>
|
||||||
@@ -450,11 +448,7 @@ export class DownloadsView extends LitElement {
|
|||||||
<wa-button
|
<wa-button
|
||||||
size="small"
|
size="small"
|
||||||
appearance="plain"
|
appearance="plain"
|
||||||
@click=${() =>
|
@click=${() => void this.pause(request)}
|
||||||
void downloadStore.pauseRequest(
|
|
||||||
request.id,
|
|
||||||
request.state !== 'paused',
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
${request.state === 'paused' ? 'Resume' : 'Pause'}
|
${request.state === 'paused' ? 'Resume' : 'Pause'}
|
||||||
</wa-button>
|
</wa-button>
|
||||||
@@ -470,13 +464,77 @@ export class DownloadsView extends LitElement {
|
|||||||
<wa-button
|
<wa-button
|
||||||
size="small"
|
size="small"
|
||||||
appearance="plain"
|
appearance="plain"
|
||||||
@click=${() => void downloadStore.removeRequest(request.id)}
|
aria-label="Stop following"
|
||||||
|
@click=${() => void this.removeRequest(request)}
|
||||||
>
|
>
|
||||||
<wa-icon name="xmark"></wa-icon>
|
<wa-icon name="xmark"></wa-icon>
|
||||||
</wa-button>
|
</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. */
|
/** Widens or narrows what an artist subscription covers. */
|
||||||
private async toggleScope(request: Request): Promise<void> {
|
private async toggleScope(request: Request): Promise<void> {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ type MBTrack = explore.MBTrack;
|
|||||||
import { exploreCache } from '../../store/explore-cache';
|
import { exploreCache } from '../../store/explore-cache';
|
||||||
import { libraryStore } from '../../store/library-store';
|
import { libraryStore } from '../../store/library-store';
|
||||||
import { artistLink, exploreLinkStyles } from '../../utils/explore-link';
|
import { artistLink, exploreLinkStyles } from '../../utils/explore-link';
|
||||||
|
import { describeError } from '../../utils/describe-error';
|
||||||
import { EventsOn } from '@runtime/runtime';
|
import { EventsOn } from '@runtime/runtime';
|
||||||
import { Events } from '../../events';
|
import { Events } from '../../events';
|
||||||
import '@awesome.me/webawesome/dist/components/icon/icon.js';
|
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.
|
// Local-only album (no MBID) — populate entirely from library.
|
||||||
if (!mbid && this.localAlbumId) {
|
if (!mbid && this.localAlbumId) {
|
||||||
console.log(
|
|
||||||
`[explore-album] loading local-only: "${this.albumName}" (id=${this.localAlbumId})`,
|
|
||||||
);
|
|
||||||
|
|
||||||
await this.hydrateLocalOnly();
|
await this.hydrateLocalOnly();
|
||||||
|
|
||||||
console.log(
|
|
||||||
`[explore-album] loaded (local-only): "${this.albumName}"`,
|
|
||||||
);
|
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(
|
|
||||||
`[explore-album] loading: "${this.albumName}" (${mbid})`,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Phase 0: hydrate from explore cache (instant).
|
// Phase 0: hydrate from explore cache (instant).
|
||||||
const cached = exploreCache.getAlbum(mbid);
|
const cached = exploreCache.getAlbum(mbid);
|
||||||
@@ -692,7 +684,6 @@ export class ExploreAlbumDetails extends LitElement {
|
|||||||
primaryType: 'Album',
|
primaryType: 'Album',
|
||||||
} as MBReleaseGroup;
|
} as MBReleaseGroup;
|
||||||
this.loadingInfo = false;
|
this.loadingInfo = false;
|
||||||
console.log(`[explore-album] hydrated from cache: "${cached.title}"`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Phase 1: hydrate tracklist from local library if available.
|
// 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.
|
// Fire cover art resolution immediately — doesn't wait for release group.
|
||||||
this.resolveCoverArt();
|
this.resolveCoverArt();
|
||||||
|
|
||||||
console.log(
|
|
||||||
`[explore-album] data requests fired: "${this.albumName}" (${mbid})`,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -874,9 +862,6 @@ export class ExploreAlbumDetails extends LitElement {
|
|||||||
this.buildClusters();
|
this.buildClusters();
|
||||||
this.loadingReleases = false;
|
this.loadingReleases = false;
|
||||||
|
|
||||||
console.log(
|
|
||||||
`[explore-album] hydrated ${localTracks.length} tracks from library`,
|
|
||||||
);
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -908,9 +893,11 @@ export class ExploreAlbumDetails extends LitElement {
|
|||||||
this.releaseGroup = await LookupReleaseGroup(mbid);
|
this.releaseGroup = await LookupReleaseGroup(mbid);
|
||||||
this.resolveCoverArt();
|
this.resolveCoverArt();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const msg = err instanceof Error ? err.message : String(err);
|
console.error('[explore-album] LookupReleaseGroup error', err);
|
||||||
this.errorInfo = msg;
|
this.errorInfo = describeError(
|
||||||
console.error(`[explore-album] LookupReleaseGroup error: ${msg}`);
|
err,
|
||||||
|
'The catalog did not answer for this album.',
|
||||||
|
);
|
||||||
} finally {
|
} finally {
|
||||||
this.loadingInfo = false;
|
this.loadingInfo = false;
|
||||||
}
|
}
|
||||||
@@ -965,9 +952,11 @@ export class ExploreAlbumDetails extends LitElement {
|
|||||||
this.catalogPending = false;
|
this.catalogPending = false;
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const msg = err instanceof Error ? err.message : String(err);
|
console.error('[explore-album] BrowseReleases error', err);
|
||||||
this.errorReleases = msg;
|
this.errorReleases = describeError(
|
||||||
console.error(`[explore-album] BrowseReleases error: ${msg}`);
|
err,
|
||||||
|
'The catalog did not answer for this album\u2019s versions.',
|
||||||
|
);
|
||||||
this.loadingReleases = false;
|
this.loadingReleases = false;
|
||||||
this.catalogPending = false;
|
this.catalogPending = false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import { libraryStore } from '../../store/library-store';
|
|||||||
import { downloadStore } from '../../store/download-store';
|
import { downloadStore } from '../../store/download-store';
|
||||||
import '@awesome.me/webawesome/dist/components/button/button.js';
|
import '@awesome.me/webawesome/dist/components/button/button.js';
|
||||||
import { trackLink, exploreLinkStyles } from '../../utils/explore-link';
|
import { trackLink, exploreLinkStyles } from '../../utils/explore-link';
|
||||||
|
import { describeError } from '../../utils/describe-error';
|
||||||
import { GetAlbumsByArtist } from '@go/library/Library';
|
import { GetAlbumsByArtist } from '@go/library/Library';
|
||||||
import { EventsOn } from '@runtime/runtime';
|
import { EventsOn } from '@runtime/runtime';
|
||||||
import { Events } from '../../events';
|
import { Events } from '../../events';
|
||||||
@@ -1014,9 +1015,6 @@ export class ExploreArtistDetails extends LitElement {
|
|||||||
|
|
||||||
// Local-only artist (no MBID) — populate from library store.
|
// Local-only artist (no MBID) — populate from library store.
|
||||||
if (!mbid && this.localArtistId) {
|
if (!mbid && this.localArtistId) {
|
||||||
console.log(
|
|
||||||
`[explore-artist] loading local-only: "${this.artistName}" (id=${this.localArtistId})`,
|
|
||||||
);
|
|
||||||
|
|
||||||
this.loadingArtist = false;
|
this.loadingArtist = false;
|
||||||
this.loadingTracks = false;
|
this.loadingTracks = false;
|
||||||
@@ -1027,16 +1025,10 @@ export class ExploreArtistDetails extends LitElement {
|
|||||||
|
|
||||||
this.hydrateLocalOnly();
|
this.hydrateLocalOnly();
|
||||||
|
|
||||||
console.log(
|
|
||||||
`[explore-artist] loaded (local-only): "${this.artistName}"`,
|
|
||||||
);
|
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(
|
|
||||||
`[explore-artist] loading: "${this.artistName}" (${mbid})`,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Phase 0: hydrate from caches (instant, no Go calls).
|
// Phase 0: hydrate from caches (instant, no Go calls).
|
||||||
this.hydrateFromCache(mbid);
|
this.hydrateFromCache(mbid);
|
||||||
@@ -1068,9 +1060,6 @@ export class ExploreArtistDetails extends LitElement {
|
|||||||
|
|
||||||
// checkLibrary now runs from fetchReleaseGroups when data arrives.
|
// 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 {
|
try {
|
||||||
this.artist = await LookupArtist(mbid);
|
this.artist = await LookupArtist(mbid);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const msg = err instanceof Error ? err.message : String(err);
|
console.error('[explore-artist] LookupArtist error', err);
|
||||||
this.errorArtist = msg;
|
this.errorArtist = describeError(
|
||||||
console.error(`[explore-artist] LookupArtist error: ${msg}`);
|
err,
|
||||||
|
'The catalog did not answer.',
|
||||||
|
);
|
||||||
} finally {
|
} finally {
|
||||||
this.loadingArtist = false;
|
this.loadingArtist = false;
|
||||||
}
|
}
|
||||||
@@ -1326,10 +1317,7 @@ export class ExploreArtistDetails extends LitElement {
|
|||||||
|
|
||||||
void this.batchResolveTrackThumbnails(tracks, mapping);
|
void this.batchResolveTrackThumbnails(tracks, mapping);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const msg = err instanceof Error ? err.message : String(err);
|
console.error('[explore-artist] TopRecordingsForArtist error', err);
|
||||||
console.error(
|
|
||||||
`[explore-artist] TopRecordingsForArtist error: ${msg}`,
|
|
||||||
);
|
|
||||||
} finally {
|
} finally {
|
||||||
// An empty first pass may mean a background discography fetch
|
// An empty first pass may mean a background discography fetch
|
||||||
// is still in flight (the artist wasn't indexed yet). Keep
|
// 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.
|
// are the most likely to be clicked from the artist page.
|
||||||
this.prefetchReleases(rgs?.map((r) => r.releaseGroupMbid) ?? []);
|
this.prefetchReleases(rgs?.map((r) => r.releaseGroupMbid) ?? []);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const msg = err instanceof Error ? err.message : String(err);
|
console.error('[explore-artist] TopReleaseGroupsForArtist error', err);
|
||||||
console.error(
|
|
||||||
`[explore-artist] TopReleaseGroupsForArtist error: ${msg}`,
|
|
||||||
);
|
|
||||||
this.topReleaseGroups = [];
|
this.topReleaseGroups = [];
|
||||||
} finally {
|
} finally {
|
||||||
// See fetchTopTracks: hold the spinner while a background
|
// 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.
|
// one from here is instant instead of a cold MB browse.
|
||||||
this.prefetchReleases(rgs?.map((r) => r.mbid) ?? []);
|
this.prefetchReleases(rgs?.map((r) => r.mbid) ?? []);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const msg = err instanceof Error ? err.message : String(err);
|
console.error('[explore-artist] BrowseReleaseGroups error', err);
|
||||||
this.errorReleases = msg;
|
this.errorReleases = describeError(
|
||||||
this.catalogPending = false;
|
err,
|
||||||
console.error(
|
'The catalog did not answer for this artist\u2019s albums.',
|
||||||
`[explore-artist] BrowseReleaseGroups error: ${msg}`,
|
|
||||||
);
|
);
|
||||||
|
this.catalogPending = false;
|
||||||
} finally {
|
} finally {
|
||||||
// BrowseReleaseGroups is index-first + async: an empty result on
|
// BrowseReleaseGroups is index-first + async: an empty result on
|
||||||
// a cold artist means the discography is still being fetched in
|
// a cold artist means the discography is still being fetched in
|
||||||
@@ -1498,10 +1483,7 @@ export class ExploreArtistDetails extends LitElement {
|
|||||||
this.similarArtists = artists ?? [];
|
this.similarArtists = artists ?? [];
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// D024: graceful degradation — silently omit similar artists on failure.
|
// D024: graceful degradation — silently omit similar artists on failure.
|
||||||
const msg = err instanceof Error ? err.message : String(err);
|
console.error('[explore-artist] SimilarArtists error', err);
|
||||||
console.error(
|
|
||||||
`[explore-artist] SimilarArtists error: ${msg}`,
|
|
||||||
);
|
|
||||||
this.similarArtists = [];
|
this.similarArtists = [];
|
||||||
} finally {
|
} finally {
|
||||||
// SimilarArtists is DB-first + async: an empty result on the
|
// 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 { designTokens } from '../../styles/tokens.css';
|
||||||
import { SearchLocal, SearchLyrics, GetThumbnail, GetThumbnails, GetArtistImageURL, RecordSearchClick } from '@go/explore/Service';
|
import { SearchLocal, SearchLyrics, GetThumbnail, GetThumbnails, GetArtistImageURL, RecordSearchClick } from '@go/explore/Service';
|
||||||
import { libraryStore } from '../../store/library-store';
|
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 { queueStore } from '../../store/queue-store';
|
||||||
import { artistLink, trackLink, exploreLinkStyles } from '../../utils/explore-link';
|
import { artistLink, trackLink, exploreLinkStyles } from '../../utils/explore-link';
|
||||||
|
import { describeError } from '../../utils/describe-error';
|
||||||
import '@awesome.me/webawesome/dist/components/icon/icon.js';
|
import '@awesome.me/webawesome/dist/components/icon/icon.js';
|
||||||
import '../library-status-indicator/library-status-indicator.js';
|
import '../library-status-indicator/library-status-indicator.js';
|
||||||
import '../top-results-row/top-results-row.js';
|
import '../top-results-row/top-results-row.js';
|
||||||
import { explore } from '@go/models';
|
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 ThumbnailRequest = explore.ThumbnailRequest;
|
||||||
type MBSearchResult = explore.MBSearchResult;
|
type MBSearchResult = explore.MBSearchResult;
|
||||||
type LyricsResult = explore.LyricsResult;
|
type LyricsResult = explore.LyricsResult;
|
||||||
@@ -25,6 +29,32 @@ const SEARCH_DEBOUNCE_MS = 180;
|
|||||||
|
|
||||||
const MAX_SECTION_RESULTS = 10;
|
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. */
|
/** Hash a string to a hue value 0–360 for avatar coloring. */
|
||||||
function nameToHue(name: string): number {
|
function nameToHue(name: string): number {
|
||||||
@@ -79,7 +109,7 @@ function getArtistAlbumArt(artistName: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@customElement('explore-view')
|
@customElement('explore-view')
|
||||||
export class ExploreView extends LitElement {
|
export class ExploreView extends ViewLifecycleMixin(LitElement) {
|
||||||
/* ── State ── */
|
/* ── State ── */
|
||||||
|
|
||||||
@state() private searchQuery = '';
|
@state() private searchQuery = '';
|
||||||
@@ -96,10 +126,29 @@ export class ExploreView extends LitElement {
|
|||||||
private searchVersion = 0;
|
private searchVersion = 0;
|
||||||
/** Debounce timer for live search-as-you-type. */
|
/** Debounce timer for live search-as-you-type. */
|
||||||
private searchDebounceTimer?: ReturnType<typeof setTimeout>;
|
private searchDebounceTimer?: ReturnType<typeof setTimeout>;
|
||||||
private thumbnailCache = new Map<string, string>();
|
private thumbnailCache = new LRUMap<string, string>(THUMBNAIL_CACHE_LIMIT);
|
||||||
private artistImageCache = new Map<string, string>();
|
private artistImageCache = new LRUMap<string, string>(ARTIST_IMAGE_CACHE_LIMIT);
|
||||||
private libraryMBIDs = new Set<string>();
|
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;
|
@litQuery('input') private inputEl!: HTMLInputElement;
|
||||||
|
|
||||||
/* ── Styles ── */
|
/* ── Styles ── */
|
||||||
@@ -592,9 +641,13 @@ export class ExploreView extends LitElement {
|
|||||||
|
|
||||||
override disconnectedCallback() {
|
override disconnectedCallback() {
|
||||||
super.disconnectedCallback();
|
super.disconnectedCallback();
|
||||||
if (this.searchDebounceTimer) {
|
this.cancelPendingSearch();
|
||||||
clearTimeout(this.searchDebounceTimer);
|
}
|
||||||
}
|
|
||||||
|
/** 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 ── */
|
/* ── Search Logic ── */
|
||||||
@@ -707,19 +760,16 @@ export class ExploreView extends LitElement {
|
|||||||
this.loading = true;
|
this.loading = true;
|
||||||
this.error = '';
|
this.error = '';
|
||||||
|
|
||||||
const startTime = performance.now();
|
|
||||||
console.log(`[explore] search started: "${query}" (${this.searchMode})`);
|
|
||||||
|
|
||||||
// Lyrics mode: a single FTS lyric search over the library.
|
// Lyrics mode: a single FTS lyric search over the library.
|
||||||
if (this.searchMode === 'lyrics') {
|
if (this.searchMode === 'lyrics') {
|
||||||
void this.executeLyricsSearch(version, query, startTime);
|
void this.executeLyricsSearch(version, query);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Offline search over the local popularity index via Wails RPC.
|
// Offline search over the local popularity index via Wails RPC.
|
||||||
// No network, and no owned-library seed — the index is the sole
|
// No network, and no owned-library seed — the index is the sole
|
||||||
// source of catalog results.
|
// 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;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async executeIndexSearch(version: number, query: string, startTime: number) {
|
private async executeIndexSearch(version: number, query: string) {
|
||||||
try {
|
try {
|
||||||
// Local FTS index only — no network. Returns null when the
|
// Local FTS index only — no network. Returns null when the
|
||||||
// index has no hits, in which case we keep the owned-library
|
// index has no hits, in which case we keep the owned-library
|
||||||
@@ -809,19 +859,16 @@ export class ExploreView extends LitElement {
|
|||||||
this.loadThumbnails();
|
this.loadThumbnails();
|
||||||
this.loadArtistImages();
|
this.loadArtistImages();
|
||||||
this.checkLibrary();
|
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) {
|
} catch (err) {
|
||||||
if (version !== this.searchVersion) return;
|
if (version !== this.searchVersion) return;
|
||||||
const message = err instanceof Error ? err.message : String(err);
|
|
||||||
this.error = message;
|
console.error(`[explore] search error: "${query}"`, err);
|
||||||
console.error(`[explore] search error: "${query}" — ${message}`);
|
// 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 {
|
} finally {
|
||||||
if (version === this.searchVersion) {
|
if (version === this.searchVersion) {
|
||||||
this.loading = false;
|
this.loading = false;
|
||||||
@@ -831,22 +878,20 @@ export class ExploreView extends LitElement {
|
|||||||
|
|
||||||
/* ── Lyrics Search ── */
|
/* ── Lyrics Search ── */
|
||||||
|
|
||||||
private async executeLyricsSearch(version: number, query: string, startTime: number) {
|
private async executeLyricsSearch(version: number, query: string) {
|
||||||
try {
|
try {
|
||||||
const hits = await SearchLyrics(query);
|
const hits = await SearchLyrics(query);
|
||||||
if (version !== this.searchVersion) return;
|
if (version !== this.searchVersion) return;
|
||||||
|
|
||||||
this.lyricsResults = hits ?? [];
|
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) {
|
} catch (err) {
|
||||||
if (version !== this.searchVersion) return;
|
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 {
|
} finally {
|
||||||
if (version === this.searchVersion) {
|
if (version === this.searchVersion) {
|
||||||
this.loading = false;
|
this.loading = false;
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
GetAllLibrariesWithTrackCounts,
|
GetAllLibrariesWithTrackCounts,
|
||||||
} from '@go/library/Library';
|
} from '@go/library/Library';
|
||||||
import { DirectoryPicker } from '@go/frontendutil/FrontendUtil';
|
import { DirectoryPicker } from '@go/frontendutil/FrontendUtil';
|
||||||
|
import { describeError, explainError } from '@utils/describe-error';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* First-run setup wizard.
|
* First-run setup wizard.
|
||||||
@@ -236,7 +237,10 @@ export class FirstRunWizard extends LitElement {
|
|||||||
|
|
||||||
if (dir) this.selectedDirectory = dir;
|
if (dir) this.selectedDirectory = dir;
|
||||||
} catch (err) {
|
} 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);
|
console.error('First-run wizard: directory picker failed:', err);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -256,7 +260,10 @@ export class FirstRunWizard extends LitElement {
|
|||||||
|
|
||||||
this.active = false;
|
this.active = false;
|
||||||
} catch (err) {
|
} 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);
|
console.error('First-run wizard: add library failed:', err);
|
||||||
} finally {
|
} finally {
|
||||||
this.saving = false;
|
this.saving = false;
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
import { EventsOn } from '@runtime/runtime';
|
import { EventsOn } from '@runtime/runtime';
|
||||||
import { Events } from '../../events';
|
import { Events } from '../../events';
|
||||||
import { libraryStore } from '@store/library-store';
|
import { libraryStore } from '@store/library-store';
|
||||||
|
import { describeError } from '@utils/describe-error';
|
||||||
import '@awesome.me/webawesome/dist/components/icon/icon.js';
|
import '@awesome.me/webawesome/dist/components/icon/icon.js';
|
||||||
import '@components/track-list/track-list.js';
|
import '@components/track-list/track-list.js';
|
||||||
import { designTokens } from '../../styles/tokens.css';
|
import { designTokens } from '../../styles/tokens.css';
|
||||||
@@ -27,6 +28,12 @@ export class GenreDetails extends LitElement {
|
|||||||
@state()
|
@state()
|
||||||
private loading = true;
|
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 =
|
private scanCompleteCleanup: (() => void) | null =
|
||||||
null;
|
null;
|
||||||
|
|
||||||
@@ -38,6 +45,21 @@ export class GenreDetails extends LitElement {
|
|||||||
height: 100%;
|
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
|
* Header
|
||||||
* ==================================== */
|
* ==================================== */
|
||||||
@@ -179,6 +201,8 @@ export class GenreDetails extends LitElement {
|
|||||||
private async loadTracks() {
|
private async loadTracks() {
|
||||||
if (!this.genreName) return;
|
if (!this.genreName) return;
|
||||||
|
|
||||||
|
this.loadError = '';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const libId =
|
const libId =
|
||||||
libraryStore.getSelectedLibraryId();
|
libraryStore.getSelectedLibraryId();
|
||||||
@@ -192,11 +216,12 @@ export class GenreDetails extends LitElement {
|
|||||||
this.genreName,
|
this.genreName,
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(
|
console.error('Error loading genre tracks:', error);
|
||||||
'Error loading genre tracks:',
|
|
||||||
error,
|
|
||||||
);
|
|
||||||
this.tracks = [];
|
this.tracks = [];
|
||||||
|
this.loadError = describeError(
|
||||||
|
error,
|
||||||
|
`The tracks for “${this.genreName}” could not be loaded.`,
|
||||||
|
);
|
||||||
} finally {
|
} finally {
|
||||||
this.loading = false;
|
this.loading = false;
|
||||||
}
|
}
|
||||||
@@ -274,9 +299,19 @@ export class GenreDetails extends LitElement {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="content">
|
<div class="content">
|
||||||
<track-list
|
${this.loadError
|
||||||
.externalTracks=${this.tracks}
|
? html`<div class="load-error" data-testid="genre-error">
|
||||||
></track-list>
|
<p>${this.loadError}</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click=${() => void this.loadTracks()}
|
||||||
|
>
|
||||||
|
Try again
|
||||||
|
</button>
|
||||||
|
</div>`
|
||||||
|
: html`<track-list
|
||||||
|
.externalTracks=${this.tracks}
|
||||||
|
></track-list>`}
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { libraryStore } from '@store/library-store';
|
|||||||
import { EventsOn } from '@runtime/runtime';
|
import { EventsOn } from '@runtime/runtime';
|
||||||
import { Events } from '../../events';
|
import { Events } from '../../events';
|
||||||
import { designTokens } from '../../styles/tokens.css';
|
import { designTokens } from '../../styles/tokens.css';
|
||||||
|
import { ViewLifecycleMixin } from '../../utils/view-lifecycle';
|
||||||
|
|
||||||
type Shelf = home.Shelf;
|
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.
|
* cover do the two things a cover should: open the album, or play it.
|
||||||
*/
|
*/
|
||||||
@customElement('home-view')
|
@customElement('home-view')
|
||||||
export class HomeView extends LitElement {
|
export class HomeView extends ViewLifecycleMixin(LitElement) {
|
||||||
@state() private shelves: Shelf[] = [];
|
@state() private shelves: Shelf[] = [];
|
||||||
|
|
||||||
@state() private loading = true;
|
@state() private loading = true;
|
||||||
@@ -194,8 +195,10 @@ export class HomeView extends LitElement {
|
|||||||
`,
|
`,
|
||||||
];
|
];
|
||||||
|
|
||||||
override connectedCallback(): void {
|
protected override onViewActivate(): void {
|
||||||
super.connectedCallback();
|
// 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();
|
void this.load();
|
||||||
|
|
||||||
// A finished scan changes what every shelf would say, and the
|
// 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, () => {
|
this.unsubScan = EventsOn(Events.LibraryScanComplete, () => {
|
||||||
void this.load();
|
void this.load();
|
||||||
});
|
});
|
||||||
}
|
this.whileActive(() => {
|
||||||
|
this.unsubScan?.();
|
||||||
override disconnectedCallback(): void {
|
this.unsubScan = undefined;
|
||||||
super.disconnectedCallback();
|
});
|
||||||
this.unsubScan?.();
|
|
||||||
this.unsubScan = undefined;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
import { jobStore } from '@store/job-store';
|
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';
|
export type JobControlAction = 'pause' | 'resume' | 'cancel' | 'dismiss';
|
||||||
|
|
||||||
@@ -7,31 +11,57 @@ export interface JobControlDetail {
|
|||||||
action: JobControlAction;
|
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>`.
|
* Applies a `job-control` event emitted by a `<job-row>`.
|
||||||
*
|
*
|
||||||
* Shared by every host that renders job rows — the top-bar popover, the
|
* Shared by every host that renders job rows — the top-bar popover, the
|
||||||
* jobs page, and the details drawer — so a control behaves identically
|
* 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.
|
* 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> {
|
export async function applyJobControl(e: Event): Promise<void> {
|
||||||
const { id, action } = (e as CustomEvent).detail as JobControlDetail;
|
const { id, action } = (e as CustomEvent).detail as JobControlDetail;
|
||||||
|
|
||||||
if (action === 'cancel' && !confirmCancel(id)) return;
|
if (action === 'cancel' && !(await confirmCancel(id))) return;
|
||||||
|
|
||||||
switch (action) {
|
try {
|
||||||
case 'pause':
|
switch (action) {
|
||||||
await jobStore.pause(id);
|
case 'pause':
|
||||||
break;
|
await jobStore.pause(id);
|
||||||
case 'resume':
|
break;
|
||||||
await jobStore.resume(id);
|
case 'resume':
|
||||||
break;
|
await jobStore.resume(id);
|
||||||
case 'cancel':
|
break;
|
||||||
await jobStore.cancel(id);
|
case 'cancel':
|
||||||
break;
|
await jobStore.cancel(id);
|
||||||
case 'dismiss':
|
break;
|
||||||
await jobStore.dismiss(id);
|
case 'dismiss':
|
||||||
break;
|
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
|
* 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.
|
* 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);
|
const job = jobStore.getJob(id);
|
||||||
|
|
||||||
if (job?.kind !== 'index-build') return true;
|
if (job?.kind !== 'index-build') return Promise.resolve(true);
|
||||||
|
|
||||||
return window.confirm(
|
return confirmAction({
|
||||||
'Stop building the search index?\n\n' +
|
title: 'Stop building the search index?',
|
||||||
|
message:
|
||||||
'Progress is checkpointed, so you can resume later without ' +
|
'Progress is checkpointed, so you can resume later without ' +
|
||||||
're-downloading. Until it finishes, search results stay ' +
|
're-downloading.',
|
||||||
'limited to your own library.',
|
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';
|
return 'folder';
|
||||||
case 'index-build':
|
case 'index-build':
|
||||||
return 'database';
|
return 'database';
|
||||||
|
case 'autotag-apply':
|
||||||
|
return 'tags';
|
||||||
default:
|
default:
|
||||||
return 'gear';
|
return 'gear';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,10 +13,14 @@ import { EventsOn } from '@runtime/runtime';
|
|||||||
import { Events } from '../../events';
|
import { Events } from '../../events';
|
||||||
import { jobStore } from '@store/job-store';
|
import { jobStore } from '@store/job-store';
|
||||||
import type { Job } 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-row';
|
||||||
import './job-details-drawer';
|
import './job-details-drawer';
|
||||||
import { applyJobControl } from './job-controls';
|
import { applyJobControl } from './job-controls';
|
||||||
import { jobStateStyles } from './job-format';
|
import { jobStateStyles } from './job-format';
|
||||||
|
import { ViewLifecycleMixin } from '../../utils/view-lifecycle';
|
||||||
|
|
||||||
type LibraryInfo = library.Info;
|
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.
|
* one implementation, two placements, so the two can never disagree.
|
||||||
*/
|
*/
|
||||||
@customElement('jobs-view')
|
@customElement('jobs-view')
|
||||||
export class JobsView extends LitElement {
|
export class JobsView extends ViewLifecycleMixin(LitElement) {
|
||||||
@state()
|
@state()
|
||||||
private jobs: Job[] = [];
|
private jobs: Job[] = [];
|
||||||
|
|
||||||
@@ -49,6 +53,15 @@ export class JobsView extends LitElement {
|
|||||||
@state()
|
@state()
|
||||||
private drawerOpen = false;
|
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 unsubscribe: (() => void) | null = null;
|
||||||
|
|
||||||
private eventCleanups: Array<() => void> = [];
|
private eventCleanups: Array<() => void> = [];
|
||||||
@@ -221,8 +234,7 @@ export class JobsView extends LitElement {
|
|||||||
`,
|
`,
|
||||||
];
|
];
|
||||||
|
|
||||||
override connectedCallback(): void {
|
protected override onViewActivate(): void {
|
||||||
super.connectedCallback();
|
|
||||||
this.unsubscribe = jobStore.subscribe(() => {
|
this.unsubscribe = jobStore.subscribe(() => {
|
||||||
this.jobs = jobStore.jobs;
|
this.jobs = jobStore.jobs;
|
||||||
});
|
});
|
||||||
@@ -243,8 +255,7 @@ export class JobsView extends LitElement {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override disconnectedCallback(): void {
|
protected override onViewDeactivate(): void {
|
||||||
super.disconnectedCallback();
|
|
||||||
this.unsubscribe?.();
|
this.unsubscribe?.();
|
||||||
this.unsubscribe = null;
|
this.unsubscribe = null;
|
||||||
this.eventCleanups.forEach((off) => off());
|
this.eventCleanups.forEach((off) => off());
|
||||||
@@ -273,20 +284,52 @@ export class JobsView extends LitElement {
|
|||||||
this.drawerOpen = false;
|
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 {
|
try {
|
||||||
await ScanLibrary(id);
|
await start();
|
||||||
} catch (err) {
|
} 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() {
|
private async startAllScans() {
|
||||||
try {
|
await this.startJob(
|
||||||
await ScanAllLibraries();
|
'Scanning your libraries',
|
||||||
} catch (err) {
|
() => ScanAllLibraries(),
|
||||||
console.error('Failed to start scans:', err);
|
() => void this.startAllScans(),
|
||||||
}
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async clearFinished() {
|
private async clearFinished() {
|
||||||
@@ -294,22 +337,25 @@ export class JobsView extends LitElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async fullRescan() {
|
private async fullRescan() {
|
||||||
if (
|
const ok = await confirmAction({
|
||||||
!window.confirm(
|
title: 'Full rescan',
|
||||||
'Full rescan deletes ALL library data — including ' +
|
message:
|
||||||
'downloaded cover art — and rebuilds it from your ' +
|
'This deletes all library data — including downloaded ' +
|
||||||
'files.\n\nThis is not the same as "Scan now", which ' +
|
'cover art — and rebuilds it from your files.',
|
||||||
'only picks up what changed. Continue?',
|
impact:
|
||||||
)
|
'It is not the same as “Scan now”, which only picks up ' +
|
||||||
) {
|
'what changed.',
|
||||||
return;
|
confirmLabel: 'Rebuild everything',
|
||||||
}
|
danger: true,
|
||||||
|
});
|
||||||
|
|
||||||
try {
|
if (!ok) return;
|
||||||
await FullRescan();
|
|
||||||
} catch (err) {
|
await this.startJob(
|
||||||
console.error('Full rescan failed:', err);
|
'The full rescan',
|
||||||
}
|
() => FullRescan(),
|
||||||
|
() => void this.fullRescan(),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private renderJobList(list: Job[], emptyText: string) {
|
private renderJobList(list: Job[], emptyText: string) {
|
||||||
@@ -393,6 +439,7 @@ export class JobsView extends LitElement {
|
|||||||
: html`
|
: html`
|
||||||
<button
|
<button
|
||||||
class="action"
|
class="action"
|
||||||
|
?disabled=${this.starting}
|
||||||
@click=${() => this.startScan(lib.id)}
|
@click=${() => this.startScan(lib.id)}
|
||||||
>
|
>
|
||||||
<wa-icon name="arrows-rotate"></wa-icon>
|
<wa-icon name="arrows-rotate"></wa-icon>
|
||||||
@@ -431,7 +478,9 @@ export class JobsView extends LitElement {
|
|||||||
<h2>Libraries</h2>
|
<h2>Libraries</h2>
|
||||||
<button
|
<button
|
||||||
class="action"
|
class="action"
|
||||||
?disabled=${anyScanning || this.libraries.length === 0}
|
?disabled=${anyScanning ||
|
||||||
|
this.starting ||
|
||||||
|
this.libraries.length === 0}
|
||||||
@click=${this.startAllScans}
|
@click=${this.startAllScans}
|
||||||
>
|
>
|
||||||
<wa-icon name="arrows-rotate"></wa-icon>
|
<wa-icon name="arrows-rotate"></wa-icon>
|
||||||
@@ -467,7 +516,9 @@ export class JobsView extends LitElement {
|
|||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
class="action danger"
|
class="action danger"
|
||||||
?disabled=${anyScanning}
|
?disabled=${anyScanning ||
|
||||||
|
this.starting ||
|
||||||
|
this.libraries.length === 0}
|
||||||
@click=${this.fullRescan}
|
@click=${this.fullRescan}
|
||||||
>
|
>
|
||||||
<wa-icon name="triangle-exclamation"></wa-icon>
|
<wa-icon name="triangle-exclamation"></wa-icon>
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ import {
|
|||||||
import { Events } from '../../events';
|
import { Events } from '../../events';
|
||||||
import type { playlist } from '@go/models';
|
import type { playlist } from '@go/models';
|
||||||
import '@components/duplicate-tracks-dialog/duplicate-tracks-dialog.js';
|
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';
|
import type { DuplicateTracksDialog } from '@components/duplicate-tracks-dialog/duplicate-tracks-dialog.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -188,6 +190,14 @@ export class PlaylistPicker extends LitElement {
|
|||||||
this.dispatchComplete();
|
this.dispatchComplete();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to add tracks to playlist:', 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 {
|
} finally {
|
||||||
this.loading = false;
|
this.loading = false;
|
||||||
}
|
}
|
||||||
@@ -223,6 +233,11 @@ export class PlaylistPicker extends LitElement {
|
|||||||
this.dispatchComplete();
|
this.dispatchComplete();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to create playlist:', err);
|
console.error('Failed to create playlist:', err);
|
||||||
|
notificationStore.transient({
|
||||||
|
key: 'playlist-create',
|
||||||
|
text: `Could not create “${name}”. ${describeError(err)}`,
|
||||||
|
detail: String(err),
|
||||||
|
});
|
||||||
} finally {
|
} finally {
|
||||||
this.loading = false;
|
this.loading = false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,10 @@ import {
|
|||||||
getActiveDragPlaylistId,
|
getActiveDragPlaylistId,
|
||||||
} from '@utils/drag-controller';
|
} from '@utils/drag-controller';
|
||||||
import { contextMenuStyles } from '@utils/context-menu-controller.js';
|
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 { FavoritesController } from '@store/controllers/favorites-controller';
|
||||||
import '@components/duplicate-tracks-dialog/duplicate-tracks-dialog.js';
|
import '@components/duplicate-tracks-dialog/duplicate-tracks-dialog.js';
|
||||||
import type { DuplicateTracksDialog } from '@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')
|
@customElement('playlist-view')
|
||||||
export class PlaylistView extends LitElement {
|
export class PlaylistView extends ViewLifecycleMixin(LitElement) {
|
||||||
private playlistCtrl = new PlaylistController(this);
|
private playlistCtrl = new PlaylistController(this);
|
||||||
private searchCtrl = new SearchController(this);
|
private searchCtrl = new SearchController(this);
|
||||||
private favCtrl = new FavoritesController(this);
|
private favCtrl = new FavoritesController(this);
|
||||||
@@ -801,23 +805,31 @@ export class PlaylistView extends LitElement {
|
|||||||
super.connectedCallback();
|
super.connectedCallback();
|
||||||
this.restoreSortPreferences();
|
this.restoreSortPreferences();
|
||||||
this.loadPlaylists();
|
this.loadPlaylists();
|
||||||
document.addEventListener(
|
}
|
||||||
|
|
||||||
|
protected override onViewActivate(): void {
|
||||||
|
this.listenWhileActive(
|
||||||
|
document,
|
||||||
'click',
|
'click',
|
||||||
this.closePlaylistCtxMenuHandler,
|
this.closePlaylistCtxMenuHandler,
|
||||||
);
|
);
|
||||||
document.addEventListener(
|
this.listenWhileActive(
|
||||||
|
document,
|
||||||
'contextmenu',
|
'contextmenu',
|
||||||
this.closePlaylistCtxMenuHandler,
|
this.closePlaylistCtxMenuHandler,
|
||||||
);
|
);
|
||||||
document.addEventListener(
|
this.listenWhileActive(
|
||||||
|
document,
|
||||||
'mousedown',
|
'mousedown',
|
||||||
this.playlistCtxMenuMousedownHandler,
|
this.playlistCtxMenuMousedownHandler,
|
||||||
);
|
);
|
||||||
document.addEventListener(
|
this.listenWhileActive(
|
||||||
|
document,
|
||||||
'click',
|
'click',
|
||||||
this.clearSelectionHandler,
|
this.clearSelectionHandler,
|
||||||
);
|
);
|
||||||
document.addEventListener(
|
this.listenWhileActive(
|
||||||
|
document,
|
||||||
'mousedown',
|
'mousedown',
|
||||||
this.sortDropdownCloseHandler,
|
this.sortDropdownCloseHandler,
|
||||||
);
|
);
|
||||||
@@ -830,27 +842,6 @@ export class PlaylistView extends LitElement {
|
|||||||
clearTimeout(this.scrollDebounceTimer);
|
clearTimeout(this.scrollDebounceTimer);
|
||||||
this.scrollDebounceTimer = null;
|
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() {
|
override updated() {
|
||||||
@@ -1351,13 +1342,33 @@ export class PlaylistView extends LitElement {
|
|||||||
break;
|
break;
|
||||||
case 'delete': {
|
case 'delete': {
|
||||||
if (this.selectedPlaylists.size > 1) {
|
if (this.selectedPlaylists.size > 1) {
|
||||||
const ids = [...this.selectedPlaylists]
|
const entries = [...this.selectedPlaylists]
|
||||||
.map(i => this.entries[i])
|
.map(i => this.entries[i])
|
||||||
.filter((e): e is PlaylistEntry => e !== undefined)
|
.filter((e): e is PlaylistEntry => e !== undefined);
|
||||||
.map(e => e.summary.ID);
|
const tracks = entries.reduce(
|
||||||
|
(sum, e) => sum + e.tracks.length,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
|
||||||
for (const id of ids) {
|
// The loop used to delete every selected playlist
|
||||||
await DeletePlaylist(id);
|
// 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();
|
this.selectedPlaylists = new Set();
|
||||||
@@ -1366,6 +1377,8 @@ export class PlaylistView extends LitElement {
|
|||||||
} else {
|
} else {
|
||||||
await this.handleDeletePlaylist(
|
await this.handleDeletePlaylist(
|
||||||
entry.summary.ID,
|
entry.summary.ID,
|
||||||
|
entry.summary.Name,
|
||||||
|
entry.tracks.length,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1380,15 +1393,54 @@ export class PlaylistView extends LitElement {
|
|||||||
|
|
||||||
private async handleDeletePlaylist(
|
private async handleDeletePlaylist(
|
||||||
playlistID: number,
|
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 {
|
try {
|
||||||
await DeletePlaylist(playlistID);
|
await DeletePlaylist(playlistID);
|
||||||
await this.refreshPlaylists();
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(
|
console.error('Failed to delete playlist:', err);
|
||||||
'Failed to delete playlist:',
|
notificationStore.persistent({
|
||||||
err,
|
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:',
|
'Failed to import playlist:',
|
||||||
err,
|
err,
|
||||||
);
|
);
|
||||||
this.importError =
|
this.importError = describeError(
|
||||||
err instanceof Error
|
err,
|
||||||
? err.message
|
'That playlist file could not be imported.',
|
||||||
: String(err);
|
);
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
this.importError = '';
|
this.importError = '';
|
||||||
}, 6000);
|
}, 6000);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { customElement, property, state } from 'lit/decorators.js';
|
|||||||
import { library } from '@go/models';
|
import { library } from '@go/models';
|
||||||
import { PreviewSmartPlaylist } from '@go/playlist/Service';
|
import { PreviewSmartPlaylist } from '@go/playlist/Service';
|
||||||
import { libraryStore } from '@store/library-store';
|
import { libraryStore } from '@store/library-store';
|
||||||
|
import { describeError } from '@utils/describe-error';
|
||||||
import { designTokens } from '../../styles/tokens.css';
|
import { designTokens } from '../../styles/tokens.css';
|
||||||
import '@components/combobox/combobox.ts';
|
import '@components/combobox/combobox.ts';
|
||||||
|
|
||||||
@@ -675,6 +676,14 @@ export class SmartPlaylistEditor extends LitElement {
|
|||||||
}, 300);
|
}, 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() {
|
private async runPreview() {
|
||||||
// Skip preview if any rule is incomplete
|
// Skip preview if any rule is incomplete
|
||||||
const incomplete = this.ruleRows.some(
|
const incomplete = this.ruleRows.some(
|
||||||
@@ -690,19 +699,30 @@ export class SmartPlaylistEditor extends LitElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const json = this.buildRulesJSON();
|
const json = this.buildRulesJSON();
|
||||||
|
const version = ++this.previewVersion;
|
||||||
|
|
||||||
this.previewLoading = true;
|
this.previewLoading = true;
|
||||||
this.previewError = '';
|
this.previewError = '';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const tracks = await PreviewSmartPlaylist(json);
|
const tracks = await PreviewSmartPlaylist(json);
|
||||||
|
|
||||||
|
if (version !== this.previewVersion) return;
|
||||||
|
|
||||||
this.previewTracks = tracks ?? [];
|
this.previewTracks = tracks ?? [];
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (version !== this.previewVersion) return;
|
||||||
|
|
||||||
console.error('Smart playlist preview failed:', error);
|
console.error('Smart playlist preview failed:', error);
|
||||||
this.previewError =
|
this.previewError = describeError(
|
||||||
error instanceof Error ? error.message : String(error);
|
error,
|
||||||
|
'The preview could not be built for these rules.',
|
||||||
|
);
|
||||||
this.previewTracks = [];
|
this.previewTracks = [];
|
||||||
} finally {
|
} 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;
|
type TrackMBIDs = library.TrackMBIDs;
|
||||||
import { ImageFilePicker, ReadFile } from '@go/frontendutil/FrontendUtil';
|
import { ImageFilePicker, ReadFile } from '@go/frontendutil/FrontendUtil';
|
||||||
import { libraryStore } from '../../store/library-store';
|
import { libraryStore } from '../../store/library-store';
|
||||||
import { EventsOn, EventsOff } from '@runtime/runtime';
|
import { EventsOn } from '@runtime/runtime';
|
||||||
import { Events } from '../../events';
|
import { Events } from '../../events';
|
||||||
|
|
||||||
import '@awesome.me/webawesome/dist/components/dialog/dialog.js';
|
import '@awesome.me/webawesome/dist/components/dialog/dialog.js';
|
||||||
@@ -1714,8 +1714,10 @@ export class TrackDetails extends LitElement {
|
|||||||
|
|
||||||
const changes = this.buildBatchChanges();
|
const changes = this.buildBatchChanges();
|
||||||
|
|
||||||
// Listen for progress events.
|
// Listen for progress events. Kept as the unsubscribe function
|
||||||
EventsOn(
|
// EventsOn returns, rather than EventsOff(name), which removes
|
||||||
|
// *every* listener for that event (errors.p3).
|
||||||
|
const stopProgress = EventsOn(
|
||||||
Events.BatchWriteProgress,
|
Events.BatchWriteProgress,
|
||||||
(data: {
|
(data: {
|
||||||
current: number;
|
current: number;
|
||||||
@@ -1762,7 +1764,7 @@ export class TrackDetails extends LitElement {
|
|||||||
],
|
],
|
||||||
};
|
};
|
||||||
} finally {
|
} finally {
|
||||||
EventsOff(Events.BatchWriteProgress);
|
stopProgress();
|
||||||
this.batchProgress = null;
|
this.batchProgress = null;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user