diff --git a/apps/web-e2e/src/m3u-movie-details.e2e.ts b/apps/web-e2e/src/m3u-movie-details.e2e.ts index 7f21e369d..a163b4656 100644 --- a/apps/web-e2e/src/m3u-movie-details.e2e.ts +++ b/apps/web-e2e/src/m3u-movie-details.e2e.ts @@ -145,12 +145,45 @@ test('@web @m3u @tmdb recognized movies open the VOD detail view', async ({ await enableTmdb(page); await importPlaylist(page); + // Record every engine that is ever attached. A polling assertion cannot + // see the defect this guards: mounting Video.js first and correcting to + // the saved engine a tick later leaves the same final DOM. + await page.evaluate(() => { + const seen = new Set(); + (window as unknown as { __enginesSeen: Set }).__enginesSeen = + seen; + const record = () => { + for (const selector of [ + 'app-vjs-player', + 'app-html-video-player', + ]) { + if (document.querySelector(selector)) { + seen.add(selector); + } + } + }; + record(); + new MutationObserver(record).observe(document.body, { + childList: true, + subtree: true, + }); + }); + // Watch-first: activating the entry plays immediately inside the detail // shell, with no EPG zone in sight. await sidebarEntry(page, 'Dune (2021) 1080p').click(); await expect(detail(page)).toBeVisible(); await expect(inlineVideo(page)).toBeVisible(); await expect(page.locator('app-epg-timeline')).toHaveCount(0); + // The engine is part of the application token, so the saved player must + // mount FIRST TIME — a late correction swaps the player mid-playback. + await expect(detail(page).locator('app-html-video-player')).toHaveCount(1); + expect( + await page.evaluate(() => [ + ...(window as unknown as { __enginesSeen: Set }) + .__enginesSeen, + ]) + ).toEqual(['app-html-video-player']); // Metadata patches the mounted view asynchronously. The shell stamps the // host templates into BOTH the hero and the watch-state About block, so diff --git a/libs/playlist/m3u/feature-player/src/lib/m3u-vod-detail/m3u-vod-detail.component.html b/libs/playlist/m3u/feature-player/src/lib/m3u-vod-detail/m3u-vod-detail.component.html index 0a29562ff..4936ba425 100644 --- a/libs/playlist/m3u/feature-player/src/lib/m3u-vod-detail/m3u-vod-detail.component.html +++ b/libs/playlist/m3u/feature-player/src/lib/m3u-vod-detail/m3u-vod-detail.component.html @@ -91,6 +91,7 @@ [playbackSessionKey]="playbackSessionKey()" [playback]="playback" [volume]="volume()" + [playerOverride]="playerOverride()" (closed)="closeInlinePlayback()" (externalFallbackRequested)="externalFallbackRequested.emit($event)" /> diff --git a/libs/playlist/m3u/feature-player/src/lib/m3u-vod-detail/m3u-vod-detail.component.spec.ts b/libs/playlist/m3u/feature-player/src/lib/m3u-vod-detail/m3u-vod-detail.component.spec.ts index 8d3a52086..b29fa15a3 100644 --- a/libs/playlist/m3u/feature-player/src/lib/m3u-vod-detail/m3u-vod-detail.component.spec.ts +++ b/libs/playlist/m3u/feature-player/src/lib/m3u-vod-detail/m3u-vod-detail.component.spec.ts @@ -45,6 +45,7 @@ class StubPortalInlinePlayerComponent { readonly playbackSessionKey = input.required(); readonly playback = input(null); readonly volume = input(1); + readonly playerOverride = input(null); readonly closed = output(); readonly externalFallbackRequested = output(); } @@ -187,6 +188,14 @@ describe('M3uVodDetailComponent', () => { expect(inlinePlayerStub()?.volume()).toBe(0.35); }); + it('forwards the resolved engine so the player never mounts a wrong one', async () => { + await create({ channel: channel(), playback: playback() }); + fixture.componentRef.setInput('playerOverride', 'html5'); + fixture.detectChanges(); + + expect(inlinePlayerStub()?.playerOverride()).toBe('html5'); + }); + it('keeps the playback payload identical when TMDB metadata lands', async () => { // Payload identity is the player's source-application key: a new // object recreates the player and restarts the movie. Enrichment must 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 fe3ae5658..a55dc2b02 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 @@ -17,7 +17,11 @@ import { tmdbPosterUrl, topCast, } from '@iptvnator/services'; -import { Channel, ResolvedPortalPlayback } from '@iptvnator/shared/interfaces'; +import { + Channel, + ResolvedPortalPlayback, + VideoPlayer, +} from '@iptvnator/shared/interfaces'; import { DetailActionsTemplateDirective, DetailMetaTemplateDirective, @@ -68,6 +72,12 @@ export class M3uVodDetailComponent { readonly inlinePlayerAvailable = input(true); /** M3U's shared persisted volume (localStorage `volume`). */ readonly volume = input(1); + /** + * Engine the parent already resolved from settings. Without it the shared + * player waits for its own asynchronous settings read and mounts Video.js + * meanwhile, then swaps — the live layout passes the same value. + */ + readonly playerOverride = input(null); readonly externalFallbackRequested = output(); /** 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 e5dee4647..35ae9e3f8 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 @@ -71,6 +71,7 @@ [playbackSessionKey]="playbackSessionKey()" [inlinePlayerAvailable]="shouldShowInlinePlayer(activeChannel)" [volume]="volume()" + [playerOverride]="playerSettings.player ?? null" (playbackStarted)="refreshVolumeFromBus()" (externalFallbackRequested)=" handleExternalFallbackRequest($event) diff --git a/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player-sources.spec.ts b/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player-sources.spec.ts index 6b4f7f3a6..40064d879 100644 --- a/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player-sources.spec.ts +++ b/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player-sources.spec.ts @@ -27,6 +27,7 @@ class StubWebPlayerViewComponent { readonly mediaTitle = input(null); readonly playback = input(null); readonly volume = input(1); + readonly playerOverride = input(null); readonly startTime = input(0); readonly seriesNavigation = input(null); readonly alternativeSources = input([]); diff --git a/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player-up-next.spec.ts b/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player-up-next.spec.ts index e8ecacb6d..4d6e9124c 100644 --- a/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player-up-next.spec.ts +++ b/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player-up-next.spec.ts @@ -24,6 +24,7 @@ class StubWebPlayerViewComponent { readonly mediaTitle = input(null); readonly playback = input(null); readonly volume = input(1); + readonly playerOverride = input(null); readonly startTime = input(0); readonly seriesNavigation = input(null); readonly alternativeSources = input([]); diff --git a/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player.component.html b/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player.component.html index 00d15040c..593f9b881 100644 --- a/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player.component.html +++ b/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player.component.html @@ -90,6 +90,7 @@ [mediaTitle]="playerMediaTitle()" [playback]="playback()" [volume]="volume()" + [playerOverride]="playerOverride()" [startTime]="startTime()" [seriesNavigation]="seriesNavigation()" [alternativeSources]="alternativeSources()" diff --git a/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player.component.spec.ts b/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player.component.spec.ts index 30e40ef4e..a96f35c2f 100644 --- a/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player.component.spec.ts +++ b/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player.component.spec.ts @@ -27,6 +27,7 @@ class StubWebPlayerViewComponent { readonly mediaTitle = input(null); readonly playback = input(null); readonly volume = input(1); + readonly playerOverride = input(null); readonly startTime = input(0); readonly seriesNavigation = input(null); readonly alternativeSources = input([]); diff --git a/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player.component.ts b/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player.component.ts index bce038d85..e05301355 100644 --- a/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player.component.ts +++ b/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player.component.ts @@ -78,6 +78,14 @@ export class PortalInlinePlayerComponent { * engines' own default, which is what this default preserves. */ readonly volume = input(1); + /** + * Engine the host already resolved. `WebPlayerViewComponent` otherwise + * waits for its own asynchronous settings read and mounts Video.js + * meanwhile — and the engine is part of the application token, so that + * correction swaps the player under a running session. Hosts that hold + * the settings synchronously pass them; `null` keeps the old behaviour. + */ + readonly playerOverride = input(null); private readonly settingsStore = inject(SettingsStore); // Strip only live-channel titles — VOD/series titles ("Mission: // Impossible - Fallout") must never lose their leading segment. diff --git a/libs/ui/playback/src/lib/web-player-view/web-player-view.component.player-fallback.spec.ts b/libs/ui/playback/src/lib/web-player-view/web-player-view.component.player-fallback.spec.ts deleted file mode 100644 index 3b221dbce..000000000 --- a/libs/ui/playback/src/lib/web-player-view/web-player-view.component.player-fallback.spec.ts +++ /dev/null @@ -1,125 +0,0 @@ -import { Component, input, output, signal } from '@angular/core'; -import { - ComponentFixture, - DeferBlockBehavior, - TestBed, -} from '@angular/core/testing'; -import { StorageMap } from '@ngx-pwa/local-storage'; -import { TranslateModule } from '@ngx-translate/core'; -import { RuntimeCapabilitiesService, SettingsStore } from '@iptvnator/services'; -import { VideoPlayer } from '@iptvnator/shared/interfaces'; -import { EMPTY } from 'rxjs'; -import type { WebPlayerViewComponent as WebPlayerViewComponentInstance } from './web-player-view.component'; - -jest.unstable_mockModule('video.js', () => ({ default: jest.fn() })); -jest.unstable_mockModule('@yangkghjh/videojs-aspect-ratio-panel', () => ({})); -jest.unstable_mockModule('videojs-contrib-quality-levels', () => ({})); -jest.unstable_mockModule('videojs-quality-selector-hls', () => ({})); - -@Component({ - selector: 'app-vjs-player', - template: '
', -}) -class StubVjsPlayerComponent { - readonly options = input.required(); - readonly volume = input(1); - readonly timeUpdate = output(); -} - -@Component({ - selector: 'app-html-video-player', - template: '
', -}) -class StubHtmlVideoPlayerComponent { - readonly channel = input.required(); - readonly volume = input(1); - readonly timeUpdate = output(); -} - -/** - * The engine is part of the application token, so choosing the wrong one - * before the settings arrive mounts a second application and swaps the player - * under the user mid-playback. The persisted settings read is asynchronous; - * `SettingsStore` holds the same value synchronously. - */ -describe('WebPlayerViewComponent player fallback', () => { - let WebPlayerViewComponent: typeof WebPlayerViewComponentInstance; - let fixture: ComponentFixture; - const storedPlayer = signal(VideoPlayer.Html5Player); - - beforeAll(async () => { - ({ WebPlayerViewComponent } = - await import('./web-player-view.component')); - }); - - const create = async (playerOverride: VideoPlayer | null = null) => { - await TestBed.configureTestingModule({ - deferBlockBehavior: DeferBlockBehavior.Playthrough, - imports: [WebPlayerViewComponent, TranslateModule.forRoot()], - providers: [ - // Never emits: stands in for the IndexedDB read still pending. - { provide: StorageMap, useValue: { get: () => EMPTY } }, - { - provide: SettingsStore, - useValue: { - player: storedPlayer, - showCaptions: signal(false), - }, - }, - { - provide: RuntimeCapabilitiesService, - useValue: { supportsManagedExternalPlayers: false }, - }, - ], - }) - .overrideComponent(WebPlayerViewComponent, { - set: { - imports: [ - StubVjsPlayerComponent, - StubHtmlVideoPlayerComponent, - ], - }, - }) - .compileComponents(); - - fixture = TestBed.createComponent(WebPlayerViewComponent); - fixture.componentRef.setInput('playbackSessionKey', 'live:p1:c1'); - fixture.componentRef.setInput('streamUrl', 'http://host/movie.mkv'); - if (playerOverride !== null) { - fixture.componentRef.setInput('playerOverride', playerOverride); - } - fixture.detectChanges(); - }; - - afterEach(() => { - fixture?.destroy(); - TestBed.resetTestingModule(); - }); - - it('uses the store while the persisted settings read is pending', async () => { - storedPlayer.set(VideoPlayer.Html5Player); - await create(); - - expect(fixture.componentInstance.selectedPlayer()).toBe( - VideoPlayer.Html5Player - ); - }); - - it('still lets a host override win', async () => { - storedPlayer.set(VideoPlayer.Html5Player); - await create(VideoPlayer.ArtPlayer); - - expect(fixture.componentInstance.selectedPlayer()).toBe( - VideoPlayer.ArtPlayer - ); - }); - - it('falls back to Video.js when the store has no player either', async () => { - storedPlayer.set(undefined as unknown as VideoPlayer); - await create(); - - expect(fixture.componentInstance.selectedPlayer()).toBe( - VideoPlayer.VideoJs - ); - }); -}); 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 dcbd9c4cd..693ca8f83 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 @@ -150,23 +150,12 @@ export class WebPlayerViewComponent implements OnDestroy { channel: Channel | undefined; vjsOptions: VideoPlayerOptions | undefined; - /** - * `settings` is an async IndexedDB read with no initial value, so before - * it resolves the fallback decides which engine mounts — and the engine - * is part of the application token, so a late correction mounts a second - * application and swaps the player under the user. `SettingsStore` holds - * the same value synchronously (the workspace route awaits its initial - * load), which closes that window; hosts passing an explicit override - * still win, and an unhydrated store returns the same VideoJs default - * this used to hard-code. - */ readonly selectedPlayer = computed(() => { const temporary = this.recoverySession.temporaryPlayerOverride(); return temporary ? toVideoPlayer(temporary) : (this.playerOverride() ?? this.settings()?.player ?? - this.settingsStore.player?.() ?? VideoPlayer.VideoJs); }); private readonly applicationState = createWebPlayerApplicationState({