From 8f79b25f52452c2ae58b0a4ddca51ab2272fe688 Mon Sep 17 00:00:00 2001 From: 4gray Date: Fri, 15 May 2026 00:59:14 +0200 Subject: [PATCH] fix(playback): treat portal mpeg-ts vod as non-live --- docs/architecture/embedded-inline-playback.md | 2 + .../vjs-player/vjs-player.component.spec.ts | 98 ++++++++++++++++- .../lib/vjs-player/vjs-player.component.ts | 101 +++++++++++++++++- .../web-player-view.component.spec.ts | 30 ++++++ .../web-player-view.component.ts | 21 +++- 5 files changed, 247 insertions(+), 5 deletions(-) diff --git a/docs/architecture/embedded-inline-playback.md b/docs/architecture/embedded-inline-playback.md index a2e376864..cbf176915 100644 --- a/docs/architecture/embedded-inline-playback.md +++ b/docs/architecture/embedded-inline-playback.md @@ -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. diff --git a/libs/ui/playback/src/lib/vjs-player/vjs-player.component.spec.ts b/libs/ui/playback/src/lib/vjs-player/vjs-player.component.spec.ts index 85da409f2..afbe6202a 100644 --- a/libs/ui/playback/src/lib/vjs-player/vjs-player.component.spec.ts +++ b/libs/ui/playback/src/lib/vjs-player/vjs-player.component.spec.ts @@ -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; +} diff --git a/libs/ui/playback/src/lib/vjs-player/vjs-player.component.ts b/libs/ui/playback/src/lib/vjs-player/vjs-player.component.ts index 02fd0e187..0418f2e06 100644 --- a/libs/ui/playback/src/lib/vjs-player/vjs-player.component.ts +++ b/libs/ui/playback/src/lib/vjs-player/vjs-player.component.ts @@ -35,6 +35,7 @@ type VideoPlayerSource = { type VideoPlayerOptions = Record & { 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(); diff --git a/libs/ui/playback/src/lib/web-player-view/web-player-view.component.spec.ts b/libs/ui/playback/src/lib/web-player-view/web-player-view.component.spec.ts index 701eeb0e6..ee6b1eab6 100644 --- a/libs/ui/playback/src/lib/web-player-view/web-player-view.component.spec.ts +++ b/libs/ui/playback/src/lib/web-player-view/web-player-view.component.spec.ts @@ -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(); diff --git a/libs/ui/playback/src/lib/web-player-view/web-player-view.component.ts b/libs/ui/playback/src/lib/web-player-view/web-player-view.component.ts index 98b5e82ec..dd13e6c4f 100644 --- a/libs/ui/playback/src/lib/web-player-view/web-player-view.component.ts +++ b/libs/ui/playback/src/lib/web-player-view/web-player-view.component.ts @@ -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(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':