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>
This commit is contained in:
4grayandClaude Fable 5 committed 2026-08-13 18:13:11 +02:00
1 parent 6eab0938ea
commit f44844fb6c
4 files changed
+74 -5

No files matched your search

@@ -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,
@@ -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;
}
@@ -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',
@@ -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<VideoPlayer>(() => {
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,