mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
fix(playback): apply saved player changes to mounted web players (#1437)
* 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> * fix(playback): retain mounted engine when saved player becomes MPV/VLC With the saved player now live-tracked, a mid-session palette switch to managed MPV/VLC cleared the inline binding on hosts without a playerOverride and left a blank viewport — the web player view can neither render nor launch external players. resolveRenderableWebPlayer keeps the mounted engine in that case; the external choice applies when the host starts the next playback. Renderable players, including Embedded MPV, still apply live. Raised by Codex review (P2). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
61fca6f016
commit
3103eba083
6 files changed
+314
-29
No files matched your search
@@ -208,3 +208,19 @@ export function toInlinePlaybackPlayer(
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Managed MPV/VLC cannot render inside the web player viewport and the view
|
||||
* never launches them itself, so a mid-session saved-player switch to one
|
||||
* retains whatever engine is already mounted — the external choice applies
|
||||
* when the host starts the next playback. Every renderable player, including
|
||||
* Embedded MPV, is applied directly.
|
||||
*/
|
||||
export function resolveRenderableWebPlayer(
|
||||
configured: VideoPlayer,
|
||||
previous?: { value: VideoPlayer }
|
||||
): VideoPlayer {
|
||||
const external =
|
||||
configured === VideoPlayer.MPV || configured === VideoPlayer.VLC;
|
||||
return external && previous ? previous.value : configured;
|
||||
}
|
||||
@@ -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,124 @@ 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('retains the mounted engine when the saved player becomes MPV/VLC', async () => {
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(
|
||||
fixture.debugElement.query(By.directive(StubVjsPlayerComponent))
|
||||
).not.toBeNull();
|
||||
|
||||
// The view can neither render nor launch an external player, so
|
||||
// the switch must not blank the viewport; it applies when the
|
||||
// host starts the next playback.
|
||||
await TestBed.inject(SettingsStore).updateSettings({
|
||||
player: VideoPlayer.MPV,
|
||||
});
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(
|
||||
fixture.debugElement.query(By.directive(StubVjsPlayerComponent))
|
||||
).not.toBeNull();
|
||||
|
||||
// A later inline choice still applies live.
|
||||
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('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,18 +1,16 @@
|
||||
import {
|
||||
Component,
|
||||
OnDestroy,
|
||||
Signal,
|
||||
ViewEncapsulation,
|
||||
computed,
|
||||
effect,
|
||||
inject,
|
||||
input,
|
||||
linkedSignal,
|
||||
output,
|
||||
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 +20,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';
|
||||
@@ -60,6 +56,7 @@ import { resolveWebPlayerMediaTitle } from './web-player-playback-state';
|
||||
import {
|
||||
createWebPlayerRecommendations,
|
||||
isPlaybackExternallyTransferable,
|
||||
resolveRenderableWebPlayer,
|
||||
toInlinePlaybackPlayer,
|
||||
toVideoPlayer,
|
||||
} from './web-player-recovery-policy';
|
||||
@@ -91,7 +88,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 +126,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,13 +143,23 @@ 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. A saved managed
|
||||
// MPV/VLC retains the mounted engine (resolveRenderableWebPlayer),
|
||||
// because this view can neither render nor launch external players.
|
||||
private readonly renderablePlayer = linkedSignal({
|
||||
source: () =>
|
||||
this.playerOverride() ??
|
||||
this.settingsStore.player?.() ??
|
||||
VideoPlayer.VideoJs,
|
||||
computation: resolveRenderableWebPlayer,
|
||||
});
|
||||
readonly selectedPlayer = computed<VideoPlayer>(() => {
|
||||
const temporary = this.recoverySession.temporaryPlayerOverride();
|
||||
return temporary
|
||||
? toVideoPlayer(temporary)
|
||||
: (this.playerOverride() ??
|
||||
this.settings()?.player ??
|
||||
VideoPlayer.VideoJs);
|
||||
: this.renderablePlayer();
|
||||
});
|
||||
private readonly applicationState = createWebPlayerApplicationState({
|
||||
playback: this.playback,
|
||||
@@ -208,7 +211,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