mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-16 14:16:16 -08:00
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:
1 parent
ef2418a2c5
commit
436825bdec
37 files changed
+1457
-67
No files matched your search
@@ -120,6 +120,48 @@ describe('XtreamUrlService', () => {
|
||||
expect(url).toBe('http://demo.example/live/demo/secret/101.ts');
|
||||
});
|
||||
|
||||
it('builds an Auto TS alternative with encoded credentials and the provider subpath', () => {
|
||||
streamFormat.set(StreamFormat.AutoStreamFormat);
|
||||
expect(
|
||||
service.constructAutoLiveTsUrl(
|
||||
{
|
||||
serverUrl:
|
||||
'https://demo.example/panel/player_api.php?token=unused',
|
||||
username: 'a/b',
|
||||
password: 'x?y#z',
|
||||
allowedOutputFormats: [' M3U8 ', ' TS '],
|
||||
},
|
||||
101
|
||||
)
|
||||
).toBe('https://demo.example/panel/live/a%2Fb/x%3Fy%23z/101.ts');
|
||||
});
|
||||
|
||||
it.each([undefined, [], ['m3u8'], ['rtmp'], ['ts']])(
|
||||
'does not guess an HLS-to-TS alternative for %j',
|
||||
(allowedOutputFormats) => {
|
||||
streamFormat.set(StreamFormat.AutoStreamFormat);
|
||||
expect(
|
||||
service.constructAutoLiveTsUrl(
|
||||
{ ...credentials, allowedOutputFormats },
|
||||
101
|
||||
)
|
||||
).toBeUndefined();
|
||||
}
|
||||
);
|
||||
|
||||
it.each([StreamFormat.TsStreamFormat, StreamFormat.M3u8StreamFormat])(
|
||||
'respects manual %s even with both formats advertised',
|
||||
(format) => {
|
||||
streamFormat.set(format);
|
||||
expect(
|
||||
service.constructAutoLiveTsUrl(
|
||||
{ ...credentials, allowedOutputFormats: ['m3u8', 'ts'] },
|
||||
101
|
||||
)
|
||||
).toBeUndefined();
|
||||
}
|
||||
);
|
||||
|
||||
it('constructs a VOD URL from catalog fields when metadata is empty', () => {
|
||||
const vodItem = {
|
||||
info: [],
|
||||
|
||||
@@ -113,6 +113,29 @@ export class XtreamUrlService {
|
||||
return `${normalizedCredentials.serverUrl}/live/${normalizedCredentials.username}/${normalizedCredentials.password}/${xtreamId}.${streamFormat}`;
|
||||
}
|
||||
|
||||
/** No probe: eligibility is explicit account evidence plus the user's Auto intent. */
|
||||
constructAutoLiveTsUrl(
|
||||
credentials: XtreamCredentials,
|
||||
xtreamId: number
|
||||
): string | undefined {
|
||||
const requested =
|
||||
this.settingsStore.streamFormat() ?? StreamFormat.AutoStreamFormat;
|
||||
const formats = this.getNormalizedAllowedOutputFormats(credentials);
|
||||
if (
|
||||
requested !== StreamFormat.AutoStreamFormat ||
|
||||
!formats?.includes('m3u8') ||
|
||||
!formats.includes('ts')
|
||||
)
|
||||
return undefined;
|
||||
return (
|
||||
this.constructLiveUrl(
|
||||
credentials,
|
||||
xtreamId,
|
||||
StreamFormat.TsStreamFormat
|
||||
) || undefined
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Construct VOD stream URL
|
||||
* Format: {serverUrl}/movie/{username}/{password}/{streamId}.{extension}
|
||||
|
||||
+11
@@ -196,6 +196,7 @@ describe('LiveStreamLayoutComponent', () => {
|
||||
getFavorites: jest.fn().mockReturnValue(of([])),
|
||||
};
|
||||
const xtreamUrlService = {
|
||||
constructAutoLiveTsUrl: jest.fn(() => undefined),
|
||||
resolveCatchupUrl: jest
|
||||
.fn()
|
||||
.mockResolvedValue('https://example.com/timeshift.ts'),
|
||||
@@ -357,6 +358,16 @@ describe('LiveStreamLayoutComponent', () => {
|
||||
window.electron = originalElectron;
|
||||
});
|
||||
|
||||
it('retires committed live playback when the playlist owner changes', () => {
|
||||
fixture.detectChanges();
|
||||
component.playLive({ xtream_id: 10000, title: 'Synthetic' });
|
||||
expect(component.activePlayback()).not.toBeNull();
|
||||
currentPlaylist.set({ ...playlist, id: 'other-playlist' });
|
||||
fixture.detectChanges();
|
||||
expect(component.activePlayback()).toBeNull();
|
||||
expect(component.playbackSessionKey()).toBe('');
|
||||
});
|
||||
|
||||
it('renders the controlled epg list for electron playback', () => {
|
||||
epgItems.set([
|
||||
buildEpgItem(
|
||||
|
||||
@@ -372,6 +372,7 @@ export class LiveStreamLayoutComponent
|
||||
private unsubscribeRemoteChannelChange?: () => void;
|
||||
private unsubscribeRemoteCommand?: () => void;
|
||||
private playbackRequestId = 0;
|
||||
private activePlaylistId: string | null = null;
|
||||
|
||||
readonly usesEmbeddedPlayer = computed(() =>
|
||||
this.portalPlayer.isEmbeddedPlayer()
|
||||
@@ -391,6 +392,16 @@ export class LiveStreamLayoutComponent
|
||||
favorites = new Map<number, boolean>();
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
const playlistId = this.xtreamStore.currentPlaylist()?.id ?? null;
|
||||
if (this.activePlaylistId && this.activePlaylistId !== playlistId) {
|
||||
this.playbackRequestId += 1;
|
||||
this.activePlaylistId = null;
|
||||
this.activePlayback.set(null);
|
||||
this.activeLiveItemId.set(null);
|
||||
this.activeCatchupProgram.set(null);
|
||||
}
|
||||
});
|
||||
effect((onCleanup) => {
|
||||
const intervalId = window.setInterval(() => {
|
||||
this.currentTimeMs.set(Date.now());
|
||||
@@ -547,8 +558,19 @@ export class LiveStreamLayoutComponent
|
||||
// store no-op.
|
||||
if (!remote) this.selectLiveItemCategory(item);
|
||||
this.activeLiveItemId.set(item.xtream_id);
|
||||
const playlist = this.xtreamStore.currentPlaylist();
|
||||
this.activePlaylistId = playlist?.id ?? null;
|
||||
this.activePlayback.set({
|
||||
streamUrl,
|
||||
liveAutoTsUrl: playlist
|
||||
? this.xtreamUrlService.constructAutoLiveTsUrl(
|
||||
playlist,
|
||||
item.xtream_id
|
||||
)
|
||||
: undefined,
|
||||
userAgent: playlist?.userAgent,
|
||||
referer: playlist?.referrer,
|
||||
origin: playlist?.origin,
|
||||
title: item.title ?? item.name ?? '',
|
||||
thumbnail: item.poster_url ?? item.stream_icon ?? null,
|
||||
isLive: true,
|
||||
@@ -761,6 +783,7 @@ export class LiveStreamLayoutComponent
|
||||
return;
|
||||
}
|
||||
|
||||
this.activePlaylistId = playlist.id;
|
||||
this.activeCatchupProgram.set(program);
|
||||
this.activePlayback.set({
|
||||
streamUrl: catchupUrl,
|
||||
|
||||
@@ -10,6 +10,8 @@ export interface PlayerContentInfo extends Omit<
|
||||
|
||||
export interface ResolvedPortalPlayback {
|
||||
streamUrl: string;
|
||||
/** Source-owner supplied, advertised TS alternative for Xtream live Auto only. */
|
||||
liveAutoTsUrl?: string;
|
||||
title: string;
|
||||
thumbnail?: string | null;
|
||||
isLive?: boolean;
|
||||
|
||||
@@ -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()
|
||||
|
||||
+288
@@ -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>();
|
||||
|
||||
Reference in new issue
Block a user