From 3cc5af14926c6403e1fe6b2538f2b66f1d5babee Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Tue, 6 Oct 2026 14:12:52 +0200 Subject: [PATCH] perf(playback): make the web players and M3U player OnPush (#1822) * perf(playback): make the web players and M3U player OnPush Plan item C6 step 3 for playback: the eight Eager components in libs/ui/playback (video.js, ArtPlayer, HTML5/hls/mpegts, audio player, web player view, VOD details, sidebar, external-player dialog) and the M3U video player and VOD detail switch to OnPush. The player libraries' events already reach the UI through the signal-backed controls adapter or outputs, and the players' DOM belongs to the libraries. The M3U video player rendered three plain fields written outside template events: the channel-number overlay, cleared by a 2 s debounce timer, and the player choice, written from an IndexedDB read and a settings effect. They are signals now, and a test checks that the overlay leaves the DOM when the timer fires. Co-Authored-By: Claude Opus 5.5 * test(playback): let the overlay's signal write schedule its own render Review follow-up (Greptile): the test forced a render with fixture.detectChanges() after the debounce timer, so it would pass even if the signal write stopped scheduling an OnPush render. It now runs the fixture with autoDetectChanges and only advances the fake timers; with plain fields under OnPush the overlay never renders and the test fails. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: 4gray Co-authored-by: Claude Opus 5.5 --- docs/architecture/zoneless-migration.md | 26 ++++++------- .../m3u-vod-detail.component.ts | 2 +- ...-player.component.fullscreen-panel.spec.ts | 37 +++++++++++++++++-- .../video-player/video-player.component.html | 8 ++-- .../video-player/video-player.component.ts | 34 +++++++++-------- .../lib/art-player/art-player.component.ts | 2 +- .../audio-player/audio-player.component.ts | 2 +- .../external-player-info-dialog.component.ts | 2 +- .../html-video-player.component.ts | 2 +- .../video-player/sidebar/sidebar.component.ts | 2 +- .../lib/vjs-player/vjs-player.component.ts | 2 +- .../lib/vod-details/vod-details.component.ts | 2 +- .../web-player-view.component.ts | 2 +- 13 files changed, 78 insertions(+), 45 deletions(-) diff --git a/docs/architecture/zoneless-migration.md b/docs/architecture/zoneless-migration.md index 84fdb1ede..63e70d481 100644 --- a/docs/architecture/zoneless-migration.md +++ b/docs/architecture/zoneless-migration.md @@ -97,14 +97,14 @@ files that still contain `ChangeDetectionStrategy.Eager`. - [x] `libs/ui/epg/src/lib/epg-progress-panel/epg-progress-panel.component.ts` (idle audit root; also `EpgTrustConfirmDialogComponent`) - [x] `libs/ui/epg/src/lib/epg-source-status/epg-source-status.component.ts` - [ ] `libs/ui/remote-control/src/lib/remote-control/remote-control.component.ts` (`apps/remote-control-web` only) -- [ ] `libs/ui/playback/src/lib/art-player/art-player.component.ts` -- [ ] `libs/ui/playback/src/lib/audio-player/audio-player.component.ts` -- [ ] `libs/ui/playback/src/lib/external-player-info-dialog/external-player-info-dialog.component.ts` -- [ ] `libs/ui/playback/src/lib/html-video-player/html-video-player.component.ts` -- [ ] `libs/ui/playback/src/lib/video-player/sidebar/sidebar.component.ts` -- [ ] `libs/ui/playback/src/lib/vjs-player/vjs-player.component.ts` -- [ ] `libs/ui/playback/src/lib/vod-details/vod-details.component.ts` -- [ ] `libs/ui/playback/src/lib/web-player-view/web-player-view.component.ts` +- [x] `libs/ui/playback/src/lib/art-player/art-player.component.ts` +- [x] `libs/ui/playback/src/lib/audio-player/audio-player.component.ts` +- [x] `libs/ui/playback/src/lib/external-player-info-dialog/external-player-info-dialog.component.ts` +- [x] `libs/ui/playback/src/lib/html-video-player/html-video-player.component.ts` +- [x] `libs/ui/playback/src/lib/video-player/sidebar/sidebar.component.ts` +- [x] `libs/ui/playback/src/lib/vjs-player/vjs-player.component.ts` +- [x] `libs/ui/playback/src/lib/vod-details/vod-details.component.ts` +- [x] `libs/ui/playback/src/lib/web-player-view/web-player-view.component.ts` `libs/ui/playback` (8) goes with the playback PR, not the `libs/ui` one. @@ -131,8 +131,8 @@ files that still contain `ChangeDetectionStrategy.Eager`. - [ ] `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` -- [ ] `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` +- [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` @@ -168,8 +168,8 @@ the field a signal (or a `computed`), or writes it through one. | Done | Site | What depends on the zone | Owning PR | | --- | --- | --- | --- | -| [ ] | `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 | +| [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 | @@ -188,7 +188,7 @@ before: with zone.js on they still matter. - [ ] `apps/web/src/app/settings/settings-unload-guard.service.ts`: two `zone.run` calls around the window-close dialog (IPC `onWindowCloseRequested` and `beforeunload`). -- [ ] `libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-session-controller.ts`: +- [x] `libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-session-controller.ts`: `runOutsideAngular(() => setInterval(...))` for the position poll; `embedded-mpv-session-controller.position.spec.ts` asserts the call and changes with it. diff --git a/libs/playlist/m3u/feature-player/src/lib/m3u-vod-detail/m3u-vod-detail.component.ts b/libs/playlist/m3u/feature-player/src/lib/m3u-vod-detail/m3u-vod-detail.component.ts index cd5b6adb8..c5d7fcb18 100644 --- a/libs/playlist/m3u/feature-player/src/lib/m3u-vod-detail/m3u-vod-detail.component.ts +++ b/libs/playlist/m3u/feature-player/src/lib/m3u-vod-detail/m3u-vod-detail.component.ts @@ -71,7 +71,7 @@ import { TranslatePipe, ], templateUrl: './m3u-vod-detail.component.html', - changeDetection: ChangeDetectionStrategy.Eager, + changeDetection: ChangeDetectionStrategy.OnPush, styleUrls: ['./m3u-vod-detail.component.scss'], }) export class M3uVodDetailComponent { diff --git a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.fullscreen-panel.spec.ts b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.fullscreen-panel.spec.ts index d8ebfef71..72af02b4e 100644 --- a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.fullscreen-panel.spec.ts +++ b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.fullscreen-panel.spec.ts @@ -231,8 +231,12 @@ describe('VideoPlayerComponent fullscreen channel panel + zapping', () => { .overrideComponent(VideoPlayerComponent, { set: { imports: [], - template: - '', + // The real template's channel-number overlay, so the + // OnPush timer test below can read the rendered state. + template: ` + @if (showChannelNumberOverlay()) { +
{{ channelNumberInput() }}
+ }`, }, }) .compileComponents(); @@ -247,6 +251,33 @@ describe('VideoPlayerComponent fullscreen channel panel + zapping', () => { fixture.destroy(); }); + // OnPush: the overlay hides from a 2 s timer, outside any template + // event, so the signal write itself must schedule the render. The test + // never forces one after the timer: a plain-field write would leave the + // overlay in the DOM. + it('hides the channel-number overlay when its debounce fires', async () => { + jest.useFakeTimers(); + try { + const overlay = () => + (fixture.nativeElement as HTMLElement).querySelector( + '.channel-number-overlay' + ); + fixture.autoDetectChanges(); + component.handleChannelNumberInput('2'); + await jest.advanceTimersByTimeAsync(50); + expect(overlay()?.textContent).toBe('2'); + + await jest.advanceTimersByTimeAsync(2000); + + expect(overlay()).toBeNull(); + expect(storeMock.dispatch).toHaveBeenCalledWith( + setActiveChannelDispatch(nextChannel) + ); + } finally { + jest.useRealTimers(); + } + }); + describe('FULLSCREEN_CHANNEL_PANEL host', () => { it.each([VideoPlayer.MPV, VideoPlayer.VLC])( 'withholds rows that would leave forced-inline DASH for %s', @@ -260,7 +291,7 @@ describe('VideoPlayerComponent fullscreen channel panel + zapping', () => { url: 'http://localhost/next.mpd', }; player.set(externalPlayer); - component.playerSettings.player = externalPlayer; + component.playerSettings.set({ player: externalPlayer }); setActive(dashChannel); channels.set([dashChannel, sampleChannel, nextDashChannel]); diff --git a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.html b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.html index 481352fc8..274f29958 100644 --- a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.html +++ b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.html @@ -85,7 +85,7 @@ [playbackSessionKey]="playbackSessionKey()" [inlinePlayerAvailable]="shouldShowInlinePlayer(activeChannel)" [volume]="volume()" - [playerOverride]="playerSettings.player ?? null" + [playerOverride]="playerSettings().player ?? null" (playbackStarted)="refreshVolumeFromBus()" (externalFallbackRequested)=" handleExternalFallbackRequest($event) @@ -124,7 +124,7 @@ [playerOverride]=" activeChannelIsDash() ? dashPlayerOverride() - : (playerSettings.player ?? null) + : (playerSettings().player ?? null) " [volume]="volume()" [timelineSegments]="catchupTimelineSegments()" @@ -255,10 +255,10 @@ } } - @if (showChannelNumberOverlay) { + @if (showChannelNumberOverlay()) {
- {{ channelNumberInput }} + {{ channelNumberInput() }}
} diff --git a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts index e10c4f119..592f5bd55 100644 --- a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts +++ b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts @@ -231,7 +231,7 @@ function isInsideScrollableRegion( { provide: EPG_GUIDE_SOURCE, useExisting: M3uEpgGuideSourceService }, ], templateUrl: './video-player.component.html', - changeDetection: ChangeDetectionStrategy.Eager, + changeDetection: ChangeDetectionStrategy.OnPush, styleUrl: './video-player.component.scss', }) export class VideoPlayerComponent @@ -713,9 +713,9 @@ export class VideoPlayerComponent ); /** Selected video player options */ - playerSettings: Partial = { + readonly playerSettings = signal>({ player: VideoPlayer.VideoJs, - }; + }); readonly isDesktop = this.runtime.isElectron; readonly supportsEpg = this.runtime.supportsEpg; @@ -731,8 +731,8 @@ export class VideoPlayerComponent ); /** Channel number input state */ - channelNumberInput = ''; - showChannelNumberOverlay = false; + readonly channelNumberInput = signal(''); + readonly showChannelNumberOverlay = signal(false); private channelNumberTimeout?: number; /** @@ -795,9 +795,9 @@ export class VideoPlayerComponent // React to settings changes effect(() => { - this.playerSettings = { + this.playerSettings.set({ player: this.settingsStore.player(), - }; + }); }); // Keep "now" fresh so EPG state re-evaluates over time. @@ -1191,10 +1191,10 @@ export class VideoPlayerComponent applySettings(): void { this.storage.get(STORE_KEY.Settings).subscribe((settings: unknown) => { if (settings && Object.keys(settings as Settings).length > 0) { - this.playerSettings = { + this.playerSettings.set({ player: (settings as Settings).player || VideoPlayer.VideoJs, - }; + }); } }); } @@ -1472,12 +1472,14 @@ export class VideoPlayerComponent } // Add digit to current input - this.channelNumberInput += digit; - this.showChannelNumberOverlay = true; + this.channelNumberInput.update((input) => input + digit); + this.showChannelNumberOverlay.set(true); // Set timeout to switch channel after 2 seconds of no input this.channelNumberTimeout = window.setTimeout(() => { - this.switchToChannelByNumber(parseInt(this.channelNumberInput, 10)); + this.switchToChannelByNumber( + parseInt(this.channelNumberInput(), 10) + ); this.clearChannelNumberInput(); }, 2000); } @@ -1547,8 +1549,8 @@ export class VideoPlayerComponent * Clear channel number input and hide overlay */ clearChannelNumberInput(): void { - this.channelNumberInput = ''; - this.showChannelNumberOverlay = false; + this.channelNumberInput.set(''); + this.showChannelNumberOverlay.set(false); if (this.channelNumberTimeout) { clearTimeout(this.channelNumberTimeout); this.channelNumberTimeout = undefined; @@ -1663,7 +1665,7 @@ export class VideoPlayerComponent return true; } - const player = this.playerSettings.player; + const player = this.playerSettings().player; return ( !this.isExternalPlayer(player) && player !== VideoPlayer.EmbeddedMpv ); @@ -1685,7 +1687,7 @@ export class VideoPlayerComponent return true; } - return !this.isExternalPlayer(this.playerSettings.player); + return !this.isExternalPlayer(this.playerSettings().player); } handleExternalFallbackRequest(request: PlaybackFallbackRequest): void { diff --git a/libs/ui/playback/src/lib/art-player/art-player.component.ts b/libs/ui/playback/src/lib/art-player/art-player.component.ts index 208b36c4f..5710777a8 100644 --- a/libs/ui/playback/src/lib/art-player/art-player.component.ts +++ b/libs/ui/playback/src/lib/art-player/art-player.component.ts @@ -51,7 +51,7 @@ Artplayer.AUTO_PLAYBACK_TIMEOUT = 10000; ], providers: [WebVideoControlsAdapter], templateUrl: './art-player.component.html', - changeDetection: ChangeDetectionStrategy.Eager, + changeDetection: ChangeDetectionStrategy.OnPush, styleUrls: ['./art-player.component.scss'], }) export class ArtPlayerComponent implements OnInit, OnDestroy, OnChanges { diff --git a/libs/ui/playback/src/lib/audio-player/audio-player.component.ts b/libs/ui/playback/src/lib/audio-player/audio-player.component.ts index 34469d51a..b8464d100 100644 --- a/libs/ui/playback/src/lib/audio-player/audio-player.component.ts +++ b/libs/ui/playback/src/lib/audio-player/audio-player.component.ts @@ -167,7 +167,7 @@ import { PlaybackHistoryConfirmation } from '../playback-history/playback-histor `, styleUrls: ['./audio-player.component.scss'], - changeDetection: ChangeDetectionStrategy.Eager, + changeDetection: ChangeDetectionStrategy.OnPush, imports: [ FormsModule, MatButtonModule, diff --git a/libs/ui/playback/src/lib/external-player-info-dialog/external-player-info-dialog.component.ts b/libs/ui/playback/src/lib/external-player-info-dialog/external-player-info-dialog.component.ts index f9e3ab956..415a2ab28 100644 --- a/libs/ui/playback/src/lib/external-player-info-dialog/external-player-info-dialog.component.ts +++ b/libs/ui/playback/src/lib/external-player-info-dialog/external-player-info-dialog.component.ts @@ -57,7 +57,7 @@ import { TranslateModule } from '@ngx-translate/core'; } `, ], - changeDetection: ChangeDetectionStrategy.Eager, + changeDetection: ChangeDetectionStrategy.OnPush, imports: [ MatButtonModule, MatCheckboxModule, diff --git a/libs/ui/playback/src/lib/html-video-player/html-video-player.component.ts b/libs/ui/playback/src/lib/html-video-player/html-video-player.component.ts index a0384a293..924e72e07 100644 --- a/libs/ui/playback/src/lib/html-video-player/html-video-player.component.ts +++ b/libs/ui/playback/src/lib/html-video-player/html-video-player.component.ts @@ -71,7 +71,7 @@ const debugHtmlPlayer = createDevLogger('HtmlVideoPlayer'); SeriesPlaybackNavigationControlsComponent, ], providers: [WebVideoControlsAdapter], - changeDetection: ChangeDetectionStrategy.Eager, + changeDetection: ChangeDetectionStrategy.OnPush, standalone: true, }) export class HtmlVideoPlayerComponent implements OnInit, OnChanges, OnDestroy { diff --git a/libs/ui/playback/src/lib/video-player/sidebar/sidebar.component.ts b/libs/ui/playback/src/lib/video-player/sidebar/sidebar.component.ts index 3e2cc0cfe..3f97a4dbe 100644 --- a/libs/ui/playback/src/lib/video-player/sidebar/sidebar.component.ts +++ b/libs/ui/playback/src/lib/video-player/sidebar/sidebar.component.ts @@ -20,7 +20,7 @@ import { ChannelListContainerComponent } from '@iptvnator/ui/components'; selector: 'app-sidebar', templateUrl: './sidebar.component.html', styleUrls: ['./sidebar.component.scss'], - changeDetection: ChangeDetectionStrategy.Eager, + changeDetection: ChangeDetectionStrategy.OnPush, imports: [ ChannelListContainerComponent, MatIcon, diff --git a/libs/ui/playback/src/lib/vjs-player/vjs-player.component.ts b/libs/ui/playback/src/lib/vjs-player/vjs-player.component.ts index 6770f466e..2996a44cc 100644 --- a/libs/ui/playback/src/lib/vjs-player/vjs-player.component.ts +++ b/libs/ui/playback/src/lib/vjs-player/vjs-player.component.ts @@ -66,7 +66,7 @@ const debugVjsPlayer = createDevLogger('VjsPlayer'); SeriesPlaybackNavigationControlsComponent, ], providers: [WebVideoControlsAdapter], - changeDetection: ChangeDetectionStrategy.Eager, + changeDetection: ChangeDetectionStrategy.OnPush, standalone: true, }) export class VjsPlayerComponent implements OnInit, OnChanges, OnDestroy { diff --git a/libs/ui/playback/src/lib/vod-details/vod-details.component.ts b/libs/ui/playback/src/lib/vod-details/vod-details.component.ts index 4d3b658e2..9e4e4f49f 100644 --- a/libs/ui/playback/src/lib/vod-details/vod-details.component.ts +++ b/libs/ui/playback/src/lib/vod-details/vod-details.component.ts @@ -82,7 +82,7 @@ import { createVodSimilarInPortals } from './vod-similar-in-portals.state'; selector: 'app-vod-details', templateUrl: './vod-details.component.html', styleUrls: ['../styles/detail-view.scss'], - changeDetection: ChangeDetectionStrategy.Eager, + changeDetection: ChangeDetectionStrategy.OnPush, imports: [ CastCrewRowComponent, DetailActionButtonComponent, diff --git a/libs/ui/playback/src/lib/web-player-view/web-player-view.component.ts b/libs/ui/playback/src/lib/web-player-view/web-player-view.component.ts index a1c55b8f9..ab0ee9aff 100644 --- a/libs/ui/playback/src/lib/web-player-view/web-player-view.component.ts +++ b/libs/ui/playback/src/lib/web-player-view/web-player-view.component.ts @@ -91,7 +91,7 @@ import { resolveWebPlayerSharedControls } from './web-player-shared-controls'; useFactory: resolveWebPlayerSharedControls, }, ], - changeDetection: ChangeDetectionStrategy.Eager, + changeDetection: ChangeDetectionStrategy.OnPush, encapsulation: ViewEncapsulation.None, }) export class WebPlayerViewComponent implements OnDestroy {