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({