From d0d29567be153ed605782e0ea2cb0e5c64a3dc6b Mon Sep 17 00:00:00 2001 From: 4gray Date: Thu, 13 Aug 2026 10:37:21 +0200 Subject: [PATCH] fix(playback): resolve the player from the store while settings load MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The engine is part of the application token, so picking one before the persisted settings arrive mounts a second application and swaps the player mid-playback. The settings read is an async IndexedDB subscription with no initial value, so anyone who had not selected Video.js briefly got it — visible on the new M3U movie detail path, which (unlike the live layout) mounts the shared portal inline player and passes no explicit override. SettingsStore holds the same value synchronously; an unhydrated store still yields the Video.js default this used to hard-code, and host overrides keep winning. Fixes the transient wrong engine for the portals' inline players too. Co-Authored-By: Claude Fable 5 --- ...yer-view.component.player-fallback.spec.ts | 125 ++++++++++++++++++ .../web-player-view.component.ts | 11 ++ 2 files changed, 136 insertions(+) create mode 100644 libs/ui/playback/src/lib/web-player-view/web-player-view.component.player-fallback.spec.ts 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 new file mode 100644 index 000000000..3b221dbce --- /dev/null +++ b/libs/ui/playback/src/lib/web-player-view/web-player-view.component.player-fallback.spec.ts @@ -0,0 +1,125 @@ +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 693ca8f83..dcbd9c4cd 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,12 +150,23 @@ 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({