mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-10 01:56:16 -08:00
fix(playlist): mount the saved engine for recognized M3U movies
WebPlayerViewComponent resolves the engine from its own asynchronous settings read and falls back to Video.js until it lands. The engine is part of the application token, so anyone whose saved player is not Video.js got a first application on the wrong engine and a swap a tick later. The M3U live layout never showed it because it passes an explicit playerOverride; the detail path goes through the shared inline player, which had no way to. PortalInlinePlayerComponent now accepts an optional playerOverride (null keeps the portals' behaviour) and the M3U detail chain forwards the value the live layout already uses. The E2E guard is a MutationObserver over engine mounts, not a polling locator: both engines leave the same final DOM, so only the record of what was ever attached can see the defect. Verified to fail without the fix. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
d0d29567be
commit
5b4079726e
12 files changed
+67
-137
No files matched your search
@@ -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<string>();
|
||||
(window as unknown as { __enginesSeen: Set<string> }).__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<string> })
|
||||
.__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
|
||||
|
||||
@@ -91,6 +91,7 @@
|
||||
[playbackSessionKey]="playbackSessionKey()"
|
||||
[playback]="playback"
|
||||
[volume]="volume()"
|
||||
[playerOverride]="playerOverride()"
|
||||
(closed)="closeInlinePlayback()"
|
||||
(externalFallbackRequested)="externalFallbackRequested.emit($event)"
|
||||
/>
|
||||
|
||||
+9
@@ -45,6 +45,7 @@ class StubPortalInlinePlayerComponent {
|
||||
readonly playbackSessionKey = input.required<string>();
|
||||
readonly playback = input<ResolvedPortalPlayback | null>(null);
|
||||
readonly volume = input(1);
|
||||
readonly playerOverride = input<unknown>(null);
|
||||
readonly closed = output<void>();
|
||||
readonly externalFallbackRequested = output<unknown>();
|
||||
}
|
||||
@@ -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
|
||||
|
||||
+11
-1
@@ -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<VideoPlayer | null>(null);
|
||||
|
||||
readonly externalFallbackRequested = output<PlaybackFallbackRequest>();
|
||||
/**
|
||||
|
||||
@@ -71,6 +71,7 @@
|
||||
[playbackSessionKey]="playbackSessionKey()"
|
||||
[inlinePlayerAvailable]="shouldShowInlinePlayer(activeChannel)"
|
||||
[volume]="volume()"
|
||||
[playerOverride]="playerSettings.player ?? null"
|
||||
(playbackStarted)="refreshVolumeFromBus()"
|
||||
(externalFallbackRequested)="
|
||||
handleExternalFallbackRequest($event)
|
||||
|
||||
@@ -27,6 +27,7 @@ class StubWebPlayerViewComponent {
|
||||
readonly mediaTitle = input<unknown>(null);
|
||||
readonly playback = input<unknown>(null);
|
||||
readonly volume = input(1);
|
||||
readonly playerOverride = input<unknown>(null);
|
||||
readonly startTime = input(0);
|
||||
readonly seriesNavigation = input<unknown>(null);
|
||||
readonly alternativeSources = input<unknown[]>([]);
|
||||
|
||||
@@ -24,6 +24,7 @@ class StubWebPlayerViewComponent {
|
||||
readonly mediaTitle = input<unknown>(null);
|
||||
readonly playback = input<unknown>(null);
|
||||
readonly volume = input(1);
|
||||
readonly playerOverride = input<unknown>(null);
|
||||
readonly startTime = input(0);
|
||||
readonly seriesNavigation = input<unknown>(null);
|
||||
readonly alternativeSources = input<unknown[]>([]);
|
||||
|
||||
@@ -90,6 +90,7 @@
|
||||
[mediaTitle]="playerMediaTitle()"
|
||||
[playback]="playback()"
|
||||
[volume]="volume()"
|
||||
[playerOverride]="playerOverride()"
|
||||
[startTime]="startTime()"
|
||||
[seriesNavigation]="seriesNavigation()"
|
||||
[alternativeSources]="alternativeSources()"
|
||||
|
||||
@@ -27,6 +27,7 @@ class StubWebPlayerViewComponent {
|
||||
readonly mediaTitle = input<unknown>(null);
|
||||
readonly playback = input<unknown>(null);
|
||||
readonly volume = input(1);
|
||||
readonly playerOverride = input<unknown>(null);
|
||||
readonly startTime = input(0);
|
||||
readonly seriesNavigation = input<unknown>(null);
|
||||
readonly alternativeSources = input<unknown[]>([]);
|
||||
|
||||
@@ -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<VideoPlayer | null>(null);
|
||||
private readonly settingsStore = inject(SettingsStore);
|
||||
// Strip only live-channel titles — VOD/series titles ("Mission:
|
||||
// Impossible - Fallout") must never lose their leading segment.
|
||||
|
||||
-125
@@ -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: '<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,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<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