Files
iptvnator/libs/ui/playback/src/lib/vjs-player/vjs-mpegts-session.ts
T
4gray fd96b85c19 feat(playback): recommend recovery actions (#1374)
* docs(playback): design recovery recommendations

* docs(playback): plan recovery recommendations

* refactor(playback): extract diagnostic utilities

* feat(playback): define recovery recommendation contracts

* feat(playback): rank recovery recommendations

* feat(playback): track session recovery attempts

* feat(playback): identify content recovery sessions

* feat(ui): add ranked playback diagnostic panel

* feat(playback): switch temporarily to recommended players

* test(playback): cover temporary player recommendation

* test(playback): verify recommendation capability guards

* docs(playback): document recovery recommendations

* fix(playback): keep recovery keys credential-free

* fix(playback): remove derived tracking ownership

* fix(playback): preserve distinct recovery fallbacks

* fix(playback): reset resume for new sources

* fix(playback): preserve desktop recovery guidance

* docs(playback): clarify recovery policy exceptions

* fix(playback): reject stale progress updates

* fix(playback): keep protected recovery guidance neutral

* test(playback): cover stale progress output

* fix(playback): neutralize protected diagnostic copy

* fix(playback): harden runtime guidance ownership

* fix(playback): stabilize recovery application ownership

* fix(ci): classify playback util coverage

* fix(e2e): preserve playback fixture bytes
2026-08-08 01:04:39 +02:00

163 lines
4.5 KiB
TypeScript

import mpegts from 'mpegts.js';
import {
InlinePlaybackPlayer,
type PlaybackDiagnostic,
classifyMpegTsPlaybackIssue,
createMpegTsPlaybackEvidence,
createPlaybackSourceMetadata,
getPlaybackMediaExtensionFromUrl,
} from '@iptvnator/playback/util';
interface VjsDurationPlayer {
duration(value?: number): unknown;
}
export interface VjsMpegTsSessionConfig {
player: () => VjsDurationPlayer;
isLive: () => boolean;
emitPlaybackIssue: (issue: PlaybackDiagnostic) => void;
}
const VOD_DURATION_EVENTS = [
'durationchange',
'loadedmetadata',
'progress',
'timeupdate',
'error',
] as const;
export class VjsMpegTsSession {
private engine: mpegts.Player | null = null;
private video: HTMLVideoElement | null = null;
private errorListener: ((...args: unknown[]) => void) | null = null;
constructor(private readonly config: VjsMpegTsSessionConfig) {}
isSupportedSource(url?: string): boolean {
if (!url) {
return false;
}
const extension = getPlaybackMediaExtensionFromUrl(url);
return (extension === 'ts' || !extension) && mpegts.isSupported();
}
start(url: string, video: HTMLVideoElement): void {
this.destroy();
const isLive = this.config.isLive();
const engine = mpegts.createPlayer({
type: 'mpegts',
isLive,
url,
});
this.engine = engine;
this.video = video;
this.errorListener = (
type: unknown,
details: unknown,
info: unknown
) => {
this.syncDuration();
this.config.emitPlaybackIssue(
classifyMpegTsPlaybackIssue(
createMpegTsPlaybackEvidence(type, details, info),
createPlaybackSourceMetadata({
url,
mimeType: 'video/mp2t',
player: InlinePlaybackPlayer.VideoJs,
})
)
);
};
engine.attachMediaElement(video);
if (!isLive) {
this.bindDurationEvents(video);
}
engine.on(mpegts.Events.ERROR, this.errorListener);
engine.load();
const playResult = engine.play();
if (playResult) {
void playResult.catch(() => undefined);
}
}
syncDuration(): void {
if (this.config.isLive() || !this.video) {
return;
}
const duration =
this.readLastFinitePositiveEnd(this.video.seekable) ??
this.readLastFinitePositiveEnd(this.video.buffered);
if (!duration) {
return;
}
const player = this.config.player();
if (player.duration() !== duration) {
player.duration(duration);
}
}
destroy(): void {
const video = this.video;
if (video) {
for (const eventName of VOD_DURATION_EVENTS) {
video.removeEventListener(eventName, this.scheduleDurationSync);
}
}
this.video = null;
const engine = this.engine;
if (!engine) {
this.errorListener = null;
return;
}
if (this.errorListener) {
engine.off(mpegts.Events.ERROR, this.errorListener);
}
this.errorListener = null;
engine.pause();
engine.unload();
engine.detachMediaElement();
engine.destroy();
this.engine = null;
}
private readonly scheduleDurationSync = () => {
this.syncDuration();
this.queueMicrotask(() => this.syncDuration());
};
private bindDurationEvents(video: HTMLVideoElement): void {
for (const eventName of VOD_DURATION_EVENTS) {
video.addEventListener(eventName, this.scheduleDurationSync);
}
this.syncDuration();
}
private readLastFinitePositiveEnd(ranges: TimeRanges): number | null {
for (let index = ranges.length - 1; index >= 0; index -= 1) {
try {
const end = ranges.end(index);
if (Number.isFinite(end) && end > 0) {
return end;
}
} catch {
continue;
}
}
return null;
}
private queueMicrotask(callback: () => void): void {
if (typeof queueMicrotask === 'function') {
queueMicrotask(callback);
return;
}
void Promise.resolve().then(callback);
}
}