diff --git a/apps/electron-backend-e2e/src/theme-tokens.e2e.ts b/apps/electron-backend-e2e/src/theme-tokens.e2e.ts index 6782598e9..4db1cecd8 100644 --- a/apps/electron-backend-e2e/src/theme-tokens.e2e.ts +++ b/apps/electron-backend-e2e/src/theme-tokens.e2e.ts @@ -150,7 +150,12 @@ test.describe('Theme tokens', () => { ).toBeGreaterThanOrEqual(3); // A floated label inherits the field's text size and renders - // at 75% of it; it must stay legible. + // at 75% of it; it must stay legible. The settings selects + // have no floating label any more (the row title names + // them): the recording folder field on Playback keeps one, + // and focusing it floats the label. + await openSettingsSection(page, 'playback'); + await page.locator('#recordingFolder').focus(); const floated = page .locator('mat-form-field .mdc-floating-label--float-above') .first(); diff --git a/apps/web/src/app/settings/settings-form.facade.ts b/apps/web/src/app/settings/settings-form.facade.ts index b7fc60785..1dbd357c5 100644 --- a/apps/web/src/app/settings/settings-form.facade.ts +++ b/apps/web/src/app/settings/settings-form.facade.ts @@ -1,4 +1,10 @@ -import { DestroyRef, inject, Injectable, signal } from '@angular/core'; +import { + DestroyRef, + inject, + Injectable, + signal, + untracked, +} from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { FormArray, FormBuilder } from '@angular/forms'; import { EpgRuntimeBridgeService } from '@iptvnator/epg/data-access'; @@ -20,6 +26,7 @@ import { diffSettingsValues, SETTINGS_RESTART_CONTROLS, } from './settings-change-tracking'; +import { SettingsLaunchValuesService } from './settings-launch-values.service'; import { applyEpgUrlsToFormArray, createEpgUrlControl, @@ -40,6 +47,7 @@ export class SettingsFormFacade { private readonly destroyRef = inject(DestroyRef); private readonly epgBridge = inject(EpgRuntimeBridgeService); private readonly formBuilder = inject(FormBuilder); + private readonly launchValues = inject(SettingsLaunchValuesService); private readonly runtime = inject(RuntimeCapabilitiesService); private readonly settingsService = inject(SettingsService); private readonly settingsSnackbar = inject(SettingsSnackbarService); @@ -61,7 +69,7 @@ export class SettingsFormFacade { */ readonly changedPaths = signal([]); - /** Controls whose saved change waits for a restart (cleared on Later). */ + /** Controls whose saved value differs from what the running app uses. */ readonly restartPendingControls = signal([]); private savedSnapshot: unknown = {}; @@ -105,6 +113,22 @@ export class SettingsFormFacade { this.setEpgUrls(currentSettings.epgUrl); } this.takeSavedSnapshot(); + // The app launched with the stored values; recorded once per app run, + // not per settings visit, so a later visit compares against launch. + this.launchValues.captureOnce( + (control) => this.form.get(control)?.value + ); + this.refreshRestartPending(); + } + + /** + * What the running app actually uses for a restart control when that + * differs from the stored value (a frame-copy opt-in the engine could + * not honour). Re-evaluates the pending notice. + */ + setRunningValue(control: string, value: unknown): void { + this.launchValues.set(control, value); + this.refreshRestartPending(); } bindDashboardControlsEnabledState(): void { @@ -187,11 +211,6 @@ export class SettingsFormFacade { this.form, this.settingsStore.getSettings() ); - // Read before the write: a successful save resets the diff. - const restartControls = this.changedPaths() - .map((path) => path.split('.')[0]) - .filter((control) => control in SETTINGS_RESTART_CONTROLS); - let cleanupError: EpgSourceReconciliationError | undefined; try { await this.settingsStore.updateSettings(settings, { @@ -205,14 +224,7 @@ export class SettingsFormFacade { } if (!cleanupError) { onSaved(); - if (restartControls.length > 0) { - this.restartPendingControls.set([ - ...new Set([ - ...this.restartPendingControls(), - ...restartControls, - ]), - ]); - } + this.refreshRestartPending(); } if (window.electron) { @@ -243,6 +255,30 @@ export class SettingsFormFacade { ); } + /** + * Restart controls whose SAVED value the running app does not use yet. + * Compares the last saved snapshot, never the draft form: a staged but + * unsaved edit must not raise or clear the reminder. Called from an + * effect (the engine probe), so the current list is read untracked and + * only a changed list is written: a fresh array on every run would + * re-trigger that effect for ever. + */ + private refreshRestartPending(): void { + const running = this.launchValues.get(); + const saved = (this.savedSnapshot ?? {}) as Record; + const pending = Object.keys(SETTINGS_RESTART_CONTROLS).filter( + (control) => + control in running && saved[control] !== running[control] + ); + const current = untracked(this.restartPendingControls); + if ( + current.length !== pending.length || + current.some((control, index) => control !== pending[index]) + ) { + this.restartPendingControls.set(pending); + } + } + private takeSavedSnapshot(): void { this.savedSnapshot = this.form.getRawValue(); this.refreshChangedPaths(); diff --git a/apps/web/src/app/settings/settings-launch-values.service.ts b/apps/web/src/app/settings/settings-launch-values.service.ts new file mode 100644 index 000000000..d169e73d6 --- /dev/null +++ b/apps/web/src/app/settings/settings-launch-values.service.ts @@ -0,0 +1,37 @@ +import { Injectable } from '@angular/core'; +import { SETTINGS_RESTART_CONTROLS } from './settings-change-tracking'; + +/** + * Values of the restart-only settings the running app started with. Root + * provided on purpose: the settings page (and its form facade) is recreated + * every time it opens, but what the app launched with does not change until + * it restarts, so the restart notice must compare against one app-lifetime + * record. + */ +@Injectable({ providedIn: 'root' }) +export class SettingsLaunchValuesService { + private values: Record | null = null; + + /** Records the stored values once, on the first settings open. */ + captureOnce(read: (control: string) => unknown): void { + if (this.values) return; + const values: Record = {}; + for (const control of Object.keys(SETTINGS_RESTART_CONTROLS)) { + values[control] = read(control); + } + this.values = values; + } + + /** + * What the running app actually uses when that differs from the stored + * value (a frame-copy opt-in the engine could not honour). + */ + set(control: string, value: unknown): void { + if (!(control in SETTINGS_RESTART_CONTROLS)) return; + this.values = { ...(this.values ?? {}), [control]: value }; + } + + get(): Readonly> { + return this.values ?? {}; + } +} diff --git a/apps/web/src/app/settings/settings.component.form.spec.ts b/apps/web/src/app/settings/settings.component.form.spec.ts index 9086c3442..4941a5882 100644 --- a/apps/web/src/app/settings/settings.component.form.spec.ts +++ b/apps/web/src/app/settings/settings.component.form.spec.ts @@ -505,6 +505,26 @@ describe('SettingsComponent form', () => { ]); expect(notice()).not.toBeNull(); + // Saving the launch value back withdraws the notice: nothing + // waits for a restart any more. + component.settingsForm.get('startupWindowMode')?.setValue('normal'); + component.settingsForm.markAsDirty(); + component.onSubmit(); + await fixture.whenStable(); + fixture.detectChanges(); + + expect(component.form.restartPendingControls()).toEqual([]); + expect(notice()).toBeNull(); + + component.settingsForm + .get('startupWindowMode') + ?.setValue('fullscreen'); + component.settingsForm.markAsDirty(); + component.onSubmit(); + await fixture.whenStable(); + fixture.detectChanges(); + expect(notice()).not.toBeNull(); + ( notice()?.querySelector( '[data-test-id="settings-restart-later"]' @@ -515,6 +535,69 @@ describe('SettingsComponent form', () => { expect(notice()).toBeNull(); }); + it('keeps the launch values across settings visits', async () => { + settingsStore.updateSettings.mockResolvedValue(undefined); + settingsStore.updateSettings.mockImplementation((settings) => { + settingsStore._setSettings(settings); + return Promise.resolve(undefined); + }); + component.settingsForm + .get('startupWindowMode') + ?.setValue('maximized'); + component.settingsForm.markAsDirty(); + component.onSubmit(); + await fixture.whenStable(); + expect(component.form.restartPendingControls()).toEqual([ + 'startupWindowMode', + ]); + + // Leave settings and come back: the page and its form are new, + // but the app still runs with the launch value. + fixture.destroy(); + fixture = TestBed.createComponent(SettingsComponent); + component = fixture.componentInstance; + stubSettingsSideEffects(component); + fixture.detectChanges(); + await fixture.whenStable(); + expect(component.form.restartPendingControls()).toEqual([ + 'startupWindowMode', + ]); + + // Saving the launch value back withdraws the notice here too. + component.settingsForm.get('startupWindowMode')?.setValue('normal'); + component.settingsForm.markAsDirty(); + component.onSubmit(); + await fixture.whenStable(); + expect(component.form.restartPendingControls()).toEqual([]); + }); + + it('compares the frame-copy opt-in against the engine the app runs', async () => { + settingsStore.updateSettings.mockResolvedValue(undefined); + // Stored true, but the engine could not honour it at launch. + component.form.setRunningValue('embeddedMpvFrameCopy', false); + component.settingsForm.get('embeddedMpvFrameCopy')?.setValue(true); + component.settingsForm.markAsDirty(); + component.onSubmit(); + await fixture.whenStable(); + + expect(component.form.restartPendingControls()).toEqual([ + 'embeddedMpvFrameCopy', + ]); + + // A staged, unsaved edit back to the launch value changes nothing: + // the saved setting still needs the restart. + component.settingsForm.get('embeddedMpvFrameCopy')?.setValue(false); + component.form.setRunningValue('embeddedMpvFrameCopy', false); + expect(component.form.restartPendingControls()).toEqual([ + 'embeddedMpvFrameCopy', + ]); + + // The engine catches up (a later probe reports frame copy): the + // notice goes without another save. + component.form.setRunningValue('embeddedMpvFrameCopy', true); + expect(component.form.restartPendingControls()).toEqual([]); + }); + it('discard reverts a staged cover size (regression: eager persist made it stick)', () => { const largeCoverButton = ( fixture.nativeElement as HTMLElement diff --git a/apps/web/src/app/settings/settings.component.ts b/apps/web/src/app/settings/settings.component.ts index fdeba525d..a8b695543 100644 --- a/apps/web/src/app/settings/settings.component.ts +++ b/apps/web/src/app/settings/settings.component.ts @@ -296,6 +296,16 @@ export class SettingsComponent } this.settingsCtx.setDirtySections(sections); }); + // The frame-copy opt-in may not be what the engine runs (it was not + // available at launch): the restart notice compares against that. + effect(() => { + if (this.embeddedMpv.support()) { + this.form.setRunningValue( + 'embeddedMpvFrameCopy', + this.embeddedMpv.frameCopyActive() + ); + } + }); effect(() => { const status = this.appUpdate.status()?.status; this.settingsCtx.setVersion(this.appUpdate.version() || null); diff --git a/docs/architecture/zoneless-migration.md b/docs/architecture/zoneless-migration.md index 265a14a6d..674840f43 100644 --- a/docs/architecture/zoneless-migration.md +++ b/docs/architecture/zoneless-migration.md @@ -85,7 +85,6 @@ picker), so each marks itself on the form's `events` through - [x] `apps/web/src/app/settings/settings-general-section.component.ts` - [x] `apps/web/src/app/settings/settings-playback-section.component.ts` - [x] `apps/web/src/app/settings/settings-remote-control-section.component.ts` -- [x] `apps/web/src/app/settings/settings-reset-section.component.ts` - [x] `apps/web/src/app/settings/settings-tmdb-section.component.ts` - [x] `apps/web/src/app/settings/settings-unsaved-changes-dialog.component.ts`