mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-10 01:56:16 -08:00
fix(playback): resolve the player from the store while settings load
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 <noreply@anthropic.com>
This commit is contained in:
1 parent
bb8251186b
commit
d0d29567be
2 files changed
+136
No files matched your search
+125
@@ -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: '<div data-test-id="stub-vjs"></div>',
|
||||
})
|
||||
class StubVjsPlayerComponent {
|
||||
readonly options = input.required<unknown>();
|
||||
readonly volume = input(1);
|
||||
readonly timeUpdate = output<unknown>();
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-html-video-player',
|
||||
template: '<div data-test-id="stub-html5"></div>',
|
||||
})
|
||||
class StubHtmlVideoPlayerComponent {
|
||||
readonly channel = input.required<unknown>();
|
||||
readonly volume = input(1);
|
||||
readonly timeUpdate = output<unknown>();
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<WebPlayerViewComponentInstance>;
|
||||
const storedPlayer = signal<VideoPlayer>(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
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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<VideoPlayer>(() => {
|
||||
const temporary = this.recoverySession.temporaryPlayerOverride();
|
||||
return temporary
|
||||
? toVideoPlayer(temporary)
|
||||
: (this.playerOverride() ??
|
||||
this.settings()?.player ??
|
||||
this.settingsStore.player?.() ??
|
||||
VideoPlayer.VideoJs);
|
||||
});
|
||||
private readonly applicationState = createWebPlayerApplicationState({
|
||||
|
||||
Reference in new issue
Block a user