fix(playback): preserve headers in diagnostics wrapper

This commit is contained in:
4gray committed 2026-05-18 23:56:16 +02:00
1 parent d7f914bc57
commit 2ae62f5a12
4 files changed
+150 -14

No files matched your search

@@ -37,6 +37,7 @@ type VideoPlayerSource = {
type VideoPlayerOptions = Record<string, unknown> & {
autoplay?: boolean;
isLive?: boolean;
reloadToken?: number;
sources?: VideoPlayerSource[];
};
@@ -235,7 +236,14 @@ export class VjsPlayerComponent implements OnInit, OnChanges, OnDestroy {
const previousSource =
changes['options'].previousValue.sources?.[0];
const newSource = changes['options'].currentValue.sources?.[0];
if (this.hasSourceChanged(previousSource, newSource)) {
if (
this.hasPlaybackInputChanged(
changes['options'].previousValue,
changes['options'].currentValue,
previousSource,
newSource
)
) {
this.playbackIssue.emit(null);
this.destroyMpegTs();
if (!newSource) {
@@ -274,11 +282,14 @@ export class VjsPlayerComponent implements OnInit, OnChanges, OnDestroy {
return (extension === 'ts' || !extension) && mpegts.isSupported();
}
private hasSourceChanged(
private hasPlaybackInputChanged(
previousOptions: VideoPlayerOptions,
newOptions: VideoPlayerOptions,
previousSource: VideoPlayerSource | undefined,
newSource: VideoPlayerSource | undefined
): boolean {
return (
previousOptions.reloadToken !== newOptions.reloadToken ||
previousSource?.src !== newSource?.src ||
previousSource?.type !== newSource?.type
);
@@ -53,7 +53,7 @@
</div>
<h2 class="web-player-diagnostic__headline">
{{ getDiagnosticHeadlineKey() | translate }}
{{ diagnosticHeadlineKey() | translate }}
</h2>
<div class="web-player-diagnostic__description">
@@ -234,6 +234,69 @@ describe('WebPlayerViewComponent', () => {
);
});
it('preserves playback HTTP metadata for channel-based players', () => {
const streamUrl = 'https://example.com/live/channel.m3u8';
fixture.componentRef.setInput('playerOverride', VideoPlayer.Html5Player);
fixture.componentRef.setInput('playback', {
streamUrl,
title: 'Header Locked Channel',
userAgent: 'ProviderAgent/1.0',
referer: 'https://provider.example/ref',
origin: 'https://provider.example',
headers: {
'User-Agent': 'IgnoredFallbackAgent/1.0',
Referer: 'https://ignored.example/ref',
Origin: 'https://ignored.example',
},
});
fixture.detectChanges();
const player = fixture.debugElement.query(
By.directive(StubHtmlVideoPlayerComponent)
).componentInstance as StubHtmlVideoPlayerComponent;
expect(player.channel()).toEqual(
expect.objectContaining({
url: streamUrl,
name: 'Header Locked Channel',
http: {
'user-agent': 'ProviderAgent/1.0',
referrer: 'https://provider.example/ref',
origin: 'https://provider.example',
},
})
);
});
it('falls back to playback headers when explicit HTTP metadata is absent', () => {
const streamUrl = 'https://example.com/live/channel.m3u8';
fixture.componentRef.setInput('playerOverride', VideoPlayer.Html5Player);
fixture.componentRef.setInput('playback', {
streamUrl,
title: 'Header Fallback Channel',
headers: {
'user-agent': 'HeaderAgent/1.0',
referer: 'https://headers.example/ref',
origin: 'https://headers.example',
},
});
fixture.detectChanges();
const player = fixture.debugElement.query(
By.directive(StubHtmlVideoPlayerComponent)
).componentInstance as StubHtmlVideoPlayerComponent;
expect(player.channel()).toEqual(
expect.objectContaining({
http: {
'user-agent': 'HeaderAgent/1.0',
referrer: 'https://headers.example/ref',
origin: 'https://headers.example',
},
})
);
});
it('uses browser access diagnostic translation keys', () => {
const issue = createBrowserAccessDiagnostic();
@@ -311,6 +374,13 @@ describe('WebPlayerViewComponent', () => {
it('clears playback diagnostics when retrying inline playback', () => {
fixture.detectChanges();
const player = fixture.debugElement.query(
By.directive(StubVjsPlayerComponent)
).componentInstance as StubVjsPlayerComponent;
expect(player.options()).toEqual(
expect.objectContaining({ reloadToken: 0 })
);
component.handlePlaybackIssue(createUnsupportedCodecDiagnostic());
fixture.detectChanges();
@@ -327,6 +397,9 @@ describe('WebPlayerViewComponent', () => {
fixture.detectChanges();
expect(component.playbackDiagnostic()).toBeNull();
expect(player.options()).toEqual(
expect.objectContaining({ reloadToken: 1 })
);
expect(
fixture.debugElement.query(
By.css('[data-test-id="playback-diagnostic-banner"]')
@@ -8,6 +8,7 @@ import {
input,
output,
signal,
untracked,
} from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { ClipboardModule } from '@angular/cdk/clipboard';
@@ -17,6 +18,7 @@ import { MatTooltipModule } from '@angular/material/tooltip';
import { StorageMap } from '@ngx-pwa/local-storage';
import { TranslatePipe } from '@ngx-translate/core';
import {
Channel,
ResolvedPortalPlayback,
Settings,
STORE_KEY,
@@ -80,19 +82,26 @@ export class WebPlayerViewComponent {
this.storage.get(STORE_KEY.Settings)
) as Signal<Settings>;
channel!: { url: string };
channel!: Channel;
player!: VideoPlayer;
vjsOptions!: {
isLive: boolean;
reloadToken: number;
sources: { src: string; type: string }[];
};
readonly isDesktop = signal(this.detectDesktop());
readonly reloadToken = signal(0);
readonly playbackDiagnostic = signal<PlaybackDiagnostic | null>(null);
readonly canShowExternalFallbackActions = computed(
() =>
this.isDesktop() &&
!!this.playbackDiagnostic()?.externalFallbackRecommended
);
readonly diagnosticHeadlineKey = computed(() =>
this.canShowExternalFallbackActions()
? 'PLAYBACK_DIAGNOSTICS.NATIVE_FALLBACK_TITLE'
: 'PLAYBACK_DIAGNOSTICS.INLINE_FAILURE_TITLE'
);
readonly resolvedPlayback = computed<ResolvedPortalPlayback>(() => {
const playback = this.playback();
@@ -119,7 +128,7 @@ export class WebPlayerViewComponent {
const playback = this.resolvedPlayback();
this.playbackDiagnostic.set(null);
this.setChannel(playback.streamUrl);
this.setChannel(playback);
this.setVjsOptions(
playback.streamUrl,
this.isLivePlayback(playback)
@@ -138,13 +147,47 @@ export class WebPlayerViewComponent {
this.vjsOptions = {
isLive,
reloadToken: untracked(() => this.reloadToken()),
sources: [{ src: streamUrl, type: mimeType }],
};
}
setChannel(streamUrl: string) {
setChannel(playbackOrUrl: ResolvedPortalPlayback | string) {
const playback =
typeof playbackOrUrl === 'string'
? {
streamUrl: playbackOrUrl,
title: playbackOrUrl,
}
: playbackOrUrl;
this.channel = {
url: streamUrl,
id: playback.streamUrl,
url: playback.streamUrl,
name: playback.title || playback.streamUrl,
group: { title: '' },
tvg: {
id: '',
name: playback.title || playback.streamUrl,
url: '',
logo: playback.thumbnail ?? '',
rec: '',
},
http: {
referrer:
playback.referer ??
this.getHeaderValue(playback.headers, 'Referer') ??
'',
'user-agent':
playback.userAgent ??
this.getHeaderValue(playback.headers, 'User-Agent') ??
'',
origin:
playback.origin ??
this.getHeaderValue(playback.headers, 'Origin') ??
'',
},
radio: 'false',
};
}
@@ -169,7 +212,8 @@ export class WebPlayerViewComponent {
const playback = this.resolvedPlayback();
this.playbackDiagnostic.set(null);
this.setChannel(playback.streamUrl);
this.reloadToken.update((value) => value + 1);
this.setChannel(playback);
this.setVjsOptions(playback.streamUrl, this.isLivePlayback(playback));
}
@@ -181,12 +225,6 @@ export class WebPlayerViewComponent {
return `${this.getDiagnosticTranslationBase(issue)}.DESCRIPTION`;
}
getDiagnosticHeadlineKey(): string {
return this.canShowExternalFallbackActions()
? 'PLAYBACK_DIAGNOSTICS.NATIVE_FALLBACK_TITLE'
: 'PLAYBACK_DIAGNOSTICS.INLINE_FAILURE_TITLE';
}
getDiagnosticMeta(issue: PlaybackDiagnostic): string {
const codecs = [...issue.videoCodecs, ...issue.audioCodecs].join(', ');
if (codecs) {
@@ -279,6 +317,20 @@ export class WebPlayerViewComponent {
return !playback.contentInfo;
}
private getHeaderValue(
headers: ResolvedPortalPlayback['headers'] | undefined,
name: string
): string | undefined {
if (!headers) {
return undefined;
}
const matchingKey = Object.keys(headers).find(
(key) => key.toLowerCase() === name.toLowerCase()
);
return matchingKey ? headers[matchingKey] : undefined;
}
private formatPlayer(player: PlaybackDiagnostic['player']): string {
switch (player) {
case 'videojs':