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

@@ -0,0 +1,6 @@
---
type: fix
area: playback
---
Playback recovery now removes old error actions as soon as a new source or player starts, even while Electron prepares stream headers. Stalker series playing inline now picks up refreshed episode names and navigation, and safely disables episode commands if the playing episode disappears.
+24 -7
View File
@@ -55,6 +55,15 @@ short-lived exact pending-request snapshot/guard that locates the selected
episode and rejects stale or out-of-order completion. This keeps token refreshes
in one logical session while ensuring colliding tracking hashes cannot select a
sibling episode.
After a Stalker episode mounts, the host retains only a frozen structural
identity (source, normalized parent, series mode, exact season key, season and
episode coordinates, and the credential-free session key). Each metadata,
navigation, or autoplay read resolves those coordinates against the current
`mappedSeasons()` value. Same-owner provider or TMDB refreshes therefore update
the mounted episode without changing its session key; if the episode disappears,
or if multiple episodes claim the same structural coordinates, the mounted
commands fail closed. The command/ID-bearing identity never outlives the pending
playback request.
Shared wrappers (`VodDetailsComponent` and `PortalInlinePlayerComponent`) pass
the key unchanged to `WebPlayerViewComponent`.
@@ -629,13 +638,21 @@ nullable binding, both opaque tokens, and its live/VOD flag. A time update can
change the resume position only while that exact capture still owns the current
application, so a replaced source cannot repopulate cleared handoff state.
Selecting a built-in recommendation records the target, clears the diagnostic,
and installs a temporary local override ahead of the host override and saved
player setting. The new engine receives the latest finite VOD position as a
best-effort resume point; live playback starts at the live edge. Retry reloads
the active target without clearing attempts. Selecting MPV or VLC records the
external target before emitting the existing fallback request. The system does
not infer whether the external process ultimately played the stream.
A separate fieldless intent token invalidates on each new source, target, or
reload intent. It clears the prior diagnostic from visible and actionable state
before any asynchronous Electron header handoff completes, without retaining
source material. False or rejected current handoffs leave it detached, and a
stale success, false result, or rejection cannot restore the old diagnostic or
mutate the newer application state.
Selecting a built-in recommendation records the target, immediately detaches
the diagnostic, and installs a temporary local override ahead of the host
override and saved player setting. The new engine receives the latest finite
VOD position as a best-effort resume point; live playback starts at the live
edge. Retry reloads the active target without clearing attempts. Selecting MPV
or VLC records the external target before emitting the existing fallback
request. The system does not infer whether the external process ultimately
played the stream.
No recommendation mutates `Settings.player` or another persisted setting.
Recovery recommendations never auto-switch a player or source and do not
+13 -1
View File
@@ -1162,7 +1162,19 @@ Series inline playback behavior is shared across all three modes:
blocks the action rather than binding regular, embedded, or lazy VOD content
to another mode; an exact canonical episode id still wins.
- `StalkerSeriesViewComponent` maps every mode into `mappedSeasons()` and derives the currently playing episode from `inlinePlayback.contentInfo.contentXtreamId`.
- `StalkerSeriesViewComponent` maps every mode into `mappedSeasons()` and uses
two episode identities. A pending playback request keeps an exact,
request-local provider command or episode ID so command rotation and hash
collisions reject stale completion. Once mounted, the component freezes only
the credential-free structural identity and session key (source, normalized
parent, mode, season key, season number, and episode number).
- The mounted structural identity is re-resolved against the current
`mappedSeasons()` for metadata, Previous/Next, and autoplay. Same-owner
provider and TMDB refreshes therefore expose current episode objects and
commands without remounting the player; if the episode is missing or its
structural coordinates are ambiguous, those surfaces and commands fail
closed. Provider commands and IDs are never retained in mounted session
state.
- The inline player header shows the current episode metadata below the title, for example `S01E03 - Episode title`.
- Embedded players receive previous/next episode state for the current season only.
- Inline series autoplay is enabled by default. On player EOF (`ended`), Stalker starts the next episode only when it already exists in the current season's mapped episode list.
@@ -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);
}