mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-09 01:16:15 -08:00
fix(playback): apply saved player changes to mounted web players
WebPlayerViewComponent resolved the saved engine from a one-shot StorageMap snapshot taken at mount, so a player switch from the command palette or settings page confirmed via snackbar and persisted the setting while an already-mounted Xtream/Stalker player silently kept the previous engine. The same snapshot also made first play mount the default Video.js engine and swap to the saved one once the async read landed. Resolve the player (and recording folder) from the live SettingsStore signal instead and drop the snapshot entirely. Precedence is unchanged: temporary recovery override -> host playerOverride -> saved player -> Video.js. Hosts passing no override (Xtream/Stalker live layouts, the portal inline detail player) now track saved changes in place; first mount reads the already-loaded store, so the default engine no longer flashes. Regression coverage (all verified to fail with the fix reverted): three unit tests on the component and two Xtream live-route e2e tests — a palette switch reaching the mounted player without a layout remount, and a MutationObserver engines-ever-seen assertion that the saved engine mounts first time. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
f7bb3a13db
commit
6eab0938ea
5 files changed
+243
-27
No files matched your search
@@ -0,0 +1,10 @@
|
||||
---
|
||||
type: fix
|
||||
area: playback
|
||||
---
|
||||
|
||||
Switching the video player — from the command palette or the settings page —
|
||||
now takes effect immediately on an already-playing Xtream or Stalker stream
|
||||
instead of waiting for the player to be reopened. Playback also starts with
|
||||
the saved player right away, without briefly mounting the default engine
|
||||
first.
|
||||
@@ -534,6 +534,139 @@ test('@xtream minimal scenario — reduced item count', async ({ request }) => {
|
||||
expect(streams.length).toBe(10);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Player engine selection on the live route
|
||||
//
|
||||
// The Xtream live layout mounts app-web-player-view WITHOUT a playerOverride,
|
||||
// so the engine must track the saved player setting live. Regression guarded:
|
||||
// the engine used to come from a one-shot storage snapshot taken at mount, so
|
||||
// a command-palette switch confirmed via snackbar and persisted the setting
|
||||
// while the mounted player silently kept the previous engine.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function openLiveChannel(page: Page): Promise<void> {
|
||||
await page.goto(page.url().replace(/\/vod.*$/, '/live'));
|
||||
|
||||
// On the live root the category click updates store state without
|
||||
// navigating; the channel sidebar appearing is the completion signal.
|
||||
const firstCategory = page
|
||||
.locator('.context-panel .category-item')
|
||||
.first();
|
||||
await expect(firstCategory).toBeVisible();
|
||||
await firstCategory.click();
|
||||
|
||||
const channel = page
|
||||
.locator('app-live-stream-layout [data-test-id="channel-item"]')
|
||||
.first();
|
||||
await expect(channel).toBeVisible();
|
||||
await channel.click();
|
||||
}
|
||||
|
||||
test('@xtream command palette player switch reaches the mounted live player', async ({
|
||||
page,
|
||||
}) => {
|
||||
await addXtreamPortal(page);
|
||||
await openLiveChannel(page);
|
||||
|
||||
const playerView = page.locator('app-web-player-view');
|
||||
await expect(playerView.locator('app-vjs-player')).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
|
||||
// Tag the mounted layout so the final assertion proves the switch reached
|
||||
// the EXISTING player instead of surviving through a layout remount.
|
||||
await page.locator('app-live-stream-layout').evaluate((el) => {
|
||||
(el as HTMLElement & { __e2eSameMount?: boolean }).__e2eSameMount =
|
||||
true;
|
||||
});
|
||||
|
||||
await page.keyboard.press('Control+k');
|
||||
const palette = page.locator('.workspace-command-palette-overlay');
|
||||
await expect(palette).toBeVisible();
|
||||
await palette.locator('input[type="search"]').fill('html5');
|
||||
await palette
|
||||
.getByRole('button', { name: 'Switch player to HTML5 video player' })
|
||||
.click();
|
||||
|
||||
await expect(playerView.locator('app-html-video-player')).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
await expect(playerView.locator('app-vjs-player')).toHaveCount(0);
|
||||
expect(
|
||||
await page
|
||||
.locator('app-live-stream-layout')
|
||||
.evaluate(
|
||||
(el) =>
|
||||
(el as HTMLElement & { __e2eSameMount?: boolean })
|
||||
.__e2eSameMount
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test('@xtream the saved engine mounts the live player first time — no default-engine flash', async ({
|
||||
page,
|
||||
}) => {
|
||||
// Persist HTML5 as the saved player before any player ever mounts.
|
||||
await page.goto('/workspace/settings/playback');
|
||||
await page.locator('[data-test-id="select-video-player"]').click();
|
||||
await page
|
||||
.getByRole('option', { name: 'HTML5 video player', exact: true })
|
||||
.click();
|
||||
const saveButton = page.getByRole('button', { name: 'Save changes' });
|
||||
await saveButton.click();
|
||||
await expect(saveButton).toBeHidden();
|
||||
|
||||
await page.goto('/');
|
||||
await addXtreamPortal(page);
|
||||
await page.goto(page.url().replace(/\/vod.*$/, '/live'));
|
||||
const firstCategory = page
|
||||
.locator('.context-panel .category-item')
|
||||
.first();
|
||||
await expect(firstCategory).toBeVisible();
|
||||
await firstCategory.click();
|
||||
|
||||
// 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 once the async settings read lands 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,
|
||||
});
|
||||
});
|
||||
|
||||
const channel = page
|
||||
.locator('app-live-stream-layout [data-test-id="channel-item"]')
|
||||
.first();
|
||||
await expect(channel).toBeVisible();
|
||||
await channel.click();
|
||||
|
||||
await expect(
|
||||
page.locator('app-web-player-view app-html-video-player')
|
||||
).toBeVisible({ timeout: 15_000 });
|
||||
expect(
|
||||
await page.evaluate(() => [
|
||||
...(window as unknown as { __enginesSeen: Set<string> })
|
||||
.__enginesSeen,
|
||||
])
|
||||
).toEqual(['app-html-video-player']);
|
||||
});
|
||||
|
||||
type XtreamLiveStream = {
|
||||
category_id: string;
|
||||
name: string;
|
||||
|
||||
@@ -364,7 +364,20 @@ Current contract:
|
||||
|
||||
The shared `WebPlayerViewComponent` is the central browser-player viewport for
|
||||
M3U, Xtream, and Stalker inline playback, including live streams opened from
|
||||
favorites and recently viewed collections. Video.js, HTML5, and ArtPlayer
|
||||
favorites and recently viewed collections.
|
||||
|
||||
The mounted engine resolves in a fixed order: temporary recovery override →
|
||||
host `playerOverride` input → the saved player read from the live
|
||||
`SettingsStore` signal (Video.js as the last-resort default). The saved player
|
||||
is deliberately NOT a mount-time storage snapshot: persisting a different
|
||||
player — from the settings page or the command palette — re-applies to every
|
||||
mounted `WebPlayerViewComponent` in place as a new playback application, and a
|
||||
first mount reads the already-loaded store value so the default engine never
|
||||
flashes before the saved one. Hosts that pass no `playerOverride` (the Xtream
|
||||
and Stalker live layouts, the portal inline detail player) rely on this live
|
||||
tracking.
|
||||
|
||||
Video.js, HTML5, and ArtPlayer
|
||||
report native media errors, hls.js errors, Video.js/VHS errors, Shaka errors,
|
||||
mpegts.js errors, and HLS manifest codec metadata into the DOM-free classifiers
|
||||
exported by `@iptvnator/playback/util`.
|
||||
|
||||
@@ -12,9 +12,9 @@ import { By } from '@angular/platform-browser';
|
||||
import { VodSourceRowComponent } from '@iptvnator/ui/components';
|
||||
import { StorageMap } from '@ngx-pwa/local-storage';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { of, Subject } from 'rxjs';
|
||||
import { of } from 'rxjs';
|
||||
import { VideoPlayer } from '@iptvnator/shared/interfaces';
|
||||
import { RuntimeCapabilitiesService } from '@iptvnator/services';
|
||||
import { RuntimeCapabilitiesService, SettingsStore } from '@iptvnator/services';
|
||||
import { ErrorDetails, ErrorTypes } from 'hls.js';
|
||||
import type { WebPlayerViewComponent as WebPlayerViewComponentInstance } from './web-player-view.component';
|
||||
import {
|
||||
@@ -119,6 +119,7 @@ describe('WebPlayerViewComponent', () => {
|
||||
let component: WebPlayerViewComponentInstance;
|
||||
const storageMap = {
|
||||
get: jest.fn(() => of({ player: VideoPlayer.VideoJs })),
|
||||
set: jest.fn(() => of(undefined)),
|
||||
};
|
||||
let runtimeCapabilities: { supportsManagedExternalPlayers: boolean };
|
||||
|
||||
@@ -591,19 +592,7 @@ describe('WebPlayerViewComponent', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('renders embedded MPV before settings storage emits', () => {
|
||||
fixture.destroy();
|
||||
|
||||
const pendingSettings = new Subject<unknown>();
|
||||
storageMap.get.mockReturnValue(pendingSettings.asObservable());
|
||||
fixture = TestBed.createComponent(WebPlayerViewComponent);
|
||||
fixture.componentRef.setInput('playbackSessionKey', 'test-session');
|
||||
component = fixture.componentInstance;
|
||||
fixture.componentRef.setInput(
|
||||
'streamUrl',
|
||||
'https://example.com/archive/movie.mkv'
|
||||
);
|
||||
fixture.componentRef.setInput('title', 'Example Movie');
|
||||
it('renders embedded MPV with an empty recording folder fallback', () => {
|
||||
fixture.componentRef.setInput(
|
||||
'playerOverride',
|
||||
VideoPlayer.EmbeddedMpv
|
||||
@@ -617,6 +606,83 @@ describe('WebPlayerViewComponent', () => {
|
||||
expect(player.recordingFolder()).toBe('');
|
||||
});
|
||||
|
||||
describe('saved player changes', () => {
|
||||
// The selected engine must come from the live SettingsStore signal.
|
||||
// It used to come from a one-shot StorageMap snapshot taken at mount,
|
||||
// so a saved player change (settings page, command palette) never
|
||||
// reached an already-mounted Xtream/Stalker player.
|
||||
it('switches the mounted engine when the saved player changes', async () => {
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(
|
||||
fixture.debugElement.query(By.directive(StubVjsPlayerComponent))
|
||||
).not.toBeNull();
|
||||
|
||||
await TestBed.inject(SettingsStore).updateSettings({
|
||||
player: VideoPlayer.Html5Player,
|
||||
});
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(
|
||||
fixture.debugElement.query(By.directive(StubVjsPlayerComponent))
|
||||
).toBeNull();
|
||||
expect(
|
||||
fixture.debugElement.query(
|
||||
By.directive(StubHtmlVideoPlayerComponent)
|
||||
)
|
||||
).not.toBeNull();
|
||||
});
|
||||
|
||||
it('mounts the engine saved in the settings store on first render', async () => {
|
||||
const settingsStore = TestBed.inject(SettingsStore);
|
||||
await settingsStore.loadSettings();
|
||||
await settingsStore.updateSettings({
|
||||
player: VideoPlayer.ArtPlayer,
|
||||
});
|
||||
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(
|
||||
fixture.debugElement.query(By.directive(StubArtPlayerComponent))
|
||||
).not.toBeNull();
|
||||
expect(
|
||||
fixture.debugElement.query(By.directive(StubVjsPlayerComponent))
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps an explicit playerOverride ahead of the saved player', async () => {
|
||||
fixture.componentRef.setInput(
|
||||
'playerOverride',
|
||||
VideoPlayer.ArtPlayer
|
||||
);
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
fixture.detectChanges();
|
||||
|
||||
await TestBed.inject(SettingsStore).updateSettings({
|
||||
player: VideoPlayer.Html5Player,
|
||||
});
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(
|
||||
fixture.debugElement.query(By.directive(StubArtPlayerComponent))
|
||||
).not.toBeNull();
|
||||
expect(
|
||||
fixture.debugElement.query(
|
||||
By.directive(StubHtmlVideoPlayerComponent)
|
||||
)
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it('suppresses browser diagnostics while embedded MPV is selected', () => {
|
||||
const requests: unknown[] = [];
|
||||
runtimeCapabilities.supportsManagedExternalPlayers = true;
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import {
|
||||
Component,
|
||||
OnDestroy,
|
||||
Signal,
|
||||
ViewEncapsulation,
|
||||
computed,
|
||||
effect,
|
||||
@@ -11,8 +10,6 @@ import {
|
||||
signal,
|
||||
untracked,
|
||||
} from '@angular/core';
|
||||
import { toSignal } from '@angular/core/rxjs-interop';
|
||||
import { StorageMap } from '@ngx-pwa/local-storage';
|
||||
import {
|
||||
type PlaybackDiagnostic,
|
||||
type PlaybackDiagnosticCode,
|
||||
@@ -22,11 +19,9 @@ import {
|
||||
import { PORTAL_EXTERNAL_PLAYBACK } from '@iptvnator/portal/shared/util';
|
||||
import { RuntimeCapabilitiesService, SettingsStore } from '@iptvnator/services';
|
||||
import {
|
||||
STORE_KEY,
|
||||
VideoPlayer,
|
||||
type Channel,
|
||||
type ResolvedPortalPlayback,
|
||||
type Settings,
|
||||
type VodSourceDescriptor,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import { ArtPlayerComponent } from '../art-player/art-player.component';
|
||||
@@ -91,7 +86,6 @@ function resolveWebPlayerSharedControls(): boolean {
|
||||
encapsulation: ViewEncapsulation.None,
|
||||
})
|
||||
export class WebPlayerViewComponent implements OnDestroy {
|
||||
private readonly storage = inject(StorageMap);
|
||||
private readonly runtime = inject(RuntimeCapabilitiesService);
|
||||
private readonly settingsStore = inject(SettingsStore);
|
||||
private readonly externalPlayback = inject(PORTAL_EXTERNAL_PLAYBACK, {
|
||||
@@ -130,9 +124,6 @@ export class WebPlayerViewComponent implements OnDestroy {
|
||||
readonly previousEpisodeRequested = output<void>();
|
||||
readonly nextEpisodeRequested = output<void>();
|
||||
|
||||
readonly settings = toSignal(
|
||||
this.storage.get(STORE_KEY.Settings)
|
||||
) as Signal<Settings | undefined>;
|
||||
readonly showCaptions = computed(
|
||||
() => this.settingsStore.showCaptions?.() ?? false
|
||||
);
|
||||
@@ -150,12 +141,15 @@ export class WebPlayerViewComponent implements OnDestroy {
|
||||
channel: Channel | undefined;
|
||||
vjsOptions: VideoPlayerOptions | undefined;
|
||||
|
||||
// Resolved from the live SettingsStore signal, not a mount-time storage
|
||||
// snapshot: a saved player change (settings page, command palette) must
|
||||
// reach an already-mounted player without a remount.
|
||||
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({
|
||||
@@ -208,7 +202,7 @@ export class WebPlayerViewComponent implements OnDestroy {
|
||||
isPlaybackExternallyTransferable(this.resolvedPlayback())
|
||||
);
|
||||
readonly recordingFolder = computed(
|
||||
() => this.settings()?.recordingFolder ?? ''
|
||||
() => this.settingsStore.recordingFolder?.() ?? ''
|
||||
);
|
||||
get supportsManagedExternalPlayers(): boolean {
|
||||
return this.runtime.supportsManagedExternalPlayers;
|
||||
|
||||
Reference in new issue
Block a user