mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
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 <noreply@anthropic.com>
This commit is contained in:
1 parent
2738bc28a1
commit
5d60960d9d
16 files changed
+177
-49
No files matched your search
@@ -124,20 +124,20 @@ files that still contain `ChangeDetectionStrategy.Eager`.
|
||||
|
||||
### 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`
|
||||
- [ ] `libs/playlist/m3u/feature-player/src/lib/m3u-vod-detail/m3u-vod-detail.component.ts`
|
||||
- [ ] `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.
|
||||
|
||||
@@ -167,9 +167,9 @@ the field a signal (or a `computed`), or writes it through one.
|
||||
| --- | --- | --- | --- |
|
||||
| [ ] | `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 |
|
||||
| [ ] | 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 |
|
||||
| [ ] | `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 |
|
||||
| [ ] | `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 |
|
||||
| [ ] | same file, programme dialog `afterClosed` | deletes from `epgPrograms`/`currentProgramsProgress` after `await` without marking | portal |
|
||||
|
||||
+1
-1
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+1
-1
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
+1
-1
@@ -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 {
|
||||
|
||||
+1
-1
@@ -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 {
|
||||
|
||||
+95
@@ -0,0 +1,95 @@
|
||||
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,
|
||||
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<void>() },
|
||||
},
|
||||
{
|
||||
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]);
|
||||
|
||||
await fixture.componentInstance.removePlaylistEpgSource(removeUrl);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(renderedSources()).toEqual([keepUrl]);
|
||||
});
|
||||
});
|
||||
+15
-4
@@ -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<Playlist & { id: string }>(
|
||||
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
|
||||
|
||||
+2
-2
@@ -64,8 +64,8 @@
|
||||
<span
|
||||
class="status-dot"
|
||||
[class]="getStatusClass()"
|
||||
[matTooltip]="'Portal status: ' + portalStatus"
|
||||
[attr.aria-label]="'Portal status: ' + portalStatus"
|
||||
[matTooltip]="'Portal status: ' + portalStatus()"
|
||||
[attr.aria-label]="'Portal status: ' + portalStatus()"
|
||||
></span>
|
||||
} @else if (showsAutoRefresh) {
|
||||
<mat-icon
|
||||
|
||||
+32
-12
@@ -127,9 +127,7 @@ describe('PlaylistItemComponent', () => {
|
||||
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,38 @@ 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',
|
||||
};
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
fixture.detectChanges();
|
||||
|
||||
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(
|
||||
|
||||
+8
-6
@@ -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<string>();
|
||||
readonly cancelBusyActionClicked = output<void>();
|
||||
|
||||
portalStatus: PortalStatus = 'unavailable';
|
||||
readonly portalStatus = signal<PortalStatus>('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 {
|
||||
|
||||
@@ -181,7 +181,7 @@ export interface SourceCleanupDialogData extends SourceCleanupContext {
|
||||
</button>
|
||||
}
|
||||
</mat-dialog-actions>`,
|
||||
changeDetection: ChangeDetectionStrategy.Eager,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
styles: [
|
||||
`
|
||||
:host {
|
||||
|
||||
@@ -29,7 +29,7 @@ import { PlaylistMeta, sourceHealthType } from '@iptvnator/shared/interfaces';
|
||||
role="img"
|
||||
></span>
|
||||
}`,
|
||||
changeDetection: ChangeDetectionStrategy.Eager,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
styles: [
|
||||
`
|
||||
:host {
|
||||
|
||||
Reference in new issue
Block a user