mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-12 12:06:16 -08:00
Merge branch 'perf/onpush-libs-playlist' into ci/zoneless-packaged-smoke
This commit is contained in:
commit
4c03850bb8
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.
|
||||
|
||||
@@ -169,9 +169,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