From 0a6f54ca15ae5ba7474badc31d54c1e2110c417a Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Wed, 7 Oct 2026 00:11:16 +0200 Subject: [PATCH] perf(playlist): make the playlist import and shared UI components OnPush (#1820) * perf(playlist): make the playlist import and shared UI components OnPush Plan item C6 step 3 for libs/playlist (import/feature and shared/ui): the twelve Eager components switch to OnPush. Two of them rendered plain fields written after an await, outside any template event, which only an Eager check on the next zone tick picked up: - playlist-item's portal status dot (PWA, after the async portal check) now reads a signal; - playlist-info's playlist is backed by a signal behind its existing getter/setter name, so the EPG source list follows removals and file picks that land after awaited cleanup and dialogs. A regression test for each fails on OnPush with the plain field and passes with the signal. The Stalker import's post-await patchValue needs no change (see the zoneless checklist). The m3u feature-player components stay Eager for the playback PR. Co-Authored-By: Claude Opus 5.5 * test(playlist): check the OnPush dialogs without forcing a render Review follow-ups (Greptile, Codex): - The portal-status and EPG-row tests forced detectChanges() after their await, so they passed with plain fields. They now let the fixture render on its own; with portalStatus back on a plain field the status test fails. - New: the playlist info dialog enables Save and shows the path after a native EPG file pick, without a forced render. pristine and valid read the form's state signals, so the OnPush dialog follows on its own. - New render spec for the add-playlist dialog with the real URL form: Add enables after typing and after a patch from outside the child (as an auto-detect prefill does). Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: 4gray Co-authored-by: Claude Opus 5.5 --- docs/architecture/zoneless-migration.md | 30 ++--- ...d-playlist-dialog.component.render.spec.ts | 86 ++++++++++++ .../add-playlist-dialog.component.ts | 2 +- .../lib/auto-import/auto-import.component.ts | 2 +- .../lib/file-upload/file-upload.component.ts | 2 +- .../stalker-portal-import.component.ts | 2 +- .../lib/text-import/text-import.component.ts | 2 +- .../lib/url-upload/url-upload.component.ts | 2 +- .../xtream-code-import.component.ts | 2 +- .../empty-state/empty-state.component.ts | 2 +- ...laylist-info-epg-sources.component.spec.ts | 126 ++++++++++++++++++ .../playlist-info/playlist-info.component.ts | 19 ++- .../playlist-item.component.html | 4 +- .../playlist-item.component.spec.ts | 46 +++++-- .../playlist-item/playlist-item.component.ts | 14 +- .../source-cleanup-dialog.component.ts | 2 +- .../source-health-indicator.component.ts | 2 +- 17 files changed, 296 insertions(+), 49 deletions(-) create mode 100644 libs/playlist/import/feature/src/lib/add-playlist-dialog/add-playlist-dialog.component.render.spec.ts create mode 100644 libs/playlist/shared/ui/src/lib/recent-playlists/playlist-info/playlist-info-epg-sources.component.spec.ts diff --git a/docs/architecture/zoneless-migration.md b/docs/architecture/zoneless-migration.md index 95bfd0670..265a14a6d 100644 --- a/docs/architecture/zoneless-migration.md +++ b/docs/architecture/zoneless-migration.md @@ -130,20 +130,20 @@ picker), so each marks itself on the form's `events` through ### libs/playlist (14) -- [ ] `libs/playlist/import/feature/src/lib/add-playlist-dialog/add-playlist-dialog.component.ts` -- [ ] `libs/playlist/import/feature/src/lib/auto-import/auto-import.component.ts` -- [ ] `libs/playlist/import/feature/src/lib/file-upload/file-upload.component.ts` -- [ ] `libs/playlist/import/feature/src/lib/stalker-portal-import/stalker-portal-import.component.ts` -- [ ] `libs/playlist/import/feature/src/lib/text-import/text-import.component.ts` -- [ ] `libs/playlist/import/feature/src/lib/url-upload/url-upload.component.ts` -- [ ] `libs/playlist/import/feature/src/lib/xtream-code-import/xtream-code-import.component.ts` +- [x] `libs/playlist/import/feature/src/lib/add-playlist-dialog/add-playlist-dialog.component.ts` +- [x] `libs/playlist/import/feature/src/lib/auto-import/auto-import.component.ts` +- [x] `libs/playlist/import/feature/src/lib/file-upload/file-upload.component.ts` +- [x] `libs/playlist/import/feature/src/lib/stalker-portal-import/stalker-portal-import.component.ts` +- [x] `libs/playlist/import/feature/src/lib/text-import/text-import.component.ts` +- [x] `libs/playlist/import/feature/src/lib/url-upload/url-upload.component.ts` +- [x] `libs/playlist/import/feature/src/lib/xtream-code-import/xtream-code-import.component.ts` - [x] `libs/playlist/m3u/feature-player/src/lib/m3u-vod-detail/m3u-vod-detail.component.ts` - [x] `libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts` -- [ ] `libs/playlist/shared/ui/src/lib/recent-playlists/empty-state/empty-state.component.ts` -- [ ] `libs/playlist/shared/ui/src/lib/recent-playlists/playlist-info/playlist-info.component.ts` -- [ ] `libs/playlist/shared/ui/src/lib/recent-playlists/playlist-item/playlist-item.component.ts` -- [ ] `libs/playlist/shared/ui/src/lib/source-health/source-cleanup-dialog.component.ts` -- [ ] `libs/playlist/shared/ui/src/lib/source-health/source-health-indicator.component.ts` +- [x] `libs/playlist/shared/ui/src/lib/recent-playlists/empty-state/empty-state.component.ts` +- [x] `libs/playlist/shared/ui/src/lib/recent-playlists/playlist-info/playlist-info.component.ts` +- [x] `libs/playlist/shared/ui/src/lib/recent-playlists/playlist-item/playlist-item.component.ts` +- [x] `libs/playlist/shared/ui/src/lib/source-health/source-cleanup-dialog.component.ts` +- [x] `libs/playlist/shared/ui/src/lib/source-health/source-health-indicator.component.ts` `libs/playlist/m3u/feature-player` (2) goes with the playback PR. @@ -176,9 +176,9 @@ the field a signal (or a `computed`), or writes it through one. | --- | --- | --- | --- | | [x] | `libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts` `onChannelNumberInput`/`clearChannelNumberInput` | 2 s `window.setTimeout` hides the channel-number overlay through plain `showChannelNumberOverlay`/`channelNumberInput` | playback | | [x] | same file, `applySettings` and the settings `effect()` | IndexedDB `storage.get(...).subscribe` and an effect assign plain `playerSettings`, which picks the player in the template | playback | -| [ ] | `libs/playlist/shared/ui/src/lib/recent-playlists/playlist-item/playlist-item.component.ts` `checkPortalStatus` | plain `portalStatus` assigned after `await` in `ngOnInit` (PWA only: skipped when source health is supported) | playlist | -| [ ] | `libs/playlist/shared/ui/src/lib/recent-playlists/playlist-info/playlist-info.component.ts` (EPG clear and EPG file pick handlers) | plain `playlist` reassigned after `await` | playlist | -| [ ] | `libs/playlist/import/feature/src/lib/stalker-portal-import/stalker-portal-import.component.ts` (device-id derivation) | `form.patchValue` after `await`; template getters read `control.value`, which is not signal-backed | playlist | +| [x] | `libs/playlist/shared/ui/src/lib/recent-playlists/playlist-item/playlist-item.component.ts` `checkPortalStatus` | plain `portalStatus` assigned after `await` in `ngOnInit` (PWA only: skipped when source health is supported) | playlist | +| [x] | `libs/playlist/shared/ui/src/lib/recent-playlists/playlist-info/playlist-info.component.ts` (EPG clear and EPG file pick handlers) | plain `playlist` reassigned after `await` | playlist | +| [x] | `libs/playlist/import/feature/src/lib/stalker-portal-import/stalker-portal-import.component.ts` (device-id derivation) | `form.patchValue` after `await`; no fix needed: while derivation is on `hasManualDeviceIds` is false whatever the IDs are, the controls write their own DOM values, and typing goes through value-accessor listeners that mark the view | playlist | | [x] | `libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts` (favorites load) | `favorites` Map filled in a `subscribe` without `markForCheck`; the component is OnPush already, so this is a latent bug today | portal | | [x] | `libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.ts` (favorites load) | same pattern; the neighbouring `favoriteMarks.changes$` handler does call `markForCheck` | portal | | [x] | same file, programme dialog `afterClosed` | deletes from `epgPrograms`/`currentProgramsProgress` after `await` without marking | portal | diff --git a/libs/playlist/import/feature/src/lib/add-playlist-dialog/add-playlist-dialog.component.render.spec.ts b/libs/playlist/import/feature/src/lib/add-playlist-dialog/add-playlist-dialog.component.render.spec.ts new file mode 100644 index 000000000..38350f156 --- /dev/null +++ b/libs/playlist/import/feature/src/lib/add-playlist-dialog/add-playlist-dialog.component.render.spec.ts @@ -0,0 +1,86 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { Store } from '@ngrx/store'; +import { TranslateModule } from '@ngx-translate/core'; +import { DataService, RuntimeCapabilitiesService } from '@iptvnator/services'; +import { AddPlaylistDialogComponent } from './add-playlist-dialog.component'; + +/** + * The dialog is OnPush and its Add button reads `form.valid` of the real URL + * import child. The fixture renders on its own (`autoDetectChanges`): a + * forced `detectChanges()` would hide a button that does not follow the + * child form. + */ +describe('AddPlaylistDialogComponent with the real URL form', () => { + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ + AddPlaylistDialogComponent, + TranslateModule.forRoot(), + NoopAnimationsModule, + ], + providers: [ + { provide: DataService, useValue: { sendIpcEvent: jest.fn() } }, + { provide: MatDialogRef, useValue: { close: jest.fn() } }, + { provide: Store, useValue: { dispatch: jest.fn() } }, + { provide: MatSnackBar, useValue: { open: jest.fn() } }, + { provide: MAT_DIALOG_DATA, useValue: null }, + { + provide: RuntimeCapabilitiesService, + useValue: { isElectron: true }, + }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(AddPlaylistDialogComponent); + fixture.autoDetectChanges(); + await settle(); + }); + + async function settle(): Promise { + await fixture.whenStable(); + // The render a form change schedules runs in the next macrotask. + await new Promise((resolve) => setTimeout(resolve)); + } + + const addButton = () => + Array.from( + ( + fixture.nativeElement as HTMLElement + ).querySelectorAll('button[mat-flat-button]') + ).find((button) => + button.textContent?.includes('HOME.URL_UPLOAD.ADD_PLAYLIST') + ); + const urlInput = () => + (fixture.nativeElement as HTMLElement).querySelector( + 'input[formcontrolname="playlistUrl"]' + ); + + it('enables Add once the user types a playlist URL', async () => { + expect(addButton()?.disabled).toBe(true); + + const input = urlInput(); + if (!input) throw new Error('Expected the URL input'); + input.value = 'https://example.com/list.m3u'; + input.dispatchEvent(new Event('input')); + await settle(); + + expect(addButton()?.disabled).toBe(false); + }); + + it('enables Add when the child form is filled without a template event', async () => { + expect(addButton()?.disabled).toBe(true); + + // As an auto-detect prefill does: a patch from outside the child. + fixture.componentInstance + .urlUpload() + ?.form.patchValue({ playlistUrl: 'https://example.com/list.m3u' }); + await settle(); + + expect(addButton()?.disabled).toBe(false); + }); +}); diff --git a/libs/playlist/import/feature/src/lib/add-playlist-dialog/add-playlist-dialog.component.ts b/libs/playlist/import/feature/src/lib/add-playlist-dialog/add-playlist-dialog.component.ts index 05090e988..88ded3ec6 100644 --- a/libs/playlist/import/feature/src/lib/add-playlist-dialog/add-playlist-dialog.component.ts +++ b/libs/playlist/import/feature/src/lib/add-playlist-dialog/add-playlist-dialog.component.ts @@ -72,7 +72,7 @@ const METHOD_BY_CANDIDATE_KIND: Record< selector: 'app-add-playlist', templateUrl: './add-playlist-dialog.component.html', styleUrl: './add-playlist-dialog.component.scss', - changeDetection: ChangeDetectionStrategy.Eager, + changeDetection: ChangeDetectionStrategy.OnPush, encapsulation: ViewEncapsulation.None, }) export class AddPlaylistDialogComponent { diff --git a/libs/playlist/import/feature/src/lib/auto-import/auto-import.component.ts b/libs/playlist/import/feature/src/lib/auto-import/auto-import.component.ts index 1dcf80b52..0b86cb5af 100644 --- a/libs/playlist/import/feature/src/lib/auto-import/auto-import.component.ts +++ b/libs/playlist/import/feature/src/lib/auto-import/auto-import.component.ts @@ -147,7 +147,7 @@ function maskUrlQueryPasswords(url: string): string { selector: 'app-auto-import', templateUrl: './auto-import.component.html', styleUrl: './auto-import.component.scss', - changeDetection: ChangeDetectionStrategy.Eager, + changeDetection: ChangeDetectionStrategy.OnPush, imports: [ MatButtonModule, MatFormFieldModule, diff --git a/libs/playlist/import/feature/src/lib/file-upload/file-upload.component.ts b/libs/playlist/import/feature/src/lib/file-upload/file-upload.component.ts index ae51aa9c2..d621b9410 100644 --- a/libs/playlist/import/feature/src/lib/file-upload/file-upload.component.ts +++ b/libs/playlist/import/feature/src/lib/file-upload/file-upload.component.ts @@ -18,7 +18,7 @@ const KB = 1024; imports: [DragDropFileUploadDirective, MatIconModule, TranslatePipe], selector: 'app-file-upload', templateUrl: './file-upload.component.html', - changeDetection: ChangeDetectionStrategy.Eager, + changeDetection: ChangeDetectionStrategy.OnPush, styleUrls: ['./file-upload.component.scss'], }) export class FileUploadComponent { diff --git a/libs/playlist/import/feature/src/lib/stalker-portal-import/stalker-portal-import.component.ts b/libs/playlist/import/feature/src/lib/stalker-portal-import/stalker-portal-import.component.ts index e373891b4..8cd65d5fd 100644 --- a/libs/playlist/import/feature/src/lib/stalker-portal-import/stalker-portal-import.component.ts +++ b/libs/playlist/import/feature/src/lib/stalker-portal-import/stalker-portal-import.component.ts @@ -75,7 +75,7 @@ interface StalkerSettledIdentity { ], selector: 'app-stalker-portal-import', templateUrl: './stalker-portal-import.component.html', - changeDetection: ChangeDetectionStrategy.Eager, + changeDetection: ChangeDetectionStrategy.OnPush, styles: [ ` :host { diff --git a/libs/playlist/import/feature/src/lib/text-import/text-import.component.ts b/libs/playlist/import/feature/src/lib/text-import/text-import.component.ts index 5eb4f7fd5..f767cffb7 100644 --- a/libs/playlist/import/feature/src/lib/text-import/text-import.component.ts +++ b/libs/playlist/import/feature/src/lib/text-import/text-import.component.ts @@ -17,7 +17,7 @@ import { TranslatePipe } from '@ngx-translate/core'; selector: 'app-text-import', templateUrl: './text-import.component.html', styleUrls: ['./text-import.component.scss'], - changeDetection: ChangeDetectionStrategy.Eager, + changeDetection: ChangeDetectionStrategy.OnPush, imports: [MatInputModule, ReactiveFormsModule, TranslatePipe], }) export class TextImportComponent { diff --git a/libs/playlist/import/feature/src/lib/url-upload/url-upload.component.ts b/libs/playlist/import/feature/src/lib/url-upload/url-upload.component.ts index ce06b6834..59338b8bf 100644 --- a/libs/playlist/import/feature/src/lib/url-upload/url-upload.component.ts +++ b/libs/playlist/import/feature/src/lib/url-upload/url-upload.component.ts @@ -19,7 +19,7 @@ import { RuntimeCapabilitiesService } from '@iptvnator/services'; @Component({ selector: 'app-url-upload', templateUrl: './url-upload.component.html', - changeDetection: ChangeDetectionStrategy.Eager, + changeDetection: ChangeDetectionStrategy.OnPush, imports: [ MatFormFieldModule, MatInputModule, diff --git a/libs/playlist/import/feature/src/lib/xtream-code-import/xtream-code-import.component.ts b/libs/playlist/import/feature/src/lib/xtream-code-import/xtream-code-import.component.ts index 83870b92d..ca7c148f9 100644 --- a/libs/playlist/import/feature/src/lib/xtream-code-import/xtream-code-import.component.ts +++ b/libs/playlist/import/feature/src/lib/xtream-code-import/xtream-code-import.component.ts @@ -60,7 +60,7 @@ function xtreamServerUrlValidator( ], selector: 'app-xtream-code-import', templateUrl: './xtream-code-import.component.html', - changeDetection: ChangeDetectionStrategy.Eager, + changeDetection: ChangeDetectionStrategy.OnPush, styles: [ ` :host { diff --git a/libs/playlist/shared/ui/src/lib/recent-playlists/empty-state/empty-state.component.ts b/libs/playlist/shared/ui/src/lib/recent-playlists/empty-state/empty-state.component.ts index 6dc6cf9a4..d5de21666 100644 --- a/libs/playlist/shared/ui/src/lib/recent-playlists/empty-state/empty-state.component.ts +++ b/libs/playlist/shared/ui/src/lib/recent-playlists/empty-state/empty-state.component.ts @@ -103,7 +103,7 @@ const SOURCE_CARDS: readonly SourceCard[] = [ './empty-state.responsive.scss', './empty-state.themes.scss', ], - changeDetection: ChangeDetectionStrategy.Eager, + changeDetection: ChangeDetectionStrategy.OnPush, imports: [MatButtonModule, MatIcon, TranslatePipe], }) export class EmptyStateComponent { diff --git a/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-info/playlist-info-epg-sources.component.spec.ts b/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-info/playlist-info-epg-sources.component.spec.ts new file mode 100644 index 000000000..1088ebc61 --- /dev/null +++ b/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-info/playlist-info-epg-sources.component.spec.ts @@ -0,0 +1,126 @@ +import { TestBed } from '@angular/core/testing'; +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { Store } from '@ngrx/store'; +import { TranslateModule } from '@ngx-translate/core'; +import { of, Subject } from 'rxjs'; +import { EpgRuntimeBridgeService } from '@iptvnator/epg/data-access'; +import { + DatabaseService, + PlaylistsService, + RuntimeCapabilitiesService, + SettingsStore, +} from '@iptvnator/services'; +import { Playlist } from '@iptvnator/shared/interfaces'; +import { PlaylistInfoComponent } from './playlist-info.component'; +import { STALKER_PLAYLIST_CONNECTION_EDITOR } from './stalker-playlist-connection-editor.token'; + +/** + * The component is OnPush: the playlist is replaced after the awaited EPG + * cleanup, outside any template event, so the rendered source list has to + * follow without a zone-triggered tick. + */ +describe('PlaylistInfoComponent playlist EPG source rows', () => { + const keepUrl = 'https://playlist.example.com/keep.xml'; + const removeUrl = 'https://playlist.example.com/remove.xml'; + const playlist = { + id: 'playlist-1', + _id: 'playlist-1', + title: 'My Playlist', + count: 1, + importDate: '2026-04-01T00:00:00.000Z', + autoRefresh: false, + url: 'https://example.com/playlist.m3u', + epgUrls: [keepUrl, removeUrl], + detectedEpgUrls: [keepUrl, removeUrl], + } as Playlist & { id: string }; + + function render() { + TestBed.configureTestingModule({ + imports: [PlaylistInfoComponent, TranslateModule.forRoot()], + providers: [ + { provide: MAT_DIALOG_DATA, useValue: playlist }, + { + provide: PlaylistsService, + useValue: { getPlaylistById: jest.fn(() => of(playlist)) }, + }, + { provide: DatabaseService, useValue: {} }, + { + provide: EpgRuntimeBridgeService, + useValue: { + supportsDataManagement: true, + supportsFilePicker: true, + pickEpgFile: jest + .fn() + .mockResolvedValue('/home/user/epg/guide.xml.gz'), + clearEpgDataForSource: jest + .fn() + .mockResolvedValue({ success: true }), + }, + }, + { provide: Store, useValue: { dispatch: jest.fn() } }, + { + provide: SettingsStore, + useValue: { getSettings: jest.fn(() => ({ epgUrl: [] })) }, + }, + { provide: MatSnackBar, useValue: { open: jest.fn() } }, + { + provide: MatDialogRef, + useValue: { beforeClosed: () => new Subject() }, + }, + { + provide: RuntimeCapabilitiesService, + useValue: { supportsDesktopFileSave: false }, + }, + { provide: STALKER_PLAYLIST_CONNECTION_EDITOR, useValue: {} }, + ], + }); + const fixture = TestBed.createComponent(PlaylistInfoComponent); + fixture.detectChanges(); + return fixture; + } + + it('drops a removed source row once its cleanup resolves', async () => { + const fixture = render(); + const renderedSources = () => + Array.from( + (fixture.nativeElement as HTMLElement).querySelectorAll( + '.playlist-epg-source-row input[readonly]' + ), + (input) => (input as HTMLInputElement).value + ); + expect(renderedSources()).toEqual([keepUrl, removeUrl]); + + // From here the fixture renders on its own: a forced detectChanges() + // after the cleanup would hide a row update that schedules no render. + fixture.autoDetectChanges(); + await fixture.componentInstance.removePlaylistEpgSource(removeUrl); + await fixture.whenStable(); + await new Promise((resolve) => setTimeout(resolve)); + + expect(renderedSources()).toEqual([keepUrl]); + }); + + // The native file pick resolves after an await, with no template event. + // The main Save button and the field must follow without a forced render. + it('enables Save after a native EPG file pick without a forced render', async () => { + const fixture = render(); + fixture.autoDetectChanges(); + await fixture.whenStable(); + const host = fixture.nativeElement as HTMLElement; + const save = () => + host.querySelector('button[type="submit"]'); + const field = () => + host.querySelector( + '.playlist-epg-source-form .playlist-epg-source-row input' + ); + expect(save()?.disabled).toBe(true); + + await fixture.componentInstance.browsePlaylistEpgSourceInput(0); + await fixture.whenStable(); + await new Promise((resolve) => setTimeout(resolve)); + + expect(field()?.value).toBe('/home/user/epg/guide.xml.gz'); + expect(save()?.disabled).toBe(false); + }); +}); diff --git a/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-info/playlist-info.component.ts b/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-info/playlist-info.component.ts index 97192e68c..35a010ded 100644 --- a/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-info/playlist-info.component.ts +++ b/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-info/playlist-info.component.ts @@ -166,7 +166,7 @@ type DesktopFileSaveBridge = Pick< `, ], providers: [DatePipe], - changeDetection: ChangeDetectionStrategy.Eager, + changeDetection: ChangeDetectionStrategy.OnPush, imports: [ ClipboardModule, MatButton, @@ -300,8 +300,20 @@ export class PlaylistInfoComponent { ) as UntypedFormArray; } - /** Playlist object */ - playlist: Playlist & { id: string }; + /** + * Playlist object. Signal-backed because it is replaced after awaited + * hydration and EPG source edits, which no template event marks for an + * OnPush check. + */ + private readonly playlistState = signal( + this.playlistData + ); + get playlist(): Playlist & { id: string } { + return this.playlistState(); + } + set playlist(value: Playlist & { id: string }) { + this.playlistState.set(value); + } /** Form group with playlist details */ playlistDetails!: UntypedFormGroup; @@ -311,7 +323,6 @@ export class PlaylistInfoComponent { this.dialogRef?.beforeClosed().subscribe(() => { this.dialogClosing = true; }); - this.playlist = this.playlistData; this.createForm(); this.connectionTest = createXtreamConnectionTestState( this.playlistDetails diff --git a/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-item/playlist-item.component.html b/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-item/playlist-item.component.html index 844252b24..f97919a39 100644 --- a/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-item/playlist-item.component.html +++ b/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-item/playlist-item.component.html @@ -64,8 +64,8 @@ } @else if (showsAutoRefresh) { { fixture.detectChanges(); expect( - (fixture.nativeElement as HTMLElement).querySelector( - '.refresh-btn' - ) + (fixture.nativeElement as HTMLElement).querySelector('.refresh-btn') ).toBeNull(); }); @@ -149,9 +147,7 @@ describe('PlaylistItemComponent', () => { fixture.detectChanges(); expect( - (fixture.nativeElement as HTMLElement).querySelector( - '.refresh-btn' - ) + (fixture.nativeElement as HTMLElement).querySelector('.refresh-btn') ).not.toBeNull(); }); @@ -173,9 +169,7 @@ describe('PlaylistItemComponent', () => { fixture.detectChanges(); expect( - (fixture.nativeElement as HTMLElement).querySelector( - '.refresh-btn' - ) + (fixture.nativeElement as HTMLElement).querySelector('.refresh-btn') ).not.toBeNull(); fixture.destroy(); @@ -195,12 +189,40 @@ describe('PlaylistItemComponent', () => { fixture.detectChanges(); expect( - (fixture.nativeElement as HTMLElement).querySelector( - '.refresh-btn' - ) + (fixture.nativeElement as HTMLElement).querySelector('.refresh-btn') ).toBeNull(); }); + // OnPush: the status arrives after an await outside any template event, + // so it must reach the view without a zone-triggered tick. + it('renders the portal status that resolves after the first render', async () => { + fixture.destroy(); + fixture = TestBed.createComponent(PlaylistItemComponent); + component = fixture.componentInstance; + component.item = { + title: 'Xtream Source', + _id: 'xtream-source', + count: 10, + importDate: Date.now().toString(), + autoRefresh: false, + serverUrl: 'https://example.com', + username: 'demo', + password: 'secret', + }; + // The fixture renders on its own: a forced detectChanges() after the + // await would hide a status that does not schedule a render. + fixture.autoDetectChanges(); + await fixture.whenStable(); + await new Promise((resolve) => setTimeout(resolve)); + + const statusDot = (fixture.nativeElement as HTMLElement).querySelector( + '.status-dot' + ); + expect(statusDot?.getAttribute('aria-label')).toBe( + 'Portal status: active' + ); + }); + it('renders cancel and progress UI for long-running playlist actions', () => { fixture.componentRef.setInput('isDeleting', true); fixture.componentRef.setInput( diff --git a/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-item/playlist-item.component.ts b/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-item/playlist-item.component.ts index 033de62c5..8cbe29077 100644 --- a/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-item/playlist-item.component.ts +++ b/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-item/playlist-item.component.ts @@ -17,6 +17,7 @@ import { inject, input, output, + signal, ChangeDetectionStrategy, } from '@angular/core'; import { toSignal } from '@angular/core/rxjs-interop'; @@ -39,7 +40,7 @@ import type { PlaylistMeta } from '@iptvnator/shared/interfaces'; selector: 'app-playlist-item', templateUrl: './playlist-item.component.html', styleUrls: ['./playlist-item.component.scss'], - changeDetection: ChangeDetectionStrategy.Eager, + changeDetection: ChangeDetectionStrategy.OnPush, imports: [ SourceHealthIndicatorComponent, DatePipe, @@ -70,7 +71,7 @@ export class PlaylistItemComponent implements OnInit { readonly removeClicked = output(); readonly cancelBusyActionClicked = output(); - portalStatus: PortalStatus = 'unavailable'; + readonly portalStatus = signal('unavailable'); private readonly portalStatusService = inject(PortalStatusService); readonly runtime = inject(RuntimeCapabilitiesService); readonly sourceIcons = SOURCE_TYPE_ICONS; @@ -125,21 +126,22 @@ export class PlaylistItemComponent implements OnInit { private async checkPortalStatus() { if (this.item.serverUrl && this.item.username && this.item.password) { - this.portalStatus = + this.portalStatus.set( await this.portalStatusService.checkPortalStatus( this.item.serverUrl, this.item.username, this.item.password - ); + ) + ); } } getStatusClass(): string { - return this.portalStatusService.getStatusClass(this.portalStatus); + return this.portalStatusService.getStatusClass(this.portalStatus()); } getStatusIcon(): string { - return this.portalStatusService.getStatusIcon(this.portalStatus); + return this.portalStatusService.getStatusIcon(this.portalStatus()); } onPlaylistClick(): void { diff --git a/libs/playlist/shared/ui/src/lib/source-health/source-cleanup-dialog.component.ts b/libs/playlist/shared/ui/src/lib/source-health/source-cleanup-dialog.component.ts index b23f785c1..43558e4f4 100644 --- a/libs/playlist/shared/ui/src/lib/source-health/source-cleanup-dialog.component.ts +++ b/libs/playlist/shared/ui/src/lib/source-health/source-cleanup-dialog.component.ts @@ -181,7 +181,7 @@ export interface SourceCleanupDialogData extends SourceCleanupContext { } `, - changeDetection: ChangeDetectionStrategy.Eager, + changeDetection: ChangeDetectionStrategy.OnPush, styles: [ ` :host { diff --git a/libs/playlist/shared/ui/src/lib/source-health/source-health-indicator.component.ts b/libs/playlist/shared/ui/src/lib/source-health/source-health-indicator.component.ts index 3637c056a..4573a2b26 100644 --- a/libs/playlist/shared/ui/src/lib/source-health/source-health-indicator.component.ts +++ b/libs/playlist/shared/ui/src/lib/source-health/source-health-indicator.component.ts @@ -29,7 +29,7 @@ import { PlaylistMeta, sourceHealthType } from '@iptvnator/shared/interfaces'; role="img" > }`, - changeDetection: ChangeDetectionStrategy.Eager, + changeDetection: ChangeDetectionStrategy.OnPush, styles: [ ` :host {