mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-11 02:46:16 -08:00
* 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
163 lines
4.5 KiB
TypeScript
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);
|
|
}
|
|
}
|