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.
792 lines
27 KiB
TypeScript
792 lines
27 KiB
TypeScript
import { LitElement, html, css, nothing } from 'lit';
|
|
import { customElement, state } from 'lit/decorators.js';
|
|
import '@awesome.me/webawesome/dist/components/button/button.js';
|
|
import '@awesome.me/webawesome/dist/components/input/input.js';
|
|
import '@awesome.me/webawesome/dist/components/select/select.js';
|
|
import '@awesome.me/webawesome/dist/components/option/option.js';
|
|
import '@awesome.me/webawesome/dist/components/switch/switch.js';
|
|
import '@awesome.me/webawesome/dist/components/spinner/spinner.js';
|
|
import '@awesome.me/webawesome/dist/components/callout/callout.js';
|
|
import { designTokens } from '../../styles/tokens.css';
|
|
import type {
|
|
DownloadDescriptor,
|
|
DownloadProvider,
|
|
ProviderField,
|
|
} from '@store/download-store';
|
|
import { downloadStore } from '@store/download-store';
|
|
import { DirectoryPicker } from '@go/frontendutil/FrontendUtil';
|
|
import { GetDownloadPreferences, SetDownloadPreferences } from '@go/config/Config';
|
|
import { SetPreferences } from '@go/download/Service';
|
|
import type { download } from '@go/models';
|
|
import { describeError, explainError } from '@utils/describe-error';
|
|
import { confirmAction } from '@components/confirm-dialog/confirm-dialog';
|
|
import './config-section';
|
|
|
|
/**
|
|
* Allowed audio formats for auto-download, mirrored from
|
|
* backend/download/types.go's `Format` constants. `FormatUnknown` is
|
|
* deliberately excluded — it names "no format detected", not a format a
|
|
* user could opt into.
|
|
*/
|
|
const AUTO_DOWNLOAD_FORMATS: { value: string; label: string }[] = [
|
|
{ value: 'flac', label: 'FLAC' },
|
|
{ value: 'alac', label: 'ALAC' },
|
|
{ value: 'wav', label: 'WAV' },
|
|
{ value: 'mp3', label: 'MP3' },
|
|
{ value: 'aac', label: 'AAC' },
|
|
{ value: 'ogg', label: 'OGG' },
|
|
{ value: 'opus', label: 'Opus' },
|
|
{ value: 'wma', label: 'WMA' },
|
|
];
|
|
|
|
/**
|
|
* Download client configuration.
|
|
*
|
|
* The forms are rendered from the descriptors the backend publishes, not
|
|
* from anything hard-coded here, so adding a provider on the backend
|
|
* gives it a settings UI with no frontend change. That is also why
|
|
* secret fields render as password inputs purely on the descriptor's
|
|
* say-so — the frontend never needs to know which services have keys.
|
|
*/
|
|
@customElement('download-clients')
|
|
export class DownloadClients extends LitElement {
|
|
@state()
|
|
private providers: DownloadProvider[] = [];
|
|
|
|
@state()
|
|
private descriptors: DownloadDescriptor[] = [];
|
|
|
|
/** Provider being edited, or 'new' while adding one. */
|
|
@state()
|
|
private editing: number | 'new' | null = null;
|
|
|
|
/** Kind selected in the add form. */
|
|
@state()
|
|
private newKind = '';
|
|
|
|
/** Working copy of the form's field values. */
|
|
@state()
|
|
private draft: Record<string, string> = {};
|
|
|
|
@state()
|
|
private draftName = '';
|
|
|
|
/** Per-provider connection test results, keyed by provider ID. */
|
|
@state()
|
|
private testResults: Record<number, { ok: boolean; message: string }> = {};
|
|
|
|
@state()
|
|
private testing: number | null = null;
|
|
|
|
@state()
|
|
private errorMessage = '';
|
|
|
|
/** Working copy of the auto-download guardrails. */
|
|
@state()
|
|
private prefs: download.AutoDownloadPrefs = {
|
|
minSizeMb: 0,
|
|
maxSizeMb: 0,
|
|
preferredSizeMb: 0,
|
|
allowedFormats: [],
|
|
} as download.AutoDownloadPrefs;
|
|
|
|
@state()
|
|
private prefsSaving = false;
|
|
|
|
@state()
|
|
private prefsError = '';
|
|
|
|
@state()
|
|
private prefsSaved = false;
|
|
|
|
private unsubscribe: (() => void) | null = null;
|
|
|
|
override connectedCallback(): void {
|
|
super.connectedCallback();
|
|
|
|
this.unsubscribe = downloadStore.subscribe(() => this.syncFromStore());
|
|
|
|
void downloadStore.init().then(() => this.syncFromStore());
|
|
void this.loadPreferences();
|
|
}
|
|
|
|
override disconnectedCallback(): void {
|
|
super.disconnectedCallback();
|
|
|
|
this.unsubscribe?.();
|
|
this.unsubscribe = null;
|
|
}
|
|
|
|
private syncFromStore(): void {
|
|
this.providers = downloadStore.providers;
|
|
this.descriptors = downloadStore.descriptors;
|
|
}
|
|
|
|
private async loadPreferences(): Promise<void> {
|
|
try {
|
|
this.prefs = await GetDownloadPreferences();
|
|
} catch (err) {
|
|
console.error('Failed to load auto-download preferences:', err);
|
|
}
|
|
}
|
|
|
|
static override styles = [
|
|
designTokens,
|
|
css`
|
|
:host {
|
|
display: block;
|
|
}
|
|
|
|
.clients {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.6em;
|
|
}
|
|
|
|
.client {
|
|
display: grid;
|
|
grid-template-columns: 1fr auto;
|
|
gap: 0.75em;
|
|
align-items: center;
|
|
padding: 0.7em 0.85em;
|
|
border: 1px solid var(--wa-color-surface-border, #333);
|
|
border-radius: 8px;
|
|
}
|
|
|
|
.client-name {
|
|
font-weight: 600;
|
|
}
|
|
|
|
.client-meta {
|
|
font-size: 0.82em;
|
|
opacity: 0.7;
|
|
margin-top: 0.15em;
|
|
}
|
|
|
|
.client-actions {
|
|
display: flex;
|
|
gap: 0.4em;
|
|
align-items: center;
|
|
}
|
|
|
|
.test-result {
|
|
font-size: 0.8em;
|
|
margin-top: 0.35em;
|
|
}
|
|
|
|
.test-result.ok {
|
|
color: var(--wa-color-success-fill-loud, #4c9f70);
|
|
}
|
|
|
|
.test-result.fail {
|
|
color: var(--wa-color-danger-fill-loud, #c65f5f);
|
|
}
|
|
|
|
.form {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.7em;
|
|
padding: 0.9em;
|
|
border: 1px solid var(--wa-color-surface-border, #333);
|
|
border-radius: 8px;
|
|
margin-top: 0.6em;
|
|
}
|
|
|
|
.form-actions {
|
|
display: flex;
|
|
gap: 0.5em;
|
|
justify-content: flex-end;
|
|
margin-top: 0.3em;
|
|
}
|
|
|
|
.requires {
|
|
font-size: 0.82em;
|
|
opacity: 0.75;
|
|
}
|
|
|
|
.empty {
|
|
opacity: 0.7;
|
|
font-size: 0.9em;
|
|
padding: 0.5em 0;
|
|
}
|
|
|
|
.add-row {
|
|
margin-top: 0.8em;
|
|
}
|
|
|
|
.field-row {
|
|
display: flex;
|
|
gap: 0.5em;
|
|
align-items: flex-end;
|
|
}
|
|
|
|
.field-row wa-input {
|
|
flex: 1;
|
|
}
|
|
|
|
.field-row .browse-button {
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.format-options {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 0.4em 1em;
|
|
margin-top: 0.4em;
|
|
}
|
|
|
|
.format-option {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.4em;
|
|
font-size: 0.9em;
|
|
cursor: pointer;
|
|
}
|
|
`,
|
|
];
|
|
|
|
override render() {
|
|
return html`
|
|
<config-section
|
|
heading="Download Clients"
|
|
description="Connect services you already run to search for and download music. Nothing is enabled until you add a client."
|
|
>
|
|
${this.errorMessage
|
|
? html`<wa-callout variant="danger">${this.errorMessage}</wa-callout>`
|
|
: nothing}
|
|
|
|
<div class="clients">
|
|
${this.providers.length === 0 && this.editing !== 'new'
|
|
? html`<div class="empty">No download clients connected.</div>`
|
|
: nothing}
|
|
${this.providers.map((provider) => this.renderProvider(provider))}
|
|
</div>
|
|
|
|
${this.editing === 'new'
|
|
? this.renderAddForm()
|
|
: html`
|
|
<div class="add-row">
|
|
<wa-button size="small" @click=${this.startAdd}>
|
|
Add download client
|
|
</wa-button>
|
|
</div>
|
|
`}
|
|
</config-section>
|
|
|
|
<config-section
|
|
heading="Auto-download preferences"
|
|
description="Guardrails on what the pipeline may grab without asking — a manual pick is never restricted by these, only automatic ones."
|
|
>
|
|
${this.prefsError
|
|
? html`<wa-callout variant="danger">${this.prefsError}</wa-callout>`
|
|
: nothing}
|
|
|
|
<div class="form">
|
|
<div class="field-row">
|
|
<wa-input
|
|
label="Minimum size (MB)"
|
|
type="number"
|
|
min="0"
|
|
placeholder="No minimum"
|
|
.value=${this.prefs.minSizeMb ? String(this.prefs.minSizeMb) : ''}
|
|
@input=${(e: Event) => {
|
|
this.prefs = {
|
|
...this.prefs,
|
|
minSizeMb: Number((e.target as HTMLInputElement).value) || 0,
|
|
};
|
|
}}
|
|
></wa-input>
|
|
<wa-input
|
|
label="Maximum size (MB)"
|
|
type="number"
|
|
min="0"
|
|
placeholder="No maximum"
|
|
.value=${this.prefs.maxSizeMb ? String(this.prefs.maxSizeMb) : ''}
|
|
@input=${(e: Event) => {
|
|
this.prefs = {
|
|
...this.prefs,
|
|
maxSizeMb: Number((e.target as HTMLInputElement).value) || 0,
|
|
};
|
|
}}
|
|
></wa-input>
|
|
<wa-input
|
|
label="Preferred size (MB)"
|
|
type="number"
|
|
min="0"
|
|
placeholder="No preference"
|
|
.value=${this.prefs.preferredSizeMb
|
|
? String(this.prefs.preferredSizeMb)
|
|
: ''}
|
|
@input=${(e: Event) => {
|
|
this.prefs = {
|
|
...this.prefs,
|
|
preferredSizeMb:
|
|
Number((e.target as HTMLInputElement).value) || 0,
|
|
};
|
|
}}
|
|
></wa-input>
|
|
</div>
|
|
|
|
<div>
|
|
<div class="requires">
|
|
Allowed formats — leave all unchecked to allow any format.
|
|
</div>
|
|
<div class="format-options">
|
|
${AUTO_DOWNLOAD_FORMATS.map(
|
|
(format) => html`
|
|
<label class="format-option">
|
|
<input
|
|
type="checkbox"
|
|
.checked=${(this.prefs.allowedFormats ?? []).includes(
|
|
format.value,
|
|
)}
|
|
@change=${(e: Event) =>
|
|
this.toggleFormat(
|
|
format.value,
|
|
(e.target as HTMLInputElement).checked,
|
|
)}
|
|
/>
|
|
${format.label}
|
|
</label>
|
|
`,
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div class="form-actions">
|
|
${this.prefsSaved
|
|
? html`<span class="test-result ok">Saved.</span>`
|
|
: nothing}
|
|
<wa-button
|
|
size="small"
|
|
variant="brand"
|
|
?disabled=${this.prefsSaving}
|
|
@click=${this.savePreferences}
|
|
>
|
|
${this.prefsSaving
|
|
? html`<wa-spinner></wa-spinner>`
|
|
: 'Save preferences'}
|
|
</wa-button>
|
|
</div>
|
|
</div>
|
|
</config-section>
|
|
`;
|
|
}
|
|
|
|
private renderProvider(provider: DownloadProvider) {
|
|
const descriptor = this.descriptorFor(provider.kind);
|
|
const test = this.testResults[provider.id];
|
|
|
|
if (this.editing === provider.id) {
|
|
return this.renderEditForm(provider);
|
|
}
|
|
|
|
return html`
|
|
<div class="client">
|
|
<div>
|
|
<div class="client-name">${provider.name}</div>
|
|
<div class="client-meta">
|
|
${descriptor?.name ?? provider.kind} ·
|
|
${provider.enabled ? 'Enabled' : 'Disabled'} ·
|
|
priority ${provider.priority}
|
|
</div>
|
|
${test
|
|
? html`<div class="test-result ${test.ok ? 'ok' : 'fail'}">
|
|
${test.message}
|
|
</div>`
|
|
: nothing}
|
|
</div>
|
|
<div class="client-actions">
|
|
<wa-button
|
|
size="small"
|
|
appearance="plain"
|
|
?disabled=${this.testing === provider.id}
|
|
@click=${() => this.testProvider(provider)}
|
|
>
|
|
${this.testing === provider.id
|
|
? html`<wa-spinner></wa-spinner>`
|
|
: 'Test'}
|
|
</wa-button>
|
|
<wa-button
|
|
size="small"
|
|
appearance="plain"
|
|
@click=${() => this.startEdit(provider)}
|
|
>
|
|
Edit
|
|
</wa-button>
|
|
<wa-button
|
|
size="small"
|
|
appearance="plain"
|
|
variant="danger"
|
|
@click=${() => void this.deleteProvider(provider)}
|
|
>
|
|
Remove
|
|
</wa-button>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
private renderAddForm() {
|
|
const descriptor = this.descriptorFor(this.newKind);
|
|
|
|
return html`
|
|
<div class="form">
|
|
<wa-select
|
|
label="Client type"
|
|
.value=${this.newKind}
|
|
@change=${this.onKindChange}
|
|
>
|
|
${this.descriptors.map(
|
|
(d) => html`<wa-option value=${d.kind}>${d.name}</wa-option>`,
|
|
)}
|
|
</wa-select>
|
|
|
|
${descriptor
|
|
? html`
|
|
<div class="requires">
|
|
${descriptor.summary}
|
|
${descriptor.requiresExternal
|
|
? html`<br />Requires a running
|
|
${descriptor.requiresExternal} instance.`
|
|
: nothing}
|
|
</div>
|
|
|
|
<wa-input
|
|
label="Name"
|
|
.value=${this.draftName}
|
|
@input=${(e: Event) => {
|
|
this.draftName = (e.target as HTMLInputElement).value;
|
|
}}
|
|
></wa-input>
|
|
|
|
${this.renderFields(descriptor)}
|
|
`
|
|
: nothing}
|
|
|
|
<div class="form-actions">
|
|
<wa-button size="small" appearance="plain" @click=${this.cancelEdit}>
|
|
Cancel
|
|
</wa-button>
|
|
<wa-button
|
|
size="small"
|
|
variant="brand"
|
|
?disabled=${!descriptor}
|
|
@click=${this.saveNew}
|
|
>
|
|
Add
|
|
</wa-button>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
private renderEditForm(provider: DownloadProvider) {
|
|
const descriptor = this.descriptorFor(provider.kind);
|
|
|
|
return html`
|
|
<div class="form">
|
|
<wa-input
|
|
label="Name"
|
|
.value=${this.draftName}
|
|
@input=${(e: Event) => {
|
|
this.draftName = (e.target as HTMLInputElement).value;
|
|
}}
|
|
></wa-input>
|
|
|
|
${descriptor ? this.renderFields(descriptor, provider) : nothing}
|
|
|
|
<wa-input
|
|
label="Priority"
|
|
type="number"
|
|
.value=${String(provider.priority)}
|
|
@input=${(e: Event) => {
|
|
this.draft['__priority'] = (e.target as HTMLInputElement).value;
|
|
}}
|
|
></wa-input>
|
|
|
|
<wa-switch
|
|
?checked=${provider.enabled}
|
|
@change=${(e: Event) => {
|
|
this.draft['__enabled'] = (e.target as HTMLInputElement)
|
|
.checked
|
|
? '1'
|
|
: '';
|
|
}}
|
|
>
|
|
Enabled
|
|
</wa-switch>
|
|
|
|
<div class="form-actions">
|
|
<wa-button size="small" appearance="plain" @click=${this.cancelEdit}>
|
|
Cancel
|
|
</wa-button>
|
|
<wa-button
|
|
size="small"
|
|
variant="brand"
|
|
@click=${() => this.saveEdit(provider)}
|
|
>
|
|
Save
|
|
</wa-button>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
/** Renders one input per descriptor field, plus a folder browse
|
|
* button for path fields and an "already set" placeholder for
|
|
* secrets the provider already has a stored value for. */
|
|
private renderFields(descriptor: DownloadDescriptor, provider?: DownloadProvider) {
|
|
return (descriptor.fields ?? []).map((field) => {
|
|
const isSet = field.secret && provider?.setSecrets?.[field.key];
|
|
const placeholder = isSet
|
|
? '•••••••• (unchanged — enter a new value to replace it)'
|
|
: (field.placeholder ?? '');
|
|
|
|
return html`
|
|
<div class="field-row">
|
|
<wa-input
|
|
label=${field.label}
|
|
placeholder=${placeholder}
|
|
type=${field.secret ? 'password' : 'text'}
|
|
.value=${this.draft[field.key] ?? ''}
|
|
@input=${(e: Event) => {
|
|
this.draft = {
|
|
...this.draft,
|
|
[field.key]: (e.target as HTMLInputElement).value,
|
|
};
|
|
}}
|
|
>
|
|
${field.help ? html`<span slot="hint">${field.help}</span>` : nothing}
|
|
</wa-input>
|
|
${field.path
|
|
? html`
|
|
<wa-button
|
|
size="small"
|
|
appearance="outlined"
|
|
class="browse-button"
|
|
@click=${() => this.browseForFolder(field)}
|
|
>
|
|
Browse
|
|
</wa-button>
|
|
`
|
|
: nothing}
|
|
</div>
|
|
`;
|
|
});
|
|
}
|
|
|
|
private browseForFolder = async (field: ProviderField) => {
|
|
try {
|
|
const dir = await DirectoryPicker();
|
|
|
|
if (dir) {
|
|
this.draft = { ...this.draft, [field.key]: dir };
|
|
}
|
|
} catch (err) {
|
|
console.error('Failed to open directory picker:', err);
|
|
}
|
|
};
|
|
|
|
private descriptorFor(kind: string): DownloadDescriptor | undefined {
|
|
return this.descriptors.find((d) => d.kind === kind);
|
|
}
|
|
|
|
private startAdd = () => {
|
|
this.editing = 'new';
|
|
this.errorMessage = '';
|
|
this.newKind = this.descriptors[0]?.kind ?? '';
|
|
this.draftName = this.descriptorFor(this.newKind)?.name ?? '';
|
|
this.draft = this.defaultsFor(this.newKind);
|
|
};
|
|
|
|
private startEdit(provider: DownloadProvider) {
|
|
this.editing = provider.id;
|
|
this.errorMessage = '';
|
|
this.draftName = provider.name;
|
|
// Secrets are never sent back to the frontend, so their fields
|
|
// start blank; a blank secret on save means "leave it alone"
|
|
// rather than "clear it".
|
|
this.draft = { ...(provider.settings ?? {}) };
|
|
}
|
|
|
|
private cancelEdit = () => {
|
|
this.editing = null;
|
|
this.draft = {};
|
|
this.errorMessage = '';
|
|
};
|
|
|
|
private onKindChange = (event: Event) => {
|
|
this.newKind = (event.target as HTMLInputElement).value;
|
|
this.draftName = this.descriptorFor(this.newKind)?.name ?? '';
|
|
this.draft = this.defaultsFor(this.newKind);
|
|
};
|
|
|
|
private defaultsFor(kind: string): Record<string, string> {
|
|
const descriptor = this.descriptorFor(kind);
|
|
const out: Record<string, string> = {};
|
|
|
|
for (const field of descriptor?.fields ?? []) {
|
|
if (field.default) out[field.key] = field.default;
|
|
}
|
|
|
|
return out;
|
|
}
|
|
|
|
private saveNew = async () => {
|
|
this.errorMessage = '';
|
|
|
|
try {
|
|
await downloadStore.addProvider(
|
|
this.newKind,
|
|
this.draftName || this.newKind,
|
|
this.cleanDraft(),
|
|
);
|
|
|
|
this.cancelEdit();
|
|
} catch (err) {
|
|
console.error('Failed to add download client:', err);
|
|
this.errorMessage = explainError(
|
|
err,
|
|
'That client could not be saved.',
|
|
);
|
|
}
|
|
};
|
|
|
|
private async saveEdit(provider: DownloadProvider) {
|
|
this.errorMessage = '';
|
|
|
|
const priority = this.draft['__priority']
|
|
? Number(this.draft['__priority'])
|
|
: provider.priority;
|
|
|
|
const enabled =
|
|
'__enabled' in this.draft
|
|
? this.draft['__enabled'] === '1'
|
|
: provider.enabled;
|
|
|
|
try {
|
|
await downloadStore.updateProvider(
|
|
provider.id,
|
|
this.draftName || provider.name,
|
|
enabled,
|
|
priority,
|
|
this.cleanDraft(),
|
|
);
|
|
|
|
this.cancelEdit();
|
|
} catch (err) {
|
|
console.error('Failed to save download client:', err);
|
|
this.errorMessage = explainError(
|
|
err,
|
|
'Those changes could not be saved.',
|
|
);
|
|
}
|
|
}
|
|
|
|
/** Strips the form's internal bookkeeping keys before saving. */
|
|
private cleanDraft(): Record<string, string> {
|
|
const out: Record<string, string> = {};
|
|
|
|
for (const [key, value] of Object.entries(this.draft)) {
|
|
if (!key.startsWith('__')) out[key] = value;
|
|
}
|
|
|
|
return out;
|
|
}
|
|
|
|
/**
|
|
* Removing a client discards its stored credentials, which cannot
|
|
* be recovered — and it used to happen on one click (errors.m4).
|
|
*/
|
|
private async deleteProvider(provider: DownloadProvider) {
|
|
const ok = await confirmAction({
|
|
title: `Remove “${provider.name}”?`,
|
|
message:
|
|
'YellowJacket will stop using this client for downloads.',
|
|
impact:
|
|
'Its stored credentials are deleted and cannot be recovered.',
|
|
confirmLabel: 'Remove client',
|
|
danger: true,
|
|
});
|
|
|
|
if (!ok) return;
|
|
|
|
this.errorMessage = '';
|
|
|
|
try {
|
|
await downloadStore.deleteProvider(provider.id);
|
|
} catch (err) {
|
|
console.error('Failed to remove download client:', err);
|
|
this.errorMessage = describeError(
|
|
err,
|
|
'That client could not be removed.',
|
|
);
|
|
}
|
|
}
|
|
|
|
private async testProvider(provider: DownloadProvider) {
|
|
this.testing = provider.id;
|
|
|
|
try {
|
|
await downloadStore.testProvider(provider.id);
|
|
|
|
this.testResults = {
|
|
...this.testResults,
|
|
[provider.id]: { ok: true, message: 'Connected.' },
|
|
};
|
|
} catch (err) {
|
|
// Deliberately verbatim: a connection test's error is the
|
|
// user's debugging tool for a misconfigured client, and is
|
|
// the documented exception to describeError() (errors.M9).
|
|
this.testResults = {
|
|
...this.testResults,
|
|
[provider.id]: { ok: false, message: String(err) },
|
|
};
|
|
} finally {
|
|
this.testing = null;
|
|
}
|
|
}
|
|
|
|
private toggleFormat(format: string, checked: boolean): void {
|
|
const current = this.prefs.allowedFormats ?? [];
|
|
const allowedFormats = checked
|
|
? [...current, format]
|
|
: current.filter((f) => f !== format);
|
|
|
|
this.prefs = { ...this.prefs, allowedFormats };
|
|
}
|
|
|
|
/**
|
|
* Saves the guardrails both to disk and to the running download
|
|
* manager in one action — persistence alone would leave the setting
|
|
* inert until restart, which is exactly the bug this mirrors away
|
|
* from (see `config.Library`'s prior persist-without-apply gap).
|
|
*/
|
|
private savePreferences = async () => {
|
|
this.prefsSaving = true;
|
|
this.prefsError = '';
|
|
this.prefsSaved = false;
|
|
|
|
try {
|
|
await SetDownloadPreferences(this.prefs);
|
|
await SetPreferences(this.prefs);
|
|
this.prefsSaved = true;
|
|
} catch (err) {
|
|
console.error('Failed to save download preferences:', err);
|
|
this.prefsError = describeError(
|
|
err,
|
|
'Those preferences could not be saved.',
|
|
);
|
|
} finally {
|
|
this.prefsSaving = false;
|
|
}
|
|
};
|
|
}
|
|
|
|
declare global {
|
|
interface HTMLElementTagNameMap {
|
|
'download-clients': DownloadClients;
|
|
}
|
|
}
|