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

@@ -5,6 +5,8 @@ import {
captureStalkerEpisodePlaybackSessionIdentity,
createStalkerEpisodePlaybackSessionKey,
resolveStalkerEpisodeStateByIdentity,
resolveStalkerEpisodeStateByStructuralIdentity,
toStalkerEpisodePlaybackStructuralIdentity,
} from './stalker-episode-playback-session-key';
const episodeState = (
@@ -103,6 +105,64 @@ describe('createStalkerEpisodePlaybackSessionKey', () => {
});
});
it('freezes only credential-free structural fields for a mounted session', () => {
const originalCmd =
'https://user:password@stream.example/episode.mpg?access_token=secret';
const identity = captureStalkerEpisodePlaybackSessionIdentity({
sourceId: 'playlist',
parentSeriesId: 'series',
seriesMode: STALKER_SERIES_DOWNLOAD_MODES.RegularSeries,
episodeState: episodeState({ originalCmd }),
});
if (!identity) throw new Error('Expected a captured request identity');
const retained = toStalkerEpisodePlaybackStructuralIdentity(identity);
expect(Object.isFrozen(retained)).toBe(true);
expect(retained).toEqual({
sourceId: identity.sourceId,
parentSeriesId: identity.parentSeriesId,
seriesMode: identity.seriesMode,
seasonKey: identity.seasonKey,
seasonNumber: identity.seasonNumber,
episodeNumber: identity.episodeNumber,
sessionKey: identity.sessionKey,
});
expect(JSON.stringify(retained)).not.toContain(originalCmd);
expect(retained).not.toHaveProperty('originalEpisodeIdentity');
});
it('fails closed when structural coordinates match multiple episodes', () => {
const state = episodeState({ originalCmd: 'command-a' });
const requestIdentity = captureStalkerEpisodePlaybackSessionIdentity({
sourceId: 'playlist',
parentSeriesId: 'series',
seriesMode: STALKER_SERIES_DOWNLOAD_MODES.RegularSeries,
episodeState: state,
});
if (!requestIdentity) {
throw new Error('Expected a captured request identity');
}
const identity =
toStalkerEpisodePlaybackStructuralIdentity(requestIdentity);
expect(
resolveStalkerEpisodeStateByStructuralIdentity({
episodesBySeason: {
[state.seasonKey]: [
state.episode,
{
...state.episode,
id: 'another-tracking-id',
originalCmd: 'command-b',
},
],
},
identity,
})
).toBeNull();
});
it('keeps lazy provider ids transient while rejecting an id refresh for a pending request', () => {
const stateA = episodeState({ originalId: 'provider|episode:token-a' });
const stateB = episodeState({ originalId: 'provider|episode:token-b' });
@@ -28,6 +28,16 @@ export interface StalkerEpisodePlaybackSessionIdentity {
readonly sessionKey: string;
}
export interface StalkerEpisodePlaybackStructuralIdentity {
readonly sourceId: string;
readonly parentSeriesId: string;
readonly seriesMode: StalkerSeriesDownloadMode;
readonly seasonKey: string;
readonly seasonNumber: number;
readonly episodeNumber: number;
readonly sessionKey: string;
}
interface StalkerEpisodeStateLookupOptions {
readonly episodesBySeason: Record<string, readonly XtreamSerieEpisode[]>;
}
@@ -104,6 +114,29 @@ export function createStalkerEpisodePlaybackSessionKey(
);
}
export function toStalkerEpisodePlaybackStructuralIdentity(
identity: StalkerEpisodePlaybackSessionIdentity
): StalkerEpisodePlaybackStructuralIdentity {
const {
sourceId,
parentSeriesId,
seriesMode,
seasonKey,
seasonNumber,
episodeNumber,
sessionKey,
} = identity;
return Object.freeze({
sourceId,
parentSeriesId,
seriesMode,
seasonKey,
seasonNumber,
episodeNumber,
sessionKey,
});
}
export function resolveSelectedStalkerEpisodeState({
episodesBySeason,
episode,
@@ -155,13 +188,30 @@ export function resolveStalkerEpisodeStateByIdentity({
);
}
export function resolveStalkerEpisodeStateByStructuralIdentity({
episodesBySeason,
identity,
}: StalkerEpisodeStateLookupOptions & {
readonly identity: StalkerEpisodePlaybackStructuralIdentity;
}): SeriesPlaybackEpisodeState<XtreamSerieEpisode> | null {
return findStalkerEpisodeState(
{ [identity.seasonKey]: episodesBySeason[identity.seasonKey] ?? [] },
(_candidate, state) =>
state.seasonNumber === identity.seasonNumber &&
state.episodeNumber === identity.episodeNumber,
true
);
}
function findStalkerEpisodeState(
episodesBySeason: Record<string, readonly XtreamSerieEpisode[]>,
matches: (
episode: XtreamSerieEpisode,
state: SeriesPlaybackEpisodeState<XtreamSerieEpisode>
) => boolean
) => boolean,
requireUnique = false
): SeriesPlaybackEpisodeState<XtreamSerieEpisode> | null {
let match: SeriesPlaybackEpisodeState<XtreamSerieEpisode> | null = null;
for (const [seasonKey, episodes] of Object.entries(episodesBySeason)) {
for (
let episodeIndex = 0;
@@ -177,8 +227,11 @@ function findStalkerEpisodeState(
previous: episodes[episodeIndex - 1] ?? null,
next: episodes[episodeIndex + 1] ?? null,
};
if (matches(episode, state)) return state;
if (!matches(episode, state)) continue;
if (!requireUnique) return state;
if (match) return null;
match = state;
}
}
return null;
return match;
}
@@ -88,7 +88,10 @@ import {
captureStalkerEpisodePlaybackSessionIdentity,
resolveSelectedStalkerEpisodeState,
resolveStalkerEpisodeStateByIdentity,
resolveStalkerEpisodeStateByStructuralIdentity,
toStalkerEpisodePlaybackStructuralIdentity,
type StalkerEpisodePlaybackSessionIdentity,
type StalkerEpisodePlaybackStructuralIdentity,
} from './stalker-episode-playback-session-key';
interface SeriesPositionContext {
@@ -186,8 +189,8 @@ export class StalkerSeriesViewComponent implements OnDestroy {
private seriesPositionsLoadGeneration = 0;
private seriesPlaybackRequestGeneration = 0;
private currentSeriesPlaybackOwnerKey = '';
private readonly inlinePlaybackEpisodeState =
signal<SeriesPlaybackEpisodeState<XtreamSerieEpisode> | null>(null);
private readonly inlinePlaybackEpisodeIdentity =
signal<StalkerEpisodePlaybackStructuralIdentity | null>(null);
private lastSaveTime = 0;
private unsubscribePositionUpdates: (() => void) | null = null;
readonly openingEpisodeId = signal<number | null>(null);
@@ -578,9 +581,24 @@ export class StalkerSeriesViewComponent implements OnDestroy {
vodSeriesSeasons: this.vodSeriesSeasons(),
});
});
readonly inlineEpisodeState = computed(() =>
this.inlinePlaybackEpisodeState()
);
readonly inlineEpisodeState = computed(() => {
const identity = this.inlinePlaybackEpisodeIdentity();
const sourceId = this.stalkerStore.currentPlaylist()?._id?.trim() ?? '';
const parentSeriesId = normalizeStalkerEntityId(this.displayItem()?.id);
if (
!identity ||
this.playbackSessionKey() !== identity.sessionKey ||
sourceId !== identity.sourceId ||
parentSeriesId !== identity.parentSeriesId ||
this.seriesMode() !== identity.seriesMode
) {
return null;
}
return resolveStalkerEpisodeStateByStructuralIdentity({
episodesBySeason: this.mappedSeasons(),
identity,
});
});
readonly playbackSessionKey = signal('');
readonly inlineEpisodeMetadata = computed(() =>
getSeriesEpisodeMetadata(this.inlineEpisodeState())
@@ -755,7 +773,6 @@ export class StalkerSeriesViewComponent implements OnDestroy {
* Handles episode click from the container
*/
onEpisodeClicked(episode: XtreamSerieEpisode) {
const mappedEpisode = episode as StalkerMappedEpisode;
const item = this.displayItem();
const episodeState = resolveSelectedStalkerEpisodeState({
episodesBySeason: this.mappedSeasons(),
@@ -764,14 +781,15 @@ export class StalkerSeriesViewComponent implements OnDestroy {
if (!item || !episodeState) return;
this.syncSeriesPlaybackOwner(this.seriesPlaybackOwnerKey());
const mappedEpisode = episodeState.episode as StalkerMappedEpisode;
const isLazyVod = mappedEpisode.custom_sid === 'vod-series';
const command = isLazyVod
? `/media/file_${mappedEpisode.originalId ?? ''}.mpg`
: mappedEpisode.originalCmd;
const title = isLazyVod
? `${item.info.name} - ${episode.title || `Episode ${episodeState.episodeNumber}`}`
? `${item.info.name} - ${mappedEpisode.title || `Episode ${episodeState.episodeNumber}`}`
: item.info.name;
const trackingId = Number(episode.id);
const trackingId = Number(mappedEpisode.id);
const startTime =
this.episodePlaybackPositions().get(trackingId)?.positionSeconds;
@@ -839,7 +857,7 @@ export class StalkerSeriesViewComponent implements OnDestroy {
closeInlinePlayer(): void {
this.seriesPlaybackRequestGeneration += 1;
this.inlinePlayback.set(null);
this.inlinePlaybackEpisodeState.set(null);
this.inlinePlaybackEpisodeIdentity.set(null);
this.playbackSessionKey.set('');
this.lastSaveTime = 0;
}
@@ -963,11 +981,7 @@ export class StalkerSeriesViewComponent implements OnDestroy {
this.lastSaveTime = 0;
if (request.usesEmbeddedPlayer && request.identity) {
this.setInlinePlayback(
resolvedPlayback,
request.identity.sessionKey,
episodeState
);
this.setInlinePlayback(resolvedPlayback, request.identity);
return;
}
@@ -1017,11 +1031,12 @@ export class StalkerSeriesViewComponent implements OnDestroy {
private setInlinePlayback(
playback: ResolvedPortalPlayback,
sessionKey: string,
episodeState: SeriesPlaybackEpisodeState<XtreamSerieEpisode> | null
identity: StalkerEpisodePlaybackSessionIdentity
): void {
this.playbackSessionKey.set(sessionKey);
this.inlinePlaybackEpisodeState.set(episodeState);
this.playbackSessionKey.set(identity.sessionKey);
this.inlinePlaybackEpisodeIdentity.set(
toStalkerEpisodePlaybackStructuralIdentity(identity)
);
this.inlinePlayback.set(playback);
}
@@ -35,6 +35,7 @@ describe('StalkerSeriesViewComponent playback session key', () => {
const serialSeasons = signal<unknown[]>([]);
const vodSeasons = signal<unknown[]>([]);
const resolveVodPlayback = jest.fn();
const tmdbGetSeason = jest.fn();
const currentPlaylist = signal({
_id: 'stalker|playlist',
title: 'Portal',
@@ -78,6 +79,7 @@ describe('StalkerSeriesViewComponent playback session key', () => {
},
})
);
tmdbGetSeason.mockReset().mockResolvedValue(null);
await TestBed.configureTestingModule({
imports: [StalkerSeriesViewComponent],
@@ -124,7 +126,7 @@ describe('StalkerSeriesViewComponent playback session key', () => {
provide: TmdbEnrichmentService,
useValue: {
isEnabled: () => false,
getSeason: jest.fn(),
getSeason: tmdbGetSeason,
getSeasonEpisodes: jest.fn(),
},
},
@@ -471,6 +473,192 @@ describe('StalkerSeriesViewComponent playback session key', () => {
expect(fixture.componentInstance.playbackSessionKey()).toBe(key);
});
it('re-resolves mounted episode metadata and neighbors after a same-owner season refresh', async () => {
serialSeasons.set([
{
id: 'regular-season',
name: 'Season 1',
cmd: 'ffrt4://regular|full-command',
series: [1, 2, 3],
},
]);
fixture.detectChanges();
await fixture.whenStable();
const initialEpisodes = Object.values(
fixture.componentInstance.mappedSeasons()
)[0] as StalkerMappedEpisode[];
fixture.componentInstance.onEpisodeClicked(initialEpisodes[1]);
await fixture.whenStable();
const key = fixture.componentInstance.playbackSessionKey();
serialSeasons.set([
{
id: 'refreshed-season',
name: 'Season 1',
cmd: 'ffrt4://refreshed|full-command',
series: [1, 2, 3],
},
]);
fixture.detectChanges();
await fixture.whenStable();
const refreshedEpisodes = Object.values(
fixture.componentInstance.mappedSeasons()
)[0] as StalkerMappedEpisode[];
const state = fixture.componentInstance.inlineEpisodeState();
expect(fixture.componentInstance.playbackSessionKey()).toBe(key);
expect(state?.episode).toBe(refreshedEpisodes[1]);
expect(state?.previous).toBe(refreshedEpisodes[0]);
expect(state?.next).toBe(refreshedEpisodes[2]);
fixture.componentInstance.handleInlinePlaybackEnded();
await fixture.whenStable();
expect(resolveVodPlayback).toHaveBeenCalledTimes(2);
expect(resolveVodPlayback).toHaveBeenLastCalledWith(
'ffrt4://refreshed|full-command',
'Series',
'poster.jpg',
3,
Number(refreshedEpisodes[2].id),
undefined
);
});
it('re-resolves mounted episode metadata after same-owner TMDB enrichment', async () => {
await playFirstEpisode();
const key = fixture.componentInstance.playbackSessionKey();
tmdbGetSeason.mockResolvedValue({
overview: 'Season overview',
episodes: [{ episode_number: 1, name: 'TMDB Pilot' }],
});
const item = selectedItem();
if (!item) throw new Error('Expected the selected series');
selectedItem.set({
...item,
info: { ...item.info, tmdb_id: 314 },
});
fixture.componentInstance.onSeasonSelected('1');
fixture.detectChanges();
await fixture.whenStable();
fixture.detectChanges();
const currentEpisode = Object.values(
fixture.componentInstance.mappedSeasons()
)[0][0];
expect(tmdbGetSeason).toHaveBeenCalledWith(314, 1);
expect(fixture.componentInstance.playbackSessionKey()).toBe(key);
expect(fixture.componentInstance.inlineEpisodeState()?.episode).toBe(
currentEpisode
);
expect(fixture.componentInstance.inlineEpisodeMetadata()?.title).toBe(
'TMDB Pilot'
);
});
it('uses the current mapped episode when a stale lazy-series event resolves', async () => {
selectedContentType.set('vod');
selectedItem.set({
id: 'series|parent',
is_series: true,
info: {
name: 'Lazy series',
movie_image: 'poster.jpg',
tmdb_id: 2718,
},
});
fixture.detectChanges();
await fixture.whenStable();
fixture.componentInstance.vodSeriesSeasons.set([
{
id: 'lazy-season',
video_id: 'series|parent',
season_number: '2',
name: 'Season 2',
episodes: [
{
id: 'provider-episode',
series_number: 3,
name: 'Episode 3',
},
],
isLoading: false,
isExpanded: false,
},
]);
fixture.detectChanges();
await fixture.whenStable();
const staleEpisode = Object.values(
fixture.componentInstance.mappedSeasons()
)[0][0] as StalkerMappedEpisode;
tmdbGetSeason.mockResolvedValue({
episodes: [{ episode_number: 3, name: 'Current TMDB title' }],
});
fixture.componentInstance.onSeasonSelected('2');
fixture.detectChanges();
await fixture.whenStable();
fixture.detectChanges();
expect(
Object.values(fixture.componentInstance.mappedSeasons())[0][0].title
).toBe('Current TMDB title');
fixture.componentInstance.onEpisodeClicked(staleEpisode);
await fixture.whenStable();
expect(resolveVodPlayback).toHaveBeenLastCalledWith(
'/media/file_provider-episode.mpg',
'Lazy series - Current TMDB title',
'poster.jpg',
3,
expect.any(Number),
undefined
);
});
it('fails mounted episode commands closed when a same-owner refresh removes the episode', async () => {
serialSeasons.set([
{
id: 'regular-season',
name: 'Season 1',
cmd: 'ffrt4://regular|full-command',
series: [1, 2, 3],
},
]);
fixture.detectChanges();
await fixture.whenStable();
const episodes = Object.values(
fixture.componentInstance.mappedSeasons()
)[0] as StalkerMappedEpisode[];
fixture.componentInstance.onEpisodeClicked(episodes[1]);
await fixture.whenStable();
const key = fixture.componentInstance.playbackSessionKey();
serialSeasons.set([
{
id: 'refreshed-season',
name: 'Season 1',
cmd: 'ffrt4://refreshed|full-command',
series: [1],
},
]);
fixture.detectChanges();
await fixture.whenStable();
expect(fixture.componentInstance.playbackSessionKey()).toBe(key);
expect(fixture.componentInstance.inlineEpisodeState()).toBeNull();
expect(fixture.componentInstance.inlineEpisodeMetadata()).toBeNull();
expect(fixture.componentInstance.inlineSeriesNavigation()).toBeNull();
fixture.componentInstance.playPreviousEpisode();
fixture.componentInstance.playNextEpisode();
fixture.componentInstance.handleInlinePlaybackEnded();
await fixture.whenStable();
expect(resolveVodPlayback).toHaveBeenCalledTimes(1);
});
it('rejects a completion after its playlist, parent, mode, and episode owner change', async () => {
fixture.detectChanges();
await fixture.whenStable();
@@ -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);
}