fix(playback): harden runtime guidance ownership

This commit is contained in:
4gray committed 2026-08-04 21:27:34 +02:00
1 parent f6328bc873
commit 68b7bdcd51
9 files changed
+422 -52

No files matched your search

@@ -732,7 +732,7 @@ describe('WebPlayerViewComponent recovery integration', () => {
expect(playerActionIds()).toEqual(['playback-recommendation-html5']);
});
it('prevents a second rendered action while a player switch is pending', async () => {
it('detaches the diagnostic while a player switch handoff is pending', async () => {
const fallbackRequests: unknown[] = [];
component.externalFallbackRequested.subscribe((request) =>
fallbackRequests.push(request)
@@ -742,13 +742,13 @@ describe('WebPlayerViewComponent recovery integration', () => {
fixture.detectChanges();
holdHeaderHandoff = true;
const htmlButton = requiredButton('playback-recommendation-html5');
const staleMpvButton = requiredButton('playback-fallback-mpv');
htmlButton.click();
fixture.detectChanges();
const mpvButton = requiredButton('playback-fallback-mpv');
expect(query('playback-diagnostic-banner')).not.toBeNull();
expect(mpvButton.disabled).toBe(true);
mpvButton.click();
expect(query('playback-diagnostic-banner')).toBeNull();
expect(component.visiblePlaybackDiagnostic()).toBeNull();
staleMpvButton.click();
expect(fallbackRequests).toEqual([]);
expect(headerResolvers).toHaveLength(1);
@@ -783,17 +783,16 @@ describe('WebPlayerViewComponent recovery integration', () => {
click('playback-recommendation-html5');
fixture.detectChanges();
expect(component.recoveryPending()).toBe(true);
expect(requiredButton('playback-fallback-mpv').disabled).toBe(true);
expect(component.visiblePlaybackDiagnostic()).toBeNull();
expect(query('playback-diagnostic-banner')).toBeNull();
complete();
await fixture.whenStable();
fixture.detectChanges();
expect(component.recoveryPending()).toBe(false);
expect(requiredButton('playback-fallback-mpv').disabled).toBe(
false
);
expect(component.playbackDiagnostic()).not.toBeNull();
expect(component.visiblePlaybackDiagnostic()).toBeNull();
expect(query('playback-diagnostic-banner')).toBeNull();
expect(component.channel).toBeUndefined();
}
);
@@ -827,8 +826,7 @@ describe('WebPlayerViewComponent recovery integration', () => {
fixture.detectChanges();
expect(component.recoveryPending()).toBe(true);
expect(requiredButton('playback-fallback-mpv').disabled).toBe(true);
expect(component.playbackDiagnostic()).not.toBeNull();
expect(component.visiblePlaybackDiagnostic()).toBeNull();
expect(component.channel).toBeUndefined();
headerResolvers[1](false);
@@ -840,7 +838,6 @@ describe('WebPlayerViewComponent recovery integration', () => {
await render();
vjs().playbackIssue.emit(mediaIssue('videojs'));
fixture.detectChanges();
const diagnostic = component.playbackDiagnostic();
const failures: PlaybackDiagnosticCode[] = [];
component.playbackFailed.subscribe((code) => failures.push(code));
holdHeaderHandoff = true;
@@ -851,6 +848,7 @@ describe('WebPlayerViewComponent recovery integration', () => {
expect(sourceABinding).not.toBeNull();
expect(headerResolvers).toHaveLength(1);
expect(component.channel).toBeUndefined();
expect(component.visiblePlaybackDiagnostic()).toBeNull();
setPlayback({ streamUrl: 'https://example.com/source-b.m3u8' });
const pendingState = {
@@ -873,7 +871,7 @@ describe('WebPlayerViewComponent recovery integration', () => {
vjsOptions: component.vjsOptions,
failures,
}).toEqual(pendingState);
expect(component.playbackDiagnostic()).toBe(diagnostic);
expect(component.visiblePlaybackDiagnostic()).toBeNull();
expect(streamHeaders.apply).toHaveBeenCalledTimes(2);
fixture.detectChanges();
@@ -881,7 +879,6 @@ describe('WebPlayerViewComponent recovery integration', () => {
expect(component.activeBinding()).not.toBe(sourceABinding);
headerResolvers[1](true);
await fixture.whenStable();
fixture.detectChanges();
expect(component.channel?.url).toBe(
'https://example.com/source-b.m3u8'
@@ -1060,7 +1057,7 @@ describe('WebPlayerViewComponent recovery integration', () => {
}
);
it('clears a diagnostic only after the current header handoff succeeds', async () => {
it('keeps a replaced-source diagnostic detached across every handoff outcome', async () => {
await render();
vjs().playbackIssue.emit(mediaIssue('videojs'));
fixture.detectChanges();
@@ -1068,27 +1065,28 @@ describe('WebPlayerViewComponent recovery integration', () => {
setPlayback({ streamUrl: 'https://example.com/alternate.m3u8' });
fixture.detectChanges();
expect(query('playback-diagnostic-banner')).toBeNull();
setPlayback({ streamUrl: 'https://example.com/current.m3u8' });
fixture.detectChanges();
expect(headerResolvers).toHaveLength(2);
expect(query('playback-diagnostic-banner')).toBeNull();
headerResolvers[0](true);
await fixture.whenStable();
fixture.detectChanges();
expect(query('playback-diagnostic-banner')).not.toBeNull();
expect(query('playback-diagnostic-banner')).toBeNull();
headerResolvers[1](false);
await fixture.whenStable();
fixture.detectChanges();
expect(query('playback-diagnostic-banner')).not.toBeNull();
expect(query('playback-diagnostic-banner')).toBeNull();
setPlayback({ streamUrl: 'https://example.com/final.m3u8' });
fixture.detectChanges();
headerResolvers[2](true);
await fixture.whenStable();
fixture.detectChanges();
expect(query('playback-diagnostic-banner')).toBeNull();
expect(vjs().options()).toEqual(
expect(component.visiblePlaybackDiagnostic()).toBeNull();
expect(component.vjsOptions).toEqual(
expect.objectContaining({
sources: [
expect.objectContaining({
@@ -162,6 +162,21 @@ export class WebPlayerViewComponent implements OnDestroy {
readonly resolvedIsLive = this.applicationState.isLive;
readonly playbackSourceRevisionToken = this.applicationState.sourceRevision;
readonly playbackApplicationToken = this.applicationState.token;
// Diagnostic visibility follows raw intent so an old action disappears
// before the application effect enters an asynchronous Electron handoff.
// Keep this token opaque: it must never retain playback payload fields.
private readonly playbackDiagnosticIntentToken =
computed<WebPlayerApplicationToken>(() => {
if (this.playback() === null) {
void this.streamUrl();
void this.startTime();
}
void this.selectedPlayer();
void this.reloadToken();
return Symbol();
});
private readonly playbackDiagnosticOwnerToken =
signal<WebPlayerApplicationToken | null>(null);
readonly effectiveStartTime = computed(() =>
this.recoverySession.resumeStartTime(
this.startTime(),
@@ -169,7 +184,9 @@ export class WebPlayerViewComponent implements OnDestroy {
)
);
readonly visiblePlaybackDiagnostic = computed(() =>
this.selectedPlayer() === VideoPlayer.EmbeddedMpv
this.selectedPlayer() === VideoPlayer.EmbeddedMpv ||
this.playbackDiagnosticOwnerToken() !==
this.playbackDiagnosticIntentToken()
? null
: this.playbackDiagnostic()
);
@@ -229,6 +246,7 @@ export class WebPlayerViewComponent implements OnDestroy {
constructor() {
effect(() => {
void this.playbackDiagnosticIntentToken();
this.syncRecoverySession();
const sourceRevision = this.playbackSourceRevisionToken();
untracked(() =>
@@ -298,6 +316,9 @@ export class WebPlayerViewComponent implements OnDestroy {
if (!this.recoverySession.recordFailure(binding)) {
return;
}
this.playbackDiagnosticOwnerToken.set(
this.playbackDiagnosticIntentToken()
);
this.playbackDiagnostic.set(issue);
this.playbackFailed.emit(issue.code);
}