mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-11 02:46:16 -08:00
fix(playback): treat portal mpeg-ts vod as non-live
This commit is contained in:
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':
|
||||
|
||||
Reference in new issue
Block a user