Files
yellowjacket/frontend/src/components/config-page/download-clients.ts
T
logan 2100f0022f fix(settings): raise every Settings control to the touch floor
#56 named 44px and #195 took the page header there. Settings is the
other half of #186 and much the larger one: swept on the reference
device (TLP301, 424x439) with all eleven config-sections expanded,
**120 controls** were under the floor -- not the 93 the issue's table
implies, and config-field is eight of them.

The bulk is behind the disclosures, which is why nobody had counted it:

    36  .column-arrow-btn          16x14   <- smallest in the app
    29  .column-toggle             16x16
    26  shortcut-capture button    80x25
     8  download format checkbox   16x16
     7  config-field select        335x30
     6  wa-input / wa-button       204x20, 185x21

**The density argument, measured rather than guessed, and it is
smaller than it looks.** The rows were already near the floor --
.column-item is 335x36 and .shortcut-row 335x37; it is the controls
*inside* them that were 14-25px. So a control grows into the row it
already occupies and the row goes 36 to 44. Measured after: the two
column lists went 373->447 and 690->850, +234px over the whole page.
Half a screen of extra scroll on a page that already scrolls, against
36 targets of 16x14.

**Settings is cheaper than the header was, and for a stated reason.**
There is no overflow fit on this page, so the header's "only width is
contested" rule does not bind at all and nothing here needs padding
with a negative margin. Height is a min-size, and the two square
controls can simply be square.

Three shapes, because one rule does not fit three kinds of control:

**A native checkbox is targeted through its label.** It cannot grow
its hit area without growing its paint, and a 44px checkbox is not
what anyone wants -- so .column-label is a real <label for> now and
the column's *name* is the target, 70x44 rather than 16x16. That is
the argument config-field already makes one file over ("a real label
association also makes the label text a click target, which is
behaviour, not annotation"), and here it is the whole fix. The
download formats already had the label; they only needed the height.

**The arrows take padding, which is invisible.** They carry
background: none and a transparent border, so 16x14 -> 44x44 changes
nothing anyone can see until hover -- #186's Direction exactly.

**Web Awesome's controls come from the library's own API.** Their
height is decided inside somebody else's shadow root, and
--wa-form-control-height is the variable that decides it. A custom
property inherits through a shadow boundary, so a :host declaration
reaches them; styles/wa-touch-floor.css.ts is that, once, adopted
rather than written at :root in index.css -- a :root rule would be
invisible to the component tier, which renders a component and no page
stylesheet.

**Two controls no sweep can see are fixed by name**, and they are the
trap this issue keeps setting. config-field's toggle has an <input>
that is opacity: 0; width: 0; height: 0, so a walk of every input
skips it as a zero-sized node -- what a finger hits is the <label>,
which measured **34x19**, smaller than anything in either of #186's
tables and absent from both. It is 44x44 with the pill still painted
at 2.5em x 1.4em and negative inline margins keeping it flush with the
inputs above. And shortcut-capture's reset button renders only for a
shortcut somebody has rebound, so a sweep of a fresh install never
meets it.

Verified on the device, same method as the sweep that filed it:
120 controls under the floor before, 42 after. All 42 are accounted
for -- 37 are checkboxes whose labels measure 70x44 and 57x44, four
are wa-input's inner input at 204x**42**, which is the control
measured *inside* its own 1px border (part=base is 238x44), and one is
the skip link, which #186 already ruled out as keyboard-only.

The e2e suite passes, top-bar-fit and header-action-overflow included
-- but that is **chromium**, which is half an answer, and saying so is
the whole of what #195's second commit was about. What can be argued
rather than run: library-filter is the only thing here in a container
that measures itself, and its width did not change. The fit measures
inline size.

Two page-header screenshots are refreshed because they are this
issue's own debris -- #195's taller sort control, merged last session,
with its references never re-recorded. app-sidebar's and
now-playing's are deliberately left: they are unrelated drift, and
blessing an unrelated screenshot is how the sidebar reference came to
still list a destination #27 retired. That is #196.
2026-08-21 22:55:54 -04:00

853 lines
30 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 { waTouchFloor } from '../../styles/wa-touch-floor.css';
import type {
DownloadDescriptor,
DownloadProvider,
ProviderField,
} from '@store/download-store';
import { downloadStore } from '@store/download-store';
import { GetDownloadPreferences, SetDownloadPreferences } from '@go/config/config.js';
import { SetPreferences } from '@go/download/service.js';
import type * as download from '@go/download/models.js';
import { Format } from '@go/download/models.js';
import { compact } from '@utils/binding';
import { describeError, explainError } from '@utils/describe-error';
import { confirmAction } from '@components/confirm-dialog/confirm-dialog';
import './config-section';
import '@components/jobs/job-panel';
import { pickDirectory } from '../../utils/pick-directory';
/**
* 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: Format; label: string }[] = [
{ value: Format.FormatFLAC, label: 'FLAC' },
{ value: Format.FormatALAC, label: 'ALAC' },
{ value: Format.FormatWAV, label: 'WAV' },
{ value: Format.FormatMP3, label: 'MP3' },
{ value: Format.FormatAAC, label: 'AAC' },
{ value: Format.FormatOGG, label: 'OGG' },
{ value: Format.FormatOpus, label: 'Opus' },
{ value: Format.FormatWMA, 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 = {
minKbps: 0,
maxKbps: 0,
preferredKbps: 0,
maxSizeMb: 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,
waTouchFloor,
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;
}
/* The checkbox is 16x16 and cannot grow without becoming
a 44px checkbox, but it is already wrapped in the label
that names it -- so the label is the target and only
needs the height (#186). Eight of them. */
.format-option {
display: flex;
align-items: center;
gap: 0.4em;
font-size: 0.9em;
cursor: pointer;
min-block-size: 44px;
}
`,
];
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>
`}
<!--
The Downloads view already shows every download's
lifecycle state; what it has never had is pause,
cancel and the log, which the Jobs tab carried (#27).
Renders nothing while nothing is downloading.
-->
<job-panel
kinds="download"
heading="Downloads in progress"
></job-panel>
</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">
<!-- Bitrate, not megabytes. A size means nothing
on its own: 300 MB is a generous single and a
suspiciously small boxset, and whoever fills
this in has no idea which release it will be
applied to. A rate is the same statement
divided by how long the music is, so one number
holds across an EP and an opera. -->
<div class="field-row">
<wa-input
label="Minimum bitrate (kbps)"
type="number"
min="0"
placeholder="No minimum"
.value=${this.prefs.minKbps ? String(this.prefs.minKbps) : ''}
@input=${(e: Event) => {
this.prefs = {
...this.prefs,
minKbps: Number((e.target as HTMLInputElement).value) || 0,
};
}}
></wa-input>
<wa-input
label="Maximum bitrate (kbps)"
type="number"
min="0"
placeholder="No maximum"
.value=${this.prefs.maxKbps ? String(this.prefs.maxKbps) : ''}
@input=${(e: Event) => {
this.prefs = {
...this.prefs,
maxKbps: Number((e.target as HTMLInputElement).value) || 0,
};
}}
></wa-input>
<wa-input
label="Preferred bitrate (kbps)"
type="number"
min="0"
placeholder="No preference"
.value=${this.prefs.preferredKbps
? String(this.prefs.preferredKbps)
: ''}
@input=${(e: Event) => {
this.prefs = {
...this.prefs,
preferredKbps:
Number((e.target as HTMLInputElement).value) || 0,
};
}}
></wa-input>
</div>
<div class="requires">
320 is the top of MP3; a FLAC rip is usually
5001000 depending on the music. Preferred
decides between copies that are otherwise equally
good — it never rules one out, which is what the
minimum and maximum are for.
</div>
<div class="field-row">
<wa-input
label="Never grab more than (MB)"
type="number"
min="0"
placeholder="No limit"
.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>
</div>
<div class="requires">
A ceiling on the download itself, in case a
mislabelled boxset gets through. Still a size
because it is a question about disk space, and
because it has to apply to a candidate whose
bitrate cannot be worked out at all.
</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 pickDirectory();
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 = compact(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: Format, 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;
}
}