fix(playback): treat portal mpeg-ts vod as non-live

This commit is contained in:
4gray committed 2026-05-15 01:07:23 +02:00
1 parent 4a4334cb64
commit 8f79b25f52
5 files changed
+247 -5

No files matched your search

@@ -165,6 +165,8 @@ The banner keeps the primary message compact, then exposes technical details on
URL extension metadata is filtered before diagnostics and player selection use it. Web script extensions such as `.php` are not shown as stream containers; explicit media query metadata such as `extension=ts` or `format=m3u8` is preferred when present.
Portal VOD and episode payloads with `contentInfo` are treated as non-live by the Video.js MPEG-TS path unless `isLive` is explicitly set. If Chromium leaves the underlying MediaSource duration at `Infinity` for a finite TS VOD, the Video.js wrapper normalizes its UI duration from the finite `seekable` or `buffered` range. This removes the misleading `LIVE` control state without changing stream decoding, diagnostics, or external fallback behavior.
When a diagnostic is actionable in Electron, the inline banner may offer `Open in MPV`, `Open in VLC`, and `Copy URL`. Web builds only expose copy/help text. MPV/VLC fallback requests carry the original `ResolvedPortalPlayback` payload so headers, referer, origin, user-agent, content metadata, and resume offset stay intact.
`PortalPlayer.openExternalPlayback(playback, player)` is the forced external launch API. It sends the playback payload to MPV or VLC regardless of the current saved player setting, so fallback buttons do not mutate preferences.
@@ -3,6 +3,7 @@ import { TestBed } from '@angular/core/testing';
import type { VjsPlayerComponent as VjsPlayerComponentInstance } from './vjs-player.component';
const videoJsMock = jest.fn();
const mpegTsCreatePlayerMock = jest.fn();
const mpegTsIsSupportedMock = jest.fn(() => false);
jest.unstable_mockModule('video.js', () => ({
@@ -15,7 +16,7 @@ jest.unstable_mockModule('videojs-quality-selector-hls', () => ({}));
jest.unstable_mockModule('mpegts.js', () => ({
default: {
createPlayer: jest.fn(),
createPlayer: mpegTsCreatePlayerMock,
isSupported: mpegTsIsSupportedMock,
Events: {
ERROR: 'error',
@@ -46,6 +47,17 @@ describe('VjsPlayerComponent', () => {
});
beforeEach(async () => {
mpegTsCreatePlayerMock.mockReset();
mpegTsCreatePlayerMock.mockReturnValue({
attachMediaElement: jest.fn(),
pause: jest.fn(),
unload: jest.fn(),
detachMediaElement: jest.fn(),
destroy: jest.fn(),
on: jest.fn(),
load: jest.fn(),
play: jest.fn(),
});
await TestBed.configureTestingModule({
imports: [VjsPlayerComponent],
}).compileComponents();
@@ -240,4 +252,88 @@ describe('VjsPlayerComponent', () => {
expect(player.reset).toHaveBeenCalled();
expect(player.src).not.toHaveBeenCalled();
});
it('passes the non-live option to mpegts.js for VOD MPEG-TS playback', () => {
const videoElement = document.createElement('video');
const testComponent = component as unknown as {
options: () => {
isLive: boolean;
sources: Array<{ src: string; type: string }>;
};
player: {
tech: () => { el: () => HTMLVideoElement };
dispose: () => void;
};
initMpegTs: (url: string) => void;
};
testComponent.options = () => ({
isLive: false,
sources: [
{
src: 'https://example.com/movie/123.ts',
type: 'video/mp2t',
},
],
});
testComponent.player = {
tech: () => ({ el: () => videoElement }),
dispose: jest.fn(),
};
testComponent.initMpegTs('https://example.com/movie/123.ts');
expect(mpegTsCreatePlayerMock).toHaveBeenCalledWith({
type: 'mpegts',
isLive: false,
url: 'https://example.com/movie/123.ts',
});
});
it('normalizes VideoJS duration for non-live MPEG-TS VOD when MSE reports infinity', () => {
const videoElement = document.createElement('video');
Object.defineProperty(videoElement, 'seekable', {
value: createTimeRanges([[0, 164.072]]),
});
Object.defineProperty(videoElement, 'buffered', {
value: createTimeRanges([]),
});
const testComponent = component as unknown as {
options: () => {
isLive: boolean;
sources: Array<{ src: string; type: string }>;
};
player: {
duration: (duration: number) => void;
dispose: () => void;
};
mpegTsVodDurationTarget: HTMLVideoElement;
syncMpegTsVodDuration: () => void;
};
testComponent.options = () => ({
isLive: false,
sources: [
{
src: 'https://example.com/movie/123.ts',
type: 'video/mp2t',
},
],
});
testComponent.player = {
duration: jest.fn(),
dispose: jest.fn(),
};
testComponent.mpegTsVodDurationTarget = videoElement;
testComponent.syncMpegTsVodDuration();
expect(testComponent.player.duration).toHaveBeenCalledWith(164.072);
});
});
function createTimeRanges(ranges: Array<[number, number]>): TimeRanges {
return {
length: ranges.length,
start: (index: number) => ranges[index][0],
end: (index: number) => ranges[index][1],
} as TimeRanges;
}
@@ -35,6 +35,7 @@ type VideoPlayerSource = {
type VideoPlayerOptions = Record<string, unknown> & {
autoplay?: boolean;
isLive?: boolean;
sources?: VideoPlayerSource[];
};
@@ -104,6 +105,14 @@ export class VjsPlayerComponent implements OnInit, OnChanges, OnDestroy {
player!: VideoJsPlayer;
/** mpegts.js player for raw MPEG-TS streams */
private mpegtsPlayer: mpegts.Player | null = null;
private mpegTsVodDurationTarget: HTMLVideoElement | null = null;
private readonly mpegTsVodDurationEvents = [
'durationchange',
'loadedmetadata',
'progress',
'timeupdate',
'error',
] as const;
readonly volume = input(1);
readonly startTime = input(0);
readonly timeUpdate = output<{
@@ -115,6 +124,10 @@ export class VjsPlayerComponent implements OnInit, OnChanges, OnDestroy {
private readonly clearPlaybackIssue = () => {
this.playbackIssue.emit(null);
};
private readonly scheduleMpegTsVodDurationSync = () => {
this.syncMpegTsVodDuration();
this.queueDurationSync(() => this.syncMpegTsVodDuration());
};
/**
* Instantiate Video.js on component init
@@ -288,15 +301,22 @@ export class VjsPlayerComponent implements OnInit, OnChanges, OnDestroy {
if (!videoEl) return;
console.log('Using mpegts.js for TS stream:', url);
const isLive = this.options().isLive !== false;
this.mpegtsPlayer = mpegts.createPlayer({
type: 'mpegts',
isLive: true,
isLive,
url: url,
});
this.mpegtsPlayer.attachMediaElement(videoEl as HTMLVideoElement);
if (!isLive) {
this.attachMpegTsVodDurationNormalization(
videoEl as HTMLVideoElement
);
}
this.mpegtsPlayer.on(
mpegts.Events.ERROR,
(type: string, details: string, info: unknown): void => {
this.syncMpegTsVodDuration();
this.playbackIssue.emit(
classifyMpegTsPlaybackIssue(
{
@@ -322,6 +342,7 @@ export class VjsPlayerComponent implements OnInit, OnChanges, OnDestroy {
: null;
const nativeError = targetVideo?.error ?? null;
this.syncMpegTsVodDuration();
this.playbackIssue.emit(
classifyNativePlaybackIssue(
videoJsError ?? nativeError,
@@ -333,6 +354,83 @@ export class VjsPlayerComponent implements OnInit, OnChanges, OnDestroy {
);
}
private attachMpegTsVodDurationNormalization(
videoEl: HTMLVideoElement
): void {
this.detachMpegTsVodDurationNormalization();
this.mpegTsVodDurationTarget = videoEl;
for (const eventName of this.mpegTsVodDurationEvents) {
videoEl.addEventListener(
eventName,
this.scheduleMpegTsVodDurationSync
);
}
this.syncMpegTsVodDuration();
}
private detachMpegTsVodDurationNormalization(): void {
if (!this.mpegTsVodDurationTarget) {
return;
}
for (const eventName of this.mpegTsVodDurationEvents) {
this.mpegTsVodDurationTarget.removeEventListener(
eventName,
this.scheduleMpegTsVodDurationSync
);
}
this.mpegTsVodDurationTarget = null;
}
private syncMpegTsVodDuration(): void {
if (this.options().isLive !== false || !this.mpegTsVodDurationTarget) {
return;
}
const duration = this.getFiniteMpegTsVodDuration(
this.mpegTsVodDurationTarget
);
if (!duration) {
return;
}
if (this.player.duration() !== duration) {
this.player.duration(duration);
}
}
private getFiniteMpegTsVodDuration(videoEl: HTMLVideoElement): number {
return (
this.getFiniteTimeRangeEnd(videoEl.seekable) ??
this.getFiniteTimeRangeEnd(videoEl.buffered) ??
0
);
}
private getFiniteTimeRangeEnd(ranges: TimeRanges): number | null {
for (let index = ranges.length - 1; index >= 0; index--) {
try {
const end = ranges.end(index);
if (Number.isFinite(end) && end > 0) {
return end;
}
} catch {
continue;
}
}
return null;
}
private queueDurationSync(callback: () => void): void {
if (typeof queueMicrotask === 'function') {
queueMicrotask(callback);
return;
}
void Promise.resolve().then(callback);
}
private createSourceMetadata(url: string, mimeType?: string) {
return createPlaybackSourceMetadata({
url,
@@ -342,6 +440,7 @@ export class VjsPlayerComponent implements OnInit, OnChanges, OnDestroy {
}
private destroyMpegTs(): void {
this.detachMpegTsVodDurationNormalization();
if (this.mpegtsPlayer) {
this.mpegtsPlayer.pause();
this.mpegtsPlayer.unload();
@@ -201,6 +201,36 @@ describe('WebPlayerViewComponent', () => {
]);
});
it('marks portal VOD playback as non-live for Video.js MPEG-TS playback', () => {
const streamUrl = 'https://example.com/movie/123.ts';
fixture.componentRef.setInput('playback', {
streamUrl,
title: 'Example Movie',
contentInfo: {
playlistId: 'playlist-1',
contentXtreamId: 123,
contentType: 'vod',
},
});
fixture.detectChanges();
const player = fixture.debugElement.query(
By.directive(StubVjsPlayerComponent)
).componentInstance as StubVjsPlayerComponent;
expect(player.options()).toEqual(
expect.objectContaining({
isLive: false,
sources: [
{
src: streamUrl,
type: 'video/mp2t',
},
],
})
);
});
it('uses browser access diagnostic translation keys', () => {
const issue = createBrowserAccessDiagnostic();
@@ -82,7 +82,10 @@ export class WebPlayerViewComponent {
channel!: { url: string };
player!: VideoPlayer;
vjsOptions!: { sources: { src: string; type: string }[] };
vjsOptions!: {
isLive: boolean;
sources: { src: string; type: string }[];
};
readonly isDesktop = signal(this.detectDesktop());
readonly playbackDiagnostic = signal<PlaybackDiagnostic | null>(null);
readonly canShowExternalFallbackActions = computed(
@@ -117,11 +120,14 @@ export class WebPlayerViewComponent {
const playback = this.resolvedPlayback();
this.playbackDiagnostic.set(null);
this.setChannel(playback.streamUrl);
this.setVjsOptions(playback.streamUrl);
this.setVjsOptions(
playback.streamUrl,
this.isLivePlayback(playback)
);
});
}
setVjsOptions(streamUrl: string) {
setVjsOptions(streamUrl: string, isLive = true) {
const extension = getPlaybackMediaExtensionFromUrl(streamUrl);
const mimeType =
extension === 'm3u' || extension === 'm3u8'
@@ -131,6 +137,7 @@ export class WebPlayerViewComponent {
: 'video/mp4';
this.vjsOptions = {
isLive,
sources: [{ src: streamUrl, type: mimeType }],
};
}
@@ -250,6 +257,14 @@ export class WebPlayerViewComponent {
return typeof window !== 'undefined' && !!window.electron;
}
private isLivePlayback(playback: ResolvedPortalPlayback): boolean {
if (typeof playback.isLive === 'boolean') {
return playback.isLive;
}
return !playback.contentInfo;
}
private formatPlayer(player: PlaybackDiagnostic['player']): string {
switch (player) {
case 'videojs':