fix(xtream): try advertised TS after initial web HLS HTTP failure (#1558)

* fix(xtream): try advertised TS after initial web HLS HTTP failure

* refactor(playback): extract fullscreen channel panel state

* test(xtream): keep synthetic media within the mock project
This commit is contained in:
4gray authored and GitHub committed 2026-09-06 11:00:09 +02:00
1 parent ef2418a2c5
commit 436825bdec
37 files changed
+1457 -67

No files matched your search

@@ -15,6 +15,7 @@ export interface ArtPlayerVideoSessionConfig {
emitPlaybackIssue: (issue: PlaybackDiagnostic | null) => void;
emitTimeUpdate: (value: { currentTime: number; duration: number }) => void;
emitPlaybackEnded: () => void;
emitPlaybackStarted?: () => void;
}
/** Owns native video and ArtPlayer event listeners for one player instance. */
@@ -35,6 +36,11 @@ export class ArtPlayerVideoSession {
);
};
private readonly handlePlaying = (): void => {
this.clearPlaybackIssue();
this.config.emitPlaybackStarted?.();
};
private readonly clearPlaybackIssue = (): void => {
this.config.emitPlaybackIssue(null);
};
@@ -73,7 +79,7 @@ export class ArtPlayerVideoSession {
> = [
['error', this.handleNativePlaybackError],
['loadeddata', this.clearPlaybackIssue],
['playing', this.clearPlaybackIssue],
['playing', this.handlePlaying],
['volumechange', this.handleVolumeChange],
['ended', this.handlePlaybackEnded],
];
@@ -66,6 +66,7 @@ export class ArtPlayerComponent implements OnInit, OnDestroy, OnChanges {
}>();
readonly playbackIssue = output<PlaybackDiagnostic | null>();
readonly playbackEnded = output<void>();
readonly playbackStarted = output<void>();
readonly previousEpisodeRequested = output<void>();
readonly nextEpisodeRequested = output<void>();
@@ -187,6 +188,7 @@ export class ArtPlayerComponent implements OnInit, OnDestroy, OnChanges {
emitPlaybackIssue: (issue) => this.playbackIssue.emit(issue),
emitTimeUpdate: (value) => this.timeUpdate.emit(value),
emitPlaybackEnded: () => this.playbackEnded.emit(),
emitPlaybackStarted: () => this.playbackStarted.emit(),
});
this.videoSession = videoSession;
videoSession.attach();
@@ -15,6 +15,7 @@ export interface HtmlVideoElementSessionConfig {
emitPlaybackIssue: (issue: PlaybackDiagnostic | null) => void;
emitTimeUpdate: (value: { currentTime: number; duration: number }) => void;
emitPlaybackEnded: () => void;
emitPlaybackStarted?: () => void;
}
export class HtmlVideoElementSession {
@@ -34,6 +35,11 @@ export class HtmlVideoElementSession {
);
};
private readonly handlePlaying = (): void => {
this.clearPlaybackIssue();
this.config.emitPlaybackStarted?.();
};
private readonly clearPlaybackIssue = (): void => {
this.config.emitPlaybackIssue(null);
};
@@ -71,7 +77,7 @@ export class HtmlVideoElementSession {
['timeupdate', this.handleTimeUpdate],
['error', this.handleNativePlaybackError],
['loadeddata', this.clearPlaybackIssue],
['playing', this.clearPlaybackIssue],
['playing', this.handlePlaying],
['ended', this.handlePlaybackEnded],
];
@@ -87,6 +87,7 @@ export class HtmlVideoPlayerComponent implements OnInit, OnChanges, OnDestroy {
}>();
@Output() playbackIssue = new EventEmitter<PlaybackDiagnostic | null>();
@Output() playbackEnded = new EventEmitter<void>();
@Output() playbackStarted = new EventEmitter<void>();
@Output() previousEpisodeRequested = new EventEmitter<void>();
@Output() nextEpisodeRequested = new EventEmitter<void>();
@@ -382,6 +383,7 @@ export class HtmlVideoPlayerComponent implements OnInit, OnChanges, OnDestroy {
emitPlaybackIssue: (issue) => this.playbackIssue.emit(issue),
emitTimeUpdate: (value) => this.timeUpdate.emit(value),
emitPlaybackEnded: () => this.playbackEnded.emit(),
emitPlaybackStarted: () => this.playbackStarted.emit(),
});
return this.videoSession;
}
@@ -90,6 +90,7 @@ export class VjsPlayerComponent implements OnInit, OnChanges, OnDestroy {
}>();
readonly playbackIssue = output<PlaybackDiagnostic | null>();
readonly playbackEnded = output<void>();
readonly playbackStarted = output<void>();
readonly previousEpisodeRequested = output<void>();
readonly nextEpisodeRequested = output<void>();
@@ -103,6 +104,7 @@ export class VjsPlayerComponent implements OnInit, OnChanges, OnDestroy {
releasePictureInPicture: !this.sharedControls,
clearPlaybackIssue: () => this.playbackIssue.emit(null),
emitPlaybackEnded: () => this.playbackEnded.emit(),
emitPlaybackStarted: () => this.playbackStarted.emit(),
});
private readonly mpegTsSession = new VjsMpegTsSession({
player: () => this.player,
@@ -146,7 +148,7 @@ export class VjsPlayerComponent implements OnInit, OnChanges, OnDestroy {
vjsOptions,
this.handlePlayerReady
) as unknown as VideoJsPlayer;
this.bindPlayerEvents();
this.bindPlayerEvents(true);
initializeVjsPlugins(this.player);
if (!this.sharedControls) {
this.legacyShortcuts = attachVjsLegacyShortcuts({
@@ -220,7 +222,7 @@ export class VjsPlayerComponent implements OnInit, OnChanges, OnDestroy {
this.mpegTsSession.destroy();
this.videoSession.destroy();
if (this.player) {
this.unbindPlayerEvents();
this.bindPlayerEvents(false);
this.player.dispose();
}
}
@@ -410,21 +412,18 @@ export class VjsPlayerComponent implements OnInit, OnChanges, OnDestroy {
this.legacyTracks.bind();
}
private bindPlayerEvents(): void {
this.player.on('loadedmetadata', this.handleLoadedMetadata);
this.player.on('error', this.handleVideoJsError);
this.player.on('volumechange', this.handleVolumeChange);
this.player.on('timeupdate', this.handleTimeUpdate);
this.player.on('pause', this.handlePauseForReset);
this.player.on('playerreset', this.handlePlayerReset);
}
private unbindPlayerEvents(): void {
this.player.off('loadedmetadata', this.handleLoadedMetadata);
this.player.off('error', this.handleVideoJsError);
this.player.off('volumechange', this.handleVolumeChange);
this.player.off('timeupdate', this.handleTimeUpdate);
this.player.off('pause', this.handlePauseForReset);
this.player.off('playerreset', this.handlePlayerReset);
private bindPlayerEvents(bind: boolean): void {
const events = {
loadedmetadata: this.handleLoadedMetadata,
error: this.handleVideoJsError,
volumechange: this.handleVolumeChange,
timeupdate: this.handleTimeUpdate,
pause: this.handlePauseForReset,
playerreset: this.handlePlayerReset,
};
for (const [event, listener] of Object.entries(events)) {
if (bind) this.player.on(event, listener);
else this.player.off(event, listener);
}
}
}
@@ -4,12 +4,18 @@ export interface VjsVideoElementSessionConfig {
releasePictureInPicture?: boolean;
clearPlaybackIssue: () => void;
emitPlaybackEnded: () => void;
emitPlaybackStarted?: () => void;
}
export class VjsVideoElementSession {
private currentVideo: HTMLVideoElement | null = null;
private destroyed = false;
private readonly handlePlaying = (): void => {
this.clearPlaybackIssue();
this.config.emitPlaybackStarted?.();
};
private readonly clearPlaybackIssue = () => {
this.config.clearPlaybackIssue();
};
@@ -28,7 +34,7 @@ export class VjsVideoElementSession {
this.detach();
this.currentVideo = video;
video.addEventListener('loadeddata', this.clearPlaybackIssue);
video.addEventListener('playing', this.clearPlaybackIssue);
video.addEventListener('playing', this.handlePlaying);
video.addEventListener('ended', this.emitPlaybackEnded);
}
@@ -57,10 +63,7 @@ export class VjsVideoElementSession {
'loadeddata',
this.clearPlaybackIssue
);
this.currentVideo.removeEventListener(
'playing',
this.clearPlaybackIssue
);
this.currentVideo.removeEventListener('playing', this.handlePlaying);
this.currentVideo.removeEventListener('ended', this.emitPlaybackEnded);
this.currentVideo = null;
}
@@ -0,0 +1,30 @@
import { linkedSignal } from '@angular/core';
import type { EmbeddedMpvSupport } from '@iptvnator/shared/interfaces';
/**
* The fullscreen channel panel is DOM content over the video. Embedded
* MPV's native-view engine paints a platform view above the page, where
* no DOM layer can show, so the panel exists only while the rendered
* engine is a web player or Embedded MPV's frame-copy canvas. Fails
* closed until frame-copy has been confirmed. Retain that confirmation
* while a replacement component probes support, so channel changes do
* not reset the panel. A confirmed native/unsupported result revokes it.
*/
export function createChannelPanelAvailability(
embedded: () => boolean,
support: () => EmbeddedMpvSupport | null
) {
return linkedSignal<
{ embedded: boolean; support: EmbeddedMpvSupport | null },
boolean
>({
source: () => ({ embedded: embedded(), support: support() }),
computation: ({ embedded, support }, previous) => {
if (!embedded) return true;
if (support === null) {
return previous?.source.embedded ? previous.value : false;
}
return support.supported && support.engine === 'frame-copy';
},
});
}
@@ -0,0 +1,184 @@
import { Component, Injector, signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import {
PlaybackDiagnosticCode,
PlaybackDiagnosticSource,
type PlaybackDiagnostic,
} from '@iptvnator/playback/util';
import {
VideoPlayer,
type ResolvedPortalPlayback,
} from '@iptvnator/shared/interfaces';
import { ErrorDetails } from 'hls.js';
import { WebPlayerLiveAutoFormat } from './web-player-live-auto-format';
@Component({ template: '' })
class Host {}
const failure: PlaybackDiagnostic = {
code: PlaybackDiagnosticCode.NetworkError,
source: PlaybackDiagnosticSource.Vhs,
sourceUrl: '',
container: 'm3u8',
player: 'videojs',
httpStatus: 403,
audioCodecs: [],
videoCodecs: [],
};
const source: ResolvedPortalPlayback = {
streamUrl: 'https://fixture.test/stream.php?token=synthetic&extension=m3u8',
liveAutoTsUrl:
'https://fixture.test/other.php?token=synthetic&extension=ts',
title: 'Fixture',
isLive: true,
userAgent: 'fixture',
headers: { Authorization: 'Bearer synthetic' },
};
describe('Auto live source-format attempt lifecycle', () => {
const playback = signal<ResolvedPortalPlayback | null>(source);
const sessionKey = signal('playlist-a/channel-1');
const player = signal(VideoPlayer.VideoJs);
const intent = signal(0);
const autoEnabled = signal(true);
let controller: WebPlayerLiveAutoFormat;
let fixture: ReturnType<typeof TestBed.createComponent<Host>>;
beforeEach(() => {
playback.set({ ...source });
sessionKey.set('playlist-a/channel-1');
player.set(VideoPlayer.VideoJs);
intent.set(0);
autoEnabled.set(true);
fixture = TestBed.createComponent(Host);
controller = new WebPlayerLiveAutoFormat({
playback,
sessionKey,
player,
intent,
autoEnabled,
injector: fixture.debugElement.injector.get(Injector),
});
fixture.detectChanges();
});
afterEach(() => {
controller.destroy();
fixture.destroy();
});
async function render() {
fixture.detectChanges();
await fixture.whenStable();
fixture.detectChanges();
}
it('waits for the old transport render, preserves the whole payload and consumes only one attempt', async () => {
expect(controller.tryFallback(failure)).toBe(true);
expect(controller.pending()).toBe(true);
expect(controller.playback()).toBe(playback());
expect(controller.tryFallback(failure)).toBe(false);
await render();
expect(controller.playback()).toEqual({
...source,
streamUrl: source.liveAutoTsUrl,
liveAutoTsUrl: undefined,
});
const mounted = controller.playback();
controller.started();
expect(controller.playback()).toBe(mounted);
expect(controller.tryFallback(failure)).toBe(false);
intent.update((n) => n + 1);
expect(controller.playback()?.streamUrl).toBe(source.liveAutoTsUrl);
player.set(VideoPlayer.Html5Player);
expect(controller.playback()?.streamUrl).toBe(source.liveAutoTsUrl);
});
it('does not downgrade after actual playing', () => {
controller.started();
expect(controller.tryFallback(failure)).toBe(false);
});
it.each([0, 200, undefined])(
'does not guess from HTTP status %s',
(httpStatus) => {
expect(controller.tryFallback({ ...failure, httpStatus })).toBe(
false
);
}
);
it.each([
PlaybackDiagnosticCode.UnknownPlaybackError,
PlaybackDiagnosticCode.DrmOrEncryption,
PlaybackDiagnosticCode.MediaDecodeError,
])('does not downgrade %s', (code) => {
expect(controller.tryFallback({ ...failure, code })).toBe(false);
});
it.each(['key', 'media', 'unknown'] as const)(
'does not downgrade HLS %s failures even with an HTTP status',
(stage) => {
expect(
controller.tryFallback({
...failure,
hls: {
engineType: 'unknown',
engineDetails: ErrorDetails.KEY_LOAD_ERROR,
disposition: 'fatal',
stage,
failure: 'http',
httpStatus: 403,
},
})
).toBe(false);
}
);
it('respects manual preference changes before the failure and before pending completion', async () => {
autoEnabled.set(false);
expect(controller.tryFallback(failure)).toBe(false);
autoEnabled.set(true);
expect(controller.tryFallback(failure)).toBe(true);
autoEnabled.set(false);
await render();
expect(controller.playback()).toBe(playback());
});
it('ignores a delayed completion after another playlist or channel takes ownership', async () => {
controller.tryFallback(failure);
sessionKey.set('playlist-b/channel-1');
playback.set({ ...source, title: 'Other playlist' });
await render();
expect(controller.playback()).toBe(playback());
expect(controller.tryFallback(failure)).toBe(true);
await render();
expect(controller.playback()?.streamUrl).toBe(source.liveAutoTsUrl);
});
it('cancels on disposal and does not transfer attempts to another mounted session', async () => {
controller.tryFallback(failure);
controller.destroy();
await render();
expect(controller.playback()).toBe(playback());
expect(controller.tryFallback(failure)).toBe(false);
});
it('does not rearm when same-session source metadata refreshes', async () => {
controller.tryFallback(failure);
await render();
playback.set({ ...source, title: 'Refreshed' });
expect(controller.playback()?.streamUrl).toBe(source.liveAutoTsUrl);
expect(controller.playback()?.title).toBe('Refreshed');
playback.set({ ...source, streamUrl: 'https://other.test/live.m3u8' });
expect(controller.playback()?.streamUrl).toBe(
'https://other.test/live.m3u8'
);
expect(controller.tryFallback(failure)).toBe(false);
});
it.each([
{ isLive: false },
{ liveAutoTsUrl: undefined },
{ drm: {} },
{ contentInfo: {} },
])(
'excludes VOD, catchup, missing advertisement and DRM: %j',
(overrides) => {
playback.set({ ...source, ...overrides } as ResolvedPortalPlayback);
expect(controller.tryFallback(failure)).toBe(false);
}
);
it('does not start an Embedded MPV attempt', () => {
player.set(VideoPlayer.EmbeddedMpv);
expect(controller.tryFallback(failure)).toBe(false);
});
});
@@ -0,0 +1,171 @@
import {
afterNextRender,
computed,
linkedSignal,
type AfterRenderRef,
type Injector,
type Signal,
} from '@angular/core';
import {
type PlaybackDiagnostic,
resolvePlaybackUrlSourceKind,
} from '@iptvnator/playback/util';
import {
type ResolvedPortalPlayback,
VideoPlayer,
} from '@iptvnator/shared/interfaces';
/** One source-format attempt per mounted logical live session, never an engine switch. */
export class WebPlayerLiveAutoFormat {
private callback?: AfterRenderRef;
private destroyed = false;
// Metadata refreshes reuse the transport revision. The returned ownership
// value is opaque; URLs/headers remain solely in the canonical source input.
private readonly origin = linkedSignal({
source: () => this.deps.playback(),
computation: (
source,
previous?: { source: ResolvedPortalPlayback | null; value: symbol }
) =>
previous && sameLiveTransport(source, previous.source)
? previous.value
: Symbol(),
});
private readonly intent = computed(() => {
void this.deps.intent();
void this.deps.player();
void this.deps.autoEnabled();
return Symbol();
});
private readonly state = linkedSignal({
source: () => ({ key: this.deps.sessionKey(), origin: this.origin() }),
computation: (
source,
previous?: {
source: { key: string; origin: symbol };
value: {
attempted: boolean;
started: boolean;
pending: boolean;
useTs: boolean;
};
}
) => ({
attempted:
previous?.source.key === source.key &&
!!previous.value.attempted,
started:
previous?.source.key === source.key && !!previous.value.started,
pending: false,
useTs: false,
}),
});
readonly pending = computed(() => this.state().pending);
private readonly useTs = computed(() => this.state().useTs);
readonly playback = computed(() => {
const playback = this.deps.playback();
return this.useTs() && playback?.liveAutoTsUrl
? {
...playback,
streamUrl: playback.liveAutoTsUrl,
liveAutoTsUrl: undefined,
}
: playback;
});
constructor(
private readonly deps: {
playback: Signal<ResolvedPortalPlayback | null>;
sessionKey: Signal<string>;
player: Signal<VideoPlayer>;
intent: () => unknown;
autoEnabled: () => boolean;
injector: Injector;
}
) {}
started(): void {
this.state.update((state) => ({ ...state, started: true }));
}
tryFallback(issue: PlaybackDiagnostic): boolean {
const source = this.deps.playback();
const state = this.state();
const status = issue.httpStatus;
if (
this.destroyed ||
!this.deps.autoEnabled() ||
state.attempted ||
state.started ||
!this.deps.sessionKey() ||
!source?.liveAutoTsUrl ||
source.isLive !== true ||
source.contentInfo ||
source.drm ||
this.deps.player() === VideoPlayer.EmbeddedMpv ||
resolvePlaybackUrlSourceKind(source.streamUrl) !== 'hls' ||
resolvePlaybackUrlSourceKind(source.liveAutoTsUrl) !== 'mpegts' ||
issue.code !== 'network-error' ||
status === undefined ||
status < 400 ||
status > 599 ||
// Key/license requests must never cause a format downgrade.
(issue.hls &&
!['manifest', 'level', 'segment'].includes(issue.hls.stage))
)
return false;
this.state.set({ ...state, attempted: true, pending: true });
const origin = this.origin();
const key = this.deps.sessionKey();
const intent = this.intent();
this.callback?.destroy();
// Render an empty application first. All web transports synchronously destroy
// their loaders in ngOnDestroy before the TS header handoff may start.
this.callback = afterNextRender(
() => {
this.callback = undefined;
if (
this.destroyed ||
origin !== this.origin() ||
key !== this.deps.sessionKey()
)
return;
this.state.update((current) => ({
...current,
pending: false,
useTs: intent === this.intent(),
}));
},
{ injector: this.deps.injector }
);
return true;
}
destroy(): void {
this.destroyed = true;
this.callback?.destroy();
this.callback = undefined;
}
}
function sameLiveTransport(
a: ResolvedPortalPlayback | null,
b: ResolvedPortalPlayback | null
): boolean {
if (!a || !b) return a === b;
const headerKeys = new Set([
...Object.keys(a.headers ?? {}),
...Object.keys(b.headers ?? {}),
]);
return (
a.streamUrl === b.streamUrl &&
a.liveAutoTsUrl === b.liveAutoTsUrl &&
a.isLive === b.isLive &&
a.drm === b.drm &&
a.contentInfo === b.contentInfo &&
a.userAgent === b.userAgent &&
a.referer === b.referer &&
a.origin === b.origin &&
[...headerKeys].every((key) => a.headers?.[key] === b.headers?.[key])
);
}
@@ -40,6 +40,7 @@ export interface WebPlayerRecoveryControllerDeps {
readonly playbackExternallyTransferable: Signal<boolean>;
readonly alternativeSourceCount: () => number;
readonly managedExternalPlayersAvailable: () => boolean;
readonly tryAutoLiveFormat?: (issue: PlaybackDiagnostic) => boolean;
readonly emitPlaybackFailed: (code: PlaybackDiagnosticCode) => void;
readonly emitExternalFallbackRequested: (
request: PlaybackFallbackRequest
@@ -120,6 +121,12 @@ export class WebPlayerRecoveryController {
this.clearDiagnostic();
return;
}
if (this.deps.tryAutoLiveFormat?.(issue)) {
applicationHandoff.invalidate();
recoverySession.clearPlaybackBinding();
this.clearDiagnostic();
return;
}
if (!recoverySession.recordFailure(binding)) {
return;
}
@@ -14,6 +14,7 @@
[seriesNavigation]="seriesNavigation()"
(timeUpdate)="handleTimeUpdate($event, application)"
(playbackIssue)="handlePlaybackIssue($event, binding)"
(playbackStarted)="handlePlaybackStarted(binding)"
(playbackEnded)="playbackEnded.emit()"
(previousEpisodeRequested)="
previousEpisodeRequested.emit()
@@ -42,6 +43,7 @@
[seriesNavigation]="seriesNavigation()"
(timeUpdate)="handleTimeUpdate($event, application)"
(playbackIssue)="handlePlaybackIssue($event, binding)"
(playbackStarted)="handlePlaybackStarted(binding)"
(playbackEnded)="playbackEnded.emit()"
(previousEpisodeRequested)="
previousEpisodeRequested.emit()
@@ -70,6 +72,7 @@
[seriesNavigation]="seriesNavigation()"
(timeUpdate)="handleTimeUpdate($event, application)"
(playbackIssue)="handlePlaybackIssue($event, binding)"
(playbackStarted)="handlePlaybackStarted(binding)"
(playbackEnded)="playbackEnded.emit()"
(previousEpisodeRequested)="
previousEpisodeRequested.emit()
@@ -0,0 +1,288 @@
import { ClipboardModule } from '@angular/cdk/clipboard';
import {
ComponentFixture,
DeferBlockBehavior,
TestBed,
} from '@angular/core/testing';
import { DestroyRef, signal } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { MatTooltipModule } from '@angular/material/tooltip';
import { By } from '@angular/platform-browser';
import { StorageMap } from '@ngx-pwa/local-storage';
import { TranslateModule } from '@ngx-translate/core';
import {
PlaybackDiagnosticCode,
PlaybackDiagnosticSource,
type PlaybackDiagnostic,
} from '@iptvnator/playback/util';
import { RuntimeCapabilitiesService, SettingsStore } from '@iptvnator/services';
import {
type ExternalPlayerSession,
STORE_KEY,
VideoPlayer,
} from '@iptvnator/shared/interfaces';
import { PORTAL_EXTERNAL_PLAYBACK } from '@iptvnator/portal/shared/util';
import { VodSourceRowComponent } from '@iptvnator/ui/components';
import { of } from 'rxjs';
import { PlaybackDiagnosticPanelComponent } from '../playback-diagnostic-panel/playback-diagnostic-panel.component';
import {
StubArtPlayerComponent,
StubEmbeddedMpvPlayerComponent,
StubFullscreenChannelPanelComponent,
StubHtmlVideoPlayerComponent,
StubVjsPlayerComponent,
} from './web-player-view.spec-stubs';
import { ElectronStreamHeadersService } from './electron-stream-headers.service';
import type { WebPlayerViewComponent as WebPlayerViewComponentInstance } from './web-player-view.component';
jest.unstable_mockModule('video.js', () => ({ default: jest.fn() }));
jest.unstable_mockModule('@yangkghjh/videojs-aspect-ratio-panel', () => ({}));
jest.unstable_mockModule('videojs-contrib-quality-levels', () => ({}));
jest.unstable_mockModule('videojs-quality-selector-hls', () => ({}));
describe('WebPlayerViewComponent live format integration', () => {
let WebPlayerViewComponent: typeof import('./web-player-view.component').WebPlayerViewComponent;
let fixture: ComponentFixture<WebPlayerViewComponentInstance>;
let component: WebPlayerViewComponentInstance;
const storedSettings = { player: VideoPlayer.VideoJs };
const storage = {
get: jest.fn((key: string) =>
of(key === STORE_KEY.Settings ? storedSettings : undefined)
),
};
let runtime: { supportsManagedExternalPlayers: boolean };
let activeExternalSession: ReturnType<
typeof signal<ExternalPlayerSession | null>
>;
let closeExternalSession: jest.Mock<Promise<void>, [ExternalPlayerSession]>;
let holdHeaderHandoff: boolean;
let headerResolvers: Array<(stillCurrent: boolean) => void>;
let headerRejectors: Array<(reason?: unknown) => void>;
const streamHeaders = {
apply: jest.fn(
() =>
(holdHeaderHandoff
? new Promise<boolean>((resolve, reject) => {
headerResolvers.push(resolve);
headerRejectors.push(reject);
})
: null) as Promise<boolean> | null
),
clear: jest.fn(),
};
beforeAll(async () => {
({ WebPlayerViewComponent } =
await import('./web-player-view.component'));
});
beforeEach(async () => {
runtime = { supportsManagedExternalPlayers: true };
activeExternalSession = signal<ExternalPlayerSession | null>(null);
closeExternalSession = jest.fn(async (session) => {
activeExternalSession.set({
...session,
status: 'closed',
canClose: false,
updatedAt: '2026-08-08T10:00:02.000Z',
});
});
holdHeaderHandoff = false;
headerResolvers = [];
headerRejectors = [];
streamHeaders.apply.mockClear();
streamHeaders.clear.mockClear();
await TestBed.configureTestingModule({
deferBlockBehavior: DeferBlockBehavior.Playthrough,
imports: [WebPlayerViewComponent, TranslateModule.forRoot()],
providers: [
{ provide: StorageMap, useValue: storage },
{ provide: RuntimeCapabilitiesService, useValue: runtime },
{
provide: ElectronStreamHeadersService,
useValue: streamHeaders,
},
{
provide: SettingsStore,
useValue: {
showCaptions: () => false,
webPlayerSharedControls: () => false,
},
},
{
provide: PORTAL_EXTERNAL_PLAYBACK,
useValue: {
activeSession: activeExternalSession,
visibleSession: activeExternalSession,
dismissActiveSession: jest.fn(),
closeSession: closeExternalSession,
},
},
],
})
.overrideComponent(WebPlayerViewComponent, {
set: {
imports: [
ClipboardModule,
MatButtonModule,
MatIconModule,
MatTooltipModule,
PlaybackDiagnosticPanelComponent,
StubArtPlayerComponent,
StubEmbeddedMpvPlayerComponent,
StubFullscreenChannelPanelComponent,
StubHtmlVideoPlayerComponent,
StubVjsPlayerComponent,
TranslateModule,
VodSourceRowComponent,
],
},
})
.compileComponents();
fixture = TestBed.createComponent(WebPlayerViewComponent);
component = fixture.componentInstance;
fixture.componentRef.setInput(
'streamUrl',
'https://example.com/live.m3u8'
);
fixture.componentRef.setInput('title', 'Recovery stream');
fixture.componentRef.setInput('playbackSessionKey', 'content-a');
});
afterEach(() => fixture.destroy());
it('tries advertised TS once after initial terminal HLS HTTP failure', async () => {
fixture.componentRef.setInput('playback', {
streamUrl: 'https://example.com/live.m3u8',
title: 'Live',
isLive: true,
liveAutoTsUrl: 'https://example.com/live.ts',
headers: { 'User-Agent': 'fixture-agent' },
});
await render();
const oldBinding = currentBinding();
component.handlePlaybackIssue(
{ ...networkIssue('videojs'), httpStatus: 403 },
oldBinding
);
await render();
expect(component.resolvedPlayback().streamUrl).toBe(
'https://example.com/live.ts'
);
expect(component.resolvedPlayback().headers).toEqual({
'User-Agent': 'fixture-agent',
});
expect(component.selectedPlayer()).toBe(VideoPlayer.VideoJs);
const tsBinding = currentBinding();
component.handlePlaybackIssue(networkIssue('videojs'), tsBinding);
await render();
expect(component.activeBinding()).toBe(tsBinding);
expect(component.visiblePlaybackDiagnostic()).not.toBeNull();
component.handlePlaybackIssue(networkIssue('videojs'), oldBinding);
expect(component.activeBinding()).toBe(tsBinding);
});
it('uses actual playing output to suppress fallback after playback starts', async () => {
fixture.componentRef.setInput('playback', {
streamUrl: 'https://example.com/live.m3u8',
title: 'Live',
isLive: true,
liveAutoTsUrl: 'https://example.com/live.ts',
});
await render();
const player = fixture.debugElement.query(
By.directive(StubVjsPlayerComponent)
).componentInstance as StubVjsPlayerComponent;
player.playbackStarted.emit();
player.playbackIssue.emit({
...networkIssue('videojs'),
httpStatus: 403,
});
await render();
expect(component.resolvedPlayback().streamUrl).toBe(
'https://example.com/live.m3u8'
);
expect(component.visiblePlaybackDiagnostic()).not.toBeNull();
});
it('destroys the previous transport before the TS header handoff', async () => {
fixture.componentRef.setInput('playback', {
streamUrl: 'https://example.com/live.m3u8',
title: 'Live',
isLive: true,
liveAutoTsUrl: 'https://example.com/live.ts',
});
await render();
const player = fixture.debugElement.query(
By.directive(StubVjsPlayerComponent)
);
const events: string[] = [];
player.injector
.get(DestroyRef)
.onDestroy(() => events.push('destroy-hls'));
streamHeaders.apply.mockImplementationOnce(() => {
events.push('ts-headers');
return null;
});
(player.componentInstance as StubVjsPlayerComponent).playbackIssue.emit(
{ ...networkIssue('videojs'), httpStatus: 403 }
);
await render();
expect(events).toEqual(['destroy-hls', 'ts-headers']);
});
it('ignores playing and failure from the old channel before the new render', async () => {
await render();
const oldBinding = currentBinding();
fixture.componentRef.setInput(
'playbackSessionKey',
'another-playlist/channel'
);
fixture.componentRef.setInput('playback', {
streamUrl: 'https://example.com/new.m3u8',
title: 'Live',
isLive: true,
liveAutoTsUrl: 'https://example.com/new.ts',
});
component.handlePlaybackStarted(oldBinding);
component.handlePlaybackIssue(
{ ...networkIssue('videojs'), httpStatus: 403 },
oldBinding
);
await render();
const binding = currentBinding();
component.handlePlaybackIssue(
{ ...networkIssue('videojs'), httpStatus: 403 },
binding
);
await render();
expect(component.resolvedPlayback().streamUrl).toBe(
'https://example.com/new.ts'
);
});
function currentBinding() {
const current = component.activeBinding();
if (!current) throw new Error('Expected a mounted playback binding');
return current;
}
async function render(): Promise<void> {
fixture.detectChanges();
await fixture.whenStable();
fixture.detectChanges();
}
});
function networkIssue(player: 'videojs' | 'html5'): PlaybackDiagnostic {
return {
code: PlaybackDiagnosticCode.NetworkError,
source: PlaybackDiagnosticSource.Vhs,
sourceUrl: 'https://example.com/live.m3u8',
container: 'm3u8',
player,
audioCodecs: [],
videoCodecs: [],
};
}
@@ -1,8 +1,8 @@
import {
Component,
ElementRef,
Injector,
OnDestroy,
type Signal,
ViewEncapsulation,
computed,
effect,
@@ -25,7 +25,6 @@ import { RuntimeCapabilitiesService, SettingsStore } from '@iptvnator/services';
import {
VideoPlayer,
type Channel,
type EmbeddedMpvSupport,
type RecordingStartMetadata,
type RecordingStoppedEvent,
type ResolvedPortalPlayback,
@@ -57,6 +56,8 @@ import {
} from './web-player-application-ownership';
import { createWebPlayerApplicationState } from './web-player-application-state';
import { resolveWebPlayerMediaTitle } from './web-player-playback-state';
import { createChannelPanelAvailability } from './web-player-channel-panel-state';
import { WebPlayerLiveAutoFormat } from './web-player-live-auto-format';
import { WebPlayerRecoveryController } from './web-player-recovery-controller';
import {
isPlaybackExternallyTransferable,
@@ -65,11 +66,6 @@ import {
toVideoPlayer,
} from './web-player-recovery-policy';
/** What the view needs from a rendered Embedded MPV player: its engine. */
interface EmbeddedMpvEngineReporter {
readonly support: Signal<EmbeddedMpvSupport | null>;
}
function resolveWebPlayerSharedControls(): boolean {
const storedValue = inject(SettingsStore).webPlayerSharedControls?.();
const fallback = WEB_PLAYER_SHARED_CONTROLS_ENABLED;
@@ -113,34 +109,11 @@ export class WebPlayerViewComponent implements OnDestroy {
readonly fullscreenSurface: HTMLElement = inject(ElementRef<HTMLElement>)
.nativeElement;
private readonly embeddedMpvPlayer =
viewChild<EmbeddedMpvEngineReporter>('embeddedMpvPlayer');
/**
* The fullscreen channel panel is DOM content over the video. Embedded
* MPV's native-view engine paints a platform view above the page, where
* no DOM layer can show, so the panel exists only while the rendered
* engine is a web player or Embedded MPV's frame-copy canvas. Fails
* closed until frame-copy has been confirmed. Retain that confirmation
* while a replacement component probes support, so channel changes do
* not reset the panel. A confirmed native/unsupported result revokes it.
*/
readonly channelPanelAvailable = linkedSignal<
{ embedded: boolean; support: EmbeddedMpvSupport | null },
boolean
>({
source: () => ({
embedded: this.renderedApplications().some(
(application) => application.embeddedMpv
),
support: this.embeddedMpvPlayer()?.support() ?? null,
}),
computation: ({ embedded, support }, previous) => {
if (!embedded) return true;
if (support === null) {
return previous?.source.embedded ? previous.value : false;
}
return support.supported && support.engine === 'frame-copy';
},
});
viewChild<EmbeddedMpvPlayerComponent>('embeddedMpvPlayer');
readonly channelPanelAvailable = createChannelPanelAvailability(
() => this.renderedApplications().some((app) => app.embeddedMpv),
() => this.embeddedMpvPlayer()?.support() ?? null
);
private readonly runtime = inject(RuntimeCapabilitiesService);
private readonly settingsStore = inject(SettingsStore);
private readonly externalPlayback = inject(PORTAL_EXTERNAL_PLAYBACK, {
@@ -223,8 +196,17 @@ export class WebPlayerViewComponent implements OnDestroy {
const temporary = this.recoverySession.temporaryPlayerOverride();
return temporary ? toVideoPlayer(temporary) : this.renderablePlayer();
});
private readonly applicationState = createWebPlayerApplicationState({
private readonly liveAutoFormat = new WebPlayerLiveAutoFormat({
playback: this.playback,
sessionKey: this.playbackSessionKey,
player: this.selectedPlayer,
intent: this.reloadToken,
autoEnabled: () =>
(this.settingsStore.streamFormat?.() ?? 'auto') === 'auto',
injector: inject(Injector),
});
private readonly applicationState = createWebPlayerApplicationState({
playback: this.liveAutoFormat.playback,
streamUrl: this.streamUrl,
title: this.title,
startTime: this.startTime,
@@ -243,7 +225,7 @@ export class WebPlayerViewComponent implements OnDestroy {
externalRecovery: this.externalRecovery,
applicationHandoff: this.applicationHandoff,
playbackSessionKey: this.playbackSessionKey,
playback: this.playback,
playback: this.liveAutoFormat.playback,
streamUrl: this.streamUrl,
startTime: this.startTime,
selectedPlayer: this.selectedPlayer,
@@ -255,6 +237,7 @@ export class WebPlayerViewComponent implements OnDestroy {
alternativeSourceCount: () => this.alternativeSources().length,
managedExternalPlayersAvailable: () =>
this.runtime.supportsManagedExternalPlayers,
tryAutoLiveFormat: (issue) => this.liveAutoFormat.tryFallback(issue),
emitPlaybackFailed: (code) => this.playbackFailed.emit(code),
emitExternalFallbackRequested: (request) =>
this.externalFallbackRequested.emit(request),
@@ -284,6 +267,7 @@ export class WebPlayerViewComponent implements OnDestroy {
readonly renderedApplications = computed<
readonly PlaybackApplicationOwnership[]
>(() => {
if (this.liveAutoFormat.pending()) return [];
const binding = this.activeBinding();
const embeddedMpv =
this.selectedPlayer() === VideoPlayer.EmbeddedMpv && !binding;
@@ -311,6 +295,7 @@ export class WebPlayerViewComponent implements OnDestroy {
// Session sync may clear a temporary player override. Run it before
// tracking intent so that reset is folded into this application.
this.recovery.syncSession();
if (this.liveAutoFormat.pending()) return;
void this.recovery.diagnosticIntentToken();
const sourceRevision = this.playbackSourceRevisionToken();
untracked(() =>
@@ -349,6 +334,7 @@ export class WebPlayerViewComponent implements OnDestroy {
}
ngOnDestroy(): void {
this.liveAutoFormat.destroy();
this.externalRecovery.destroy();
this.applicationHandoff.destroy();
}
@@ -360,6 +346,18 @@ export class WebPlayerViewComponent implements OnDestroy {
this.recovery.handlePlaybackIssue(issue, binding);
}
handlePlaybackStarted(binding: PlaybackBinding): void {
this.recovery.syncSession();
if (
this.applicationHandoff.owns(
binding,
this.playbackApplicationToken()
) &&
!this.liveAutoFormat.pending()
)
this.liveAutoFormat.started();
}
handleTimeUpdate(
event: { currentTime: number; duration: number },
ownership: PlaybackApplicationOwnership
@@ -27,6 +27,7 @@ export class StubVjsPlayerComponent {
readonly seriesNavigation = input<unknown>(null);
readonly timeUpdate = output<{ currentTime: number; duration: number }>();
readonly playbackIssue = output<PlaybackDiagnostic | null>();
readonly playbackStarted = output<void>();
readonly playbackEnded = output<void>();
readonly previousEpisodeRequested = output<void>();
readonly nextEpisodeRequested = output<void>();
@@ -48,6 +49,7 @@ export class StubHtmlVideoPlayerComponent {
readonly seriesNavigation = input<unknown>(null);
readonly timeUpdate = output<{ currentTime: number; duration: number }>();
readonly playbackIssue = output<PlaybackDiagnostic | null>();
readonly playbackStarted = output<void>();
readonly playbackEnded = output<void>();
readonly previousEpisodeRequested = output<void>();
readonly nextEpisodeRequested = output<void>();
@@ -69,6 +71,7 @@ export class StubArtPlayerComponent {
readonly seriesNavigation = input<unknown>(null);
readonly timeUpdate = output<{ currentTime: number; duration: number }>();
readonly playbackIssue = output<PlaybackDiagnostic | null>();
readonly playbackStarted = output<void>();
readonly playbackEnded = output<void>();
readonly previousEpisodeRequested = output<void>();
readonly nextEpisodeRequested = output<void>();