From f44844fb6c48a71ba4d02c1c6a49226b0fb5f7a9 Mon Sep 17 00:00:00 2001 From: 4gray Date: Thu, 13 Aug 2026 18:13:11 +0200 Subject: [PATCH] fix(playback): retain mounted engine when saved player becomes MPV/VLC MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- docs/architecture/embedded-inline-playback.md | 5 ++- .../web-player-recovery-policy.ts | 16 ++++++++ .../web-player-view.component.spec.ts | 41 +++++++++++++++++++ .../web-player-view.component.ts | 17 ++++++-- 4 files changed, 74 insertions(+), 5 deletions(-) diff --git a/docs/architecture/embedded-inline-playback.md b/docs/architecture/embedded-inline-playback.md index 9446fc703..7d9445a9f 100644 --- a/docs/architecture/embedded-inline-playback.md +++ b/docs/architecture/embedded-inline-playback.md @@ -375,7 +375,10 @@ 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. +tracking. A saved switch to managed MPV/VLC is the one exception +(`resolveRenderableWebPlayer`): the viewport can neither render nor launch an +external player, so the mounted engine is retained and the external choice +applies when the host starts the next playback. Video.js, HTML5, and ArtPlayer report native media errors, hls.js errors, Video.js/VHS errors, Shaka errors, diff --git a/libs/ui/playback/src/lib/web-player-view/web-player-recovery-policy.ts b/libs/ui/playback/src/lib/web-player-view/web-player-recovery-policy.ts index b5aba38fa..9222127c0 100644 --- a/libs/ui/playback/src/lib/web-player-view/web-player-recovery-policy.ts +++ b/libs/ui/playback/src/lib/web-player-view/web-player-recovery-policy.ts @@ -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; +} 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 a229af024..f42224023 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 @@ -656,6 +656,47 @@ describe('WebPlayerViewComponent', () => { ).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', 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 9cd4e1ce1..7fac576c5 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 @@ -6,6 +6,7 @@ import { effect, inject, input, + linkedSignal, output, signal, untracked, @@ -55,6 +56,7 @@ import { resolveWebPlayerMediaTitle } from './web-player-playback-state'; import { createWebPlayerRecommendations, isPlaybackExternallyTransferable, + resolveRenderableWebPlayer, toInlinePlaybackPlayer, toVideoPlayer, } from './web-player-recovery-policy'; @@ -143,14 +145,21 @@ export class WebPlayerViewComponent implements OnDestroy { // 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. + // 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(() => { const temporary = this.recoverySession.temporaryPlayerOverride(); return temporary ? toVideoPlayer(temporary) - : (this.playerOverride() ?? - this.settingsStore.player?.() ?? - VideoPlayer.VideoJs); + : this.renderablePlayer(); }); private readonly applicationState = createWebPlayerApplicationState({ playback: this.playback,