mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-15 05:36:17 -08:00
* perf(web): provide form-field defaults per component, not at the root app.config.ts provided MAT_FORM_FIELD_DEFAULT_OPTIONS in the root injector. Importing the token kept @angular/material/form-field in main.js, and Material 22.2's form field imports @angular/forms/signals, which would add @angular/forms to startup as well (the reason the Angular 22.2 dependency update exceeds the initial-bytes ratchet). Every form field renders in a lazy chunk, so the outline + dynamic defaults now come from provideFormFieldDefaults() (@iptvnator/ui/components/form-field-defaults) on each component that renders <mat-form-field>. tools/nx/check-form-field-defaults.mjs (ui:form-field-defaults:validate, run in CI) reports a component that renders a form field without the provider, and any use of the token outside the shared provider. The Xtream import form test asserts outlined, dynamically sized fields with no root provider; without the component provider they render as fill. renderer.initialBytes: 1,596,201 -> 1,534,604 bytes (-61,597) on a local production build; the baseline is lowered to the measured value. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * perf(web): cite #1928 as the initial-bytes baseline evidence Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(ui): check form-field providers per component on the TypeScript AST The guard matched templates and providers with regular expressions over the whole file: a quoted inline template went unread, one component's provider covered another in the same file, an earlier array-valued provider hid the call, and provideFormFieldDefaults() in app.config or a route passed although it loads Material's form field on startup again. It now reads each @Component decorator's own template and providers array, and reports any provideFormFieldDefaults() call outside one. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: 4gray <fourgray@proton.me> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
280 lines
10 KiB
TypeScript
280 lines
10 KiB
TypeScript
import {
|
|
Component,
|
|
computed,
|
|
input,
|
|
output,
|
|
ViewEncapsulation,
|
|
ChangeDetectionStrategy,
|
|
} from '@angular/core';
|
|
import { FormGroup, ReactiveFormsModule } from '@angular/forms';
|
|
import { MatButtonModule } from '@angular/material/button';
|
|
import { MatFormFieldModule } from '@angular/material/form-field';
|
|
import { MatIconModule } from '@angular/material/icon';
|
|
import { MatSelectModule } from '@angular/material/select';
|
|
import { TranslateModule } from '@ngx-translate/core';
|
|
import {
|
|
AppUpdateChannel,
|
|
APP_UPDATE_CHANNELS,
|
|
ELECTRON_BRIDGE_APP_UPDATE_STATUSES,
|
|
ElectronBridgeAppUpdateStatus,
|
|
} from '@iptvnator/shared/interfaces';
|
|
import { UpdateChannelOption } from './settings.models';
|
|
import { markSectionForCheckOnFormEvents } from './settings-section-form-render';
|
|
import { provideFormFieldDefaults } from '@iptvnator/ui/components/form-field-defaults';
|
|
|
|
interface SettingsUpdateStatusChip {
|
|
readonly labelKey: string;
|
|
readonly tone: 'accent' | 'success' | 'warn' | 'neutral';
|
|
}
|
|
|
|
@Component({
|
|
selector: 'app-settings-about-section',
|
|
providers: [provideFormFieldDefaults()],
|
|
imports: [
|
|
MatButtonModule,
|
|
MatFormFieldModule,
|
|
MatIconModule,
|
|
MatSelectModule,
|
|
ReactiveFormsModule,
|
|
TranslateModule,
|
|
],
|
|
templateUrl: './settings-about-section.component.html',
|
|
encapsulation: ViewEncapsulation.None,
|
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
styleUrls: ['./settings-about-section.component.scss'],
|
|
})
|
|
export class SettingsAboutSectionComponent {
|
|
readonly isDesktop = input(false);
|
|
readonly version = input<string | undefined>();
|
|
readonly buildCommit = input<string | undefined>();
|
|
readonly updateMessage = input<string | undefined>();
|
|
readonly appUpdateStatus = input<ElectronBridgeAppUpdateStatus | null>(
|
|
null
|
|
);
|
|
/**
|
|
* The shared settings form; the channel select lives in it so a change
|
|
* goes through the same Save and unsaved-changes flow as every other
|
|
* setting. Absent in hosts that only render the version block.
|
|
*/
|
|
readonly form = input<FormGroup | null>(null);
|
|
|
|
constructor() {
|
|
// Parent patches (Discard, backup import) change the form outside
|
|
// this OnPush section's events.
|
|
markSectionForCheckOnFormEvents(this.form);
|
|
}
|
|
|
|
readonly updateChannelOptions = input<UpdateChannelOption[]>([]);
|
|
|
|
readonly buildCommitShort = computed(() => {
|
|
const commit = this.buildCommit()?.trim();
|
|
|
|
return commit ? commit.slice(0, 7) : undefined;
|
|
});
|
|
|
|
readonly checkForAppUpdate = output<void>();
|
|
/**
|
|
* Emitted instead of a plain check while the channel select shows a
|
|
* channel the updater has not been told about yet. The host saves the
|
|
* form; the main process re-checks on its own once the saved channel
|
|
* changes, so one click yields the verdict the user is looking at.
|
|
*/
|
|
readonly saveAndCheckForAppUpdate = output<void>();
|
|
readonly downloadAppUpdate = output<void>();
|
|
readonly installAppUpdate = output<void>();
|
|
readonly openManualAppUpdate = output<void>();
|
|
readonly openAppUpdateReleaseNotes = output<void>();
|
|
/** The nightly warning offers the backup page before switching. */
|
|
readonly openBackup = output<void>();
|
|
|
|
readonly canSelectUpdateChannel = computed(
|
|
() =>
|
|
this.isDesktop() &&
|
|
this.form() !== null &&
|
|
this.updateChannelOptions().length > 0
|
|
);
|
|
|
|
/** A nightly build on the stable channel waits for the next stable release. */
|
|
readonly isNightlyBuildOnStableChannel = computed(() => {
|
|
const status = this.appUpdateStatus();
|
|
|
|
return (
|
|
status?.installedChannel === 'nightly' &&
|
|
status.channel === 'stable'
|
|
);
|
|
});
|
|
|
|
/**
|
|
* The channel the select currently shows. Read off the control on every
|
|
* check rather than tracked as a signal: the section is eagerly checked
|
|
* and the sibling nightly warning already reads the control the same way.
|
|
*/
|
|
draftUpdateChannel(): AppUpdateChannel | null {
|
|
const value: unknown = this.form()?.get('updateChannel')?.value;
|
|
|
|
return APP_UPDATE_CHANNELS.includes(value as AppUpdateChannel)
|
|
? (value as AppUpdateChannel)
|
|
: null;
|
|
}
|
|
|
|
/**
|
|
* True while the select shows a channel other than the one the last
|
|
* verdict describes. The verdict on screen is then about the OTHER
|
|
* channel, and a plain "check again" would silently repeat it.
|
|
*/
|
|
hasPendingChannelChange(): boolean {
|
|
const draft = this.draftUpdateChannel();
|
|
const applied = this.appUpdateStatus()?.channel;
|
|
|
|
return draft !== null && applied !== undefined && draft !== applied;
|
|
}
|
|
|
|
/**
|
|
* Whether Save would make the updater re-check. A download in flight or
|
|
* finished belongs to the previous channel and is kept, so saving there
|
|
* changes the channel without a new verdict and the plain check stays.
|
|
*/
|
|
canSaveAndCheckForAppUpdate(): boolean {
|
|
const status = this.appUpdateStatus()?.status;
|
|
|
|
return (
|
|
this.hasPendingChannelChange() &&
|
|
status !== ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Downloading &&
|
|
status !== ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Downloaded
|
|
);
|
|
}
|
|
|
|
channelLabelKey(channel: AppUpdateChannel | null | undefined): string {
|
|
return channel === 'nightly'
|
|
? 'SETTINGS.APP_UPDATE_CHANNEL_NIGHTLY'
|
|
: 'SETTINGS.APP_UPDATE_CHANNEL_STABLE';
|
|
}
|
|
|
|
/**
|
|
* The channel the verdict on screen belongs to. `verdictChannel` is
|
|
* stamped by every check; a channel change saved while a download was
|
|
* running or done keeps that download, so the two can differ until
|
|
* the new channel is checked.
|
|
*/
|
|
verdictChannel(): AppUpdateChannel | null {
|
|
const status = this.appUpdateStatus();
|
|
|
|
return status?.verdictChannel ?? status?.channel ?? null;
|
|
}
|
|
|
|
/** The kept download was found on another channel than the saved one. */
|
|
hasRetainedOtherChannelVerdict(): boolean {
|
|
const status = this.appUpdateStatus();
|
|
|
|
return (
|
|
status?.verdictChannel !== undefined &&
|
|
status.verdictChannel !== status.channel
|
|
);
|
|
}
|
|
|
|
readonly appliedChannelLabelKey = computed(() => {
|
|
const channel = this.verdictChannel();
|
|
|
|
return channel ? this.channelLabelKey(channel) : null;
|
|
});
|
|
|
|
/** The one-word verdict beside the app name in the hero. */
|
|
readonly updateStatusChip = computed<SettingsUpdateStatusChip | null>(
|
|
() => {
|
|
switch (this.appUpdateStatus()?.status) {
|
|
case ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Available:
|
|
case ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Downloading:
|
|
case ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Downloaded:
|
|
return {
|
|
labelKey: 'SETTINGS.UPDATE_AVAILABLE_CHIP',
|
|
tone: 'accent',
|
|
};
|
|
case ELECTRON_BRIDGE_APP_UPDATE_STATUSES.NotAvailable:
|
|
return {
|
|
labelKey: 'SETTINGS.UPDATE_UP_TO_DATE',
|
|
tone: 'success',
|
|
};
|
|
case ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Checking:
|
|
return {
|
|
labelKey: 'SETTINGS.APP_UPDATE_CHECKING',
|
|
tone: 'neutral',
|
|
};
|
|
case ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Error:
|
|
return {
|
|
labelKey: 'SETTINGS.APP_UPDATE_ERROR',
|
|
tone: 'warn',
|
|
};
|
|
default:
|
|
return null;
|
|
}
|
|
}
|
|
);
|
|
|
|
readonly isAppUpdateBusy = computed(() => {
|
|
const status = this.appUpdateStatus()?.status;
|
|
|
|
return (
|
|
status === ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Checking ||
|
|
status === ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Downloading
|
|
);
|
|
});
|
|
|
|
readonly canDownloadAppUpdate = computed(() => {
|
|
const status = this.appUpdateStatus();
|
|
|
|
return (
|
|
status?.supportedSelfUpdate === true &&
|
|
status.status === ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Available
|
|
);
|
|
});
|
|
|
|
readonly canInstallAppUpdate = computed(() => {
|
|
const status = this.appUpdateStatus();
|
|
|
|
return (
|
|
status?.supportedSelfUpdate === true &&
|
|
status.status === ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Downloaded
|
|
);
|
|
});
|
|
|
|
readonly canOpenAppUpdateReleaseNotes = computed(() => {
|
|
const status = this.appUpdateStatus();
|
|
|
|
return Boolean(
|
|
status?.currentVersion &&
|
|
status.status !== ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Checking
|
|
);
|
|
});
|
|
|
|
readonly canOpenManualAppUpdate = computed(() => {
|
|
const status = this.appUpdateStatus();
|
|
|
|
return Boolean(status && !status.supportedSelfUpdate);
|
|
});
|
|
|
|
readonly appUpdateStatusLabelKey = computed(() => {
|
|
const status =
|
|
this.appUpdateStatus()?.status ??
|
|
ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Idle;
|
|
|
|
switch (status) {
|
|
case ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Checking:
|
|
return 'SETTINGS.APP_UPDATE_CHECKING';
|
|
case ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Available:
|
|
return 'SETTINGS.APP_UPDATE_AVAILABLE';
|
|
case ELECTRON_BRIDGE_APP_UPDATE_STATUSES.NotAvailable:
|
|
return 'SETTINGS.APP_UPDATE_NOT_AVAILABLE';
|
|
case ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Downloading:
|
|
return 'SETTINGS.APP_UPDATE_DOWNLOADING';
|
|
case ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Downloaded:
|
|
return 'SETTINGS.APP_UPDATE_DOWNLOADED';
|
|
case ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Unsupported:
|
|
return 'SETTINGS.APP_UPDATE_IDLE';
|
|
case ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Error:
|
|
return 'SETTINGS.APP_UPDATE_ERROR';
|
|
default:
|
|
return 'SETTINGS.APP_UPDATE_IDLE';
|
|
}
|
|
});
|
|
}
|