diff --git a/.changes/playback-live-player-switch.md b/.changes/playback-live-player-switch.md new file mode 100644 index 000000000..9012e77f7 --- /dev/null +++ b/.changes/playback-live-player-switch.md @@ -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. diff --git a/apps/web-e2e/src/xtream.e2e.ts b/apps/web-e2e/src/xtream.e2e.ts index 3bcf5e899..163f5fc47 100644 --- a/apps/web-e2e/src/xtream.e2e.ts +++ b/apps/web-e2e/src/xtream.e2e.ts @@ -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 { + 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(); + (window as unknown as { __enginesSeen: Set }).__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 }) + .__enginesSeen, + ]) + ).toEqual(['app-html-video-player']); +}); + type XtreamLiveStream = { category_id: string; name: string; diff --git a/docs/architecture/embedded-inline-playback.md b/docs/architecture/embedded-inline-playback.md index e71534b46..9446fc703 100644 --- a/docs/architecture/embedded-inline-playback.md +++ b/docs/architecture/embedded-inline-playback.md @@ -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`. diff --git a/libs/ui/playback/src/lib/web-player-view/web-player-view.component.spec.ts b/libs/ui/playback/src/lib/web-player-view/web-player-view.component.spec.ts index 76575d748..a229af024 100644 --- a/libs/ui/playback/src/lib/web-player-view/web-player-view.component.spec.ts +++ b/libs/ui/playback/src/lib/web-player-view/web-player-view.component.spec.ts @@ -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(); - 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; 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..9cd4e1ce1 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 @@ -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(); readonly nextEpisodeRequested = output(); - readonly settings = toSignal( - this.storage.get(STORE_KEY.Settings) - ) as Signal; 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(() => { 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;