mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-12 03:56:15 -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
347 lines
11 KiB
TypeScript
347 lines
11 KiB
TypeScript
import type Artplayer from 'artplayer';
|
|
import type { Option } from 'artplayer';
|
|
import Hls, { type ErrorData, type ManifestParsedData } from 'hls.js';
|
|
import mpegts from 'mpegts.js';
|
|
import type { ChannelDrm } from '@iptvnator/shared/interfaces';
|
|
import {
|
|
InlinePlaybackPlayer,
|
|
type PlaybackDiagnostic,
|
|
classifyHlsPlaybackIssue,
|
|
classifyMpegTsPlaybackIssue,
|
|
classifyUnsupportedHlsManifestCodecs,
|
|
createHlsPlaybackEvidence,
|
|
createMpegTsPlaybackEvidence,
|
|
createPlaybackSourceMetadata,
|
|
} from '@iptvnator/playback/util';
|
|
import { isBrowserMediaTypeSupported } from '../web-video-support/browser-media-type-support';
|
|
import type { WebVideoControlsAdapter } from '../player-controls';
|
|
import type { ShakaModuleLoader } from '../shaka-engine/shaka-module.types';
|
|
import { ShakaVideoSession } from '../shaka-engine/shaka-video-session';
|
|
import {
|
|
type WebVideoControlsSource,
|
|
WebVideoSourceControlsBridge,
|
|
} from '../web-video-support/web-video-source-controls.bridge';
|
|
import { WebVideoSourceTracks } from '../web-video-support/web-video-source-tracks';
|
|
import { addHlsAudioTrackSettings } from './art-player-audio-tracks';
|
|
|
|
export interface ArtPlayerSourceSessionConfig {
|
|
sharedControls: boolean;
|
|
controlsAdapter: WebVideoControlsAdapter;
|
|
isLive: () => boolean;
|
|
showCaptions: () => boolean;
|
|
emitPlaybackIssue: (issue: PlaybackDiagnostic) => void;
|
|
/** DRM config of the active channel, used by the DASH (`mpd`) engine. */
|
|
getDrm?: () => ChannelDrm | undefined;
|
|
/** Test seam for the lazily imported shaka-player module. */
|
|
loadShaka?: ShakaModuleLoader;
|
|
}
|
|
|
|
/**
|
|
* Owns ArtPlayer's custom source engines and the shared-controls track bridge.
|
|
*
|
|
* A session belongs to one ArtPlayer instance. Its customType callbacks use the
|
|
* callback-local `art` argument and become no-ops after destroy, so ArtPlayer's
|
|
* delayed URL dispatch cannot mutate a newer component player.
|
|
*/
|
|
export class ArtPlayerSourceSession {
|
|
readonly customType: NonNullable<Option['customType']> = {
|
|
m3u8: (video, url, art) => this.startHls(video, url, art),
|
|
mpd: (video, url) => this.startDash(video, url),
|
|
ts: (video, url) => this.startMpegTs(video, url),
|
|
'video/matroska': (video, url) => this.startNative(video, url),
|
|
};
|
|
|
|
private player: Artplayer | null = null;
|
|
private controlsBridge: WebVideoSourceControlsBridge | null = null;
|
|
/**
|
|
* Legacy (ArtPlayer chrome) counterpart of {@link controlsBridge}: keeps
|
|
* the `showCaptions` preference authoritative without a controls adapter.
|
|
*/
|
|
private captionTracks: WebVideoSourceTracks | null = null;
|
|
private pendingControlsSource: WebVideoControlsSource = { kind: 'native' };
|
|
private hls: Hls | null = null;
|
|
private hlsManifestListener:
|
|
| ((event: unknown, data: ManifestParsedData) => void)
|
|
| null = null;
|
|
private hlsErrorListener:
|
|
| ((event: unknown, data: ErrorData) => void)
|
|
| null = null;
|
|
private mpegTsPlayer: mpegts.Player | null = null;
|
|
private mpegTsErrorListener:
|
|
| ((type: unknown, details: unknown, info: unknown) => void)
|
|
| null = null;
|
|
private shakaSession: ShakaVideoSession | null = null;
|
|
private destroyed = false;
|
|
|
|
constructor(private readonly config: ArtPlayerSourceSessionConfig) {}
|
|
|
|
attach(player: Artplayer): void {
|
|
if (this.destroyed || this.player) {
|
|
return;
|
|
}
|
|
|
|
this.player = player;
|
|
if (!this.config.sharedControls) {
|
|
this.captionTracks = new WebVideoSourceTracks({
|
|
video: player.video,
|
|
showCaptions: this.config.showCaptions,
|
|
vendorCaptionControls: true,
|
|
});
|
|
this.captionTracks.setSource(this.pendingControlsSource);
|
|
return;
|
|
}
|
|
|
|
const bridge = new WebVideoSourceControlsBridge({
|
|
video: player.video,
|
|
adapter: this.config.controlsAdapter,
|
|
isLive: this.config.isLive,
|
|
showCaptions: this.config.showCaptions,
|
|
});
|
|
this.controlsBridge = bridge;
|
|
bridge.attach();
|
|
bridge.setSource(this.pendingControlsSource);
|
|
}
|
|
|
|
refreshInputs(): void {
|
|
this.controlsBridge?.refreshInputs();
|
|
this.captionTracks?.refreshInputs();
|
|
}
|
|
|
|
resolveDuration(fallbackDuration: number): number {
|
|
const correctedDuration = this.controlsBridge?.readDuration() ?? NaN;
|
|
return Number.isNaN(correctedDuration)
|
|
? fallbackDuration
|
|
: correctedDuration;
|
|
}
|
|
|
|
destroy(): void {
|
|
if (this.destroyed) {
|
|
return;
|
|
}
|
|
|
|
this.destroyed = true;
|
|
this.controlsBridge?.destroy();
|
|
this.controlsBridge = null;
|
|
this.captionTracks?.destroy();
|
|
this.captionTracks = null;
|
|
this.destroyHls();
|
|
this.destroyMpegTs();
|
|
this.shakaSession?.destroy();
|
|
this.shakaSession = null;
|
|
this.player = null;
|
|
}
|
|
|
|
private startHls(
|
|
video: HTMLVideoElement,
|
|
url: string,
|
|
art: Artplayer
|
|
): void {
|
|
if (this.destroyed) {
|
|
return;
|
|
}
|
|
|
|
this.prepareForSourceChange();
|
|
if (Hls.isSupported()) {
|
|
const hls = new Hls();
|
|
this.hls = hls;
|
|
this.hlsManifestListener = (_event, data) => {
|
|
if (this.destroyed || this.hls !== hls) {
|
|
return;
|
|
}
|
|
this.handleHlsManifestParsed(url, data);
|
|
};
|
|
this.hlsErrorListener = (_event, data) => {
|
|
if (this.destroyed || this.hls !== hls) {
|
|
return;
|
|
}
|
|
this.handleHlsError(url, data);
|
|
};
|
|
hls.on(Hls.Events.MANIFEST_PARSED, this.hlsManifestListener);
|
|
hls.on(Hls.Events.ERROR, this.hlsErrorListener);
|
|
hls.attachMedia(video);
|
|
this.bindControlsSource({ kind: 'hls', hls });
|
|
hls.loadSource(url);
|
|
if (!this.config.sharedControls) {
|
|
addHlsAudioTrackSettings(art, hls);
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (video.canPlayType('application/vnd.apple.mpegurl')) {
|
|
video.src = url;
|
|
this.bindControlsSource({ kind: 'native' });
|
|
}
|
|
}
|
|
|
|
private startMpegTs(video: HTMLVideoElement, url: string): void {
|
|
if (this.destroyed) {
|
|
return;
|
|
}
|
|
|
|
this.prepareForSourceChange();
|
|
if (!mpegts.isSupported()) {
|
|
return;
|
|
}
|
|
const engine = mpegts.createPlayer({
|
|
type: 'mpegts',
|
|
isLive: this.config.sharedControls ? this.config.isLive() : true,
|
|
url,
|
|
});
|
|
this.mpegTsPlayer = engine;
|
|
this.mpegTsErrorListener = (type, details, info) => {
|
|
if (this.destroyed || this.mpegTsPlayer !== engine) {
|
|
return;
|
|
}
|
|
this.config.emitPlaybackIssue(
|
|
classifyMpegTsPlaybackIssue(
|
|
createMpegTsPlaybackEvidence(type, details, info),
|
|
this.createSourceMetadata(url, 'video/mp2t')
|
|
)
|
|
);
|
|
};
|
|
|
|
engine.attachMediaElement(video);
|
|
this.bindControlsSource({ kind: 'mpegts' });
|
|
engine.on(mpegts.Events.ERROR, this.mpegTsErrorListener);
|
|
engine.load();
|
|
const playResult = engine.play();
|
|
if (playResult) {
|
|
void playResult.catch(() => undefined);
|
|
}
|
|
}
|
|
|
|
private startDash(video: HTMLVideoElement, url: string): void {
|
|
if (this.destroyed) {
|
|
return;
|
|
}
|
|
|
|
this.prepareForSourceChange();
|
|
const session = this.getShakaSession();
|
|
this.bindControlsSource({ kind: 'shaka', session });
|
|
session.start(video, url, this.config.getDrm?.());
|
|
}
|
|
|
|
private startNative(video: HTMLVideoElement, url: string): void {
|
|
if (this.destroyed) {
|
|
return;
|
|
}
|
|
|
|
this.prepareForSourceChange();
|
|
video.src = url;
|
|
this.bindControlsSource({ kind: 'native' });
|
|
}
|
|
|
|
private prepareForSourceChange(): void {
|
|
this.controlsBridge?.clearSource();
|
|
this.captionTracks?.clearSource();
|
|
this.destroyHls();
|
|
this.destroyMpegTs();
|
|
this.shakaSession?.stop();
|
|
}
|
|
|
|
private getShakaSession(): ShakaVideoSession {
|
|
this.shakaSession ??= new ShakaVideoSession({
|
|
player: InlinePlaybackPlayer.ArtPlayer,
|
|
emitPlaybackIssue: (issue) =>
|
|
this.config.emitPlaybackIssue(issue),
|
|
showCaptions: this.config.showCaptions,
|
|
loadShaka: this.config.loadShaka,
|
|
});
|
|
return this.shakaSession;
|
|
}
|
|
|
|
private bindControlsSource(source: WebVideoControlsSource): void {
|
|
this.pendingControlsSource = source;
|
|
this.controlsBridge?.setSource(source);
|
|
this.captionTracks?.setSource(source);
|
|
}
|
|
|
|
private destroyHls(): void {
|
|
const hls = this.hls;
|
|
if (!hls) {
|
|
this.hlsManifestListener = null;
|
|
this.hlsErrorListener = null;
|
|
return;
|
|
}
|
|
|
|
if (this.hlsManifestListener) {
|
|
hls.off(Hls.Events.MANIFEST_PARSED, this.hlsManifestListener);
|
|
}
|
|
if (this.hlsErrorListener) {
|
|
hls.off(Hls.Events.ERROR, this.hlsErrorListener);
|
|
}
|
|
this.hlsManifestListener = null;
|
|
this.hlsErrorListener = null;
|
|
hls.destroy();
|
|
this.hls = null;
|
|
}
|
|
|
|
private destroyMpegTs(): void {
|
|
const engine = this.mpegTsPlayer;
|
|
if (!engine) {
|
|
this.mpegTsErrorListener = null;
|
|
return;
|
|
}
|
|
|
|
if (this.mpegTsErrorListener) {
|
|
engine.off(mpegts.Events.ERROR, this.mpegTsErrorListener);
|
|
}
|
|
this.mpegTsErrorListener = null;
|
|
engine.pause();
|
|
engine.unload();
|
|
engine.detachMediaElement();
|
|
engine.destroy();
|
|
this.mpegTsPlayer = null;
|
|
}
|
|
|
|
private handleHlsManifestParsed(
|
|
url: string,
|
|
data: ManifestParsedData
|
|
): void {
|
|
const metadata = this.createSourceMetadata(
|
|
url,
|
|
'application/x-mpegURL',
|
|
data.levels
|
|
.map((level) => level.audioCodec)
|
|
.filter((codec): codec is string => Boolean(codec)),
|
|
data.levels
|
|
.map((level) => level.videoCodec)
|
|
.filter((codec): codec is string => Boolean(codec))
|
|
);
|
|
const issue = classifyUnsupportedHlsManifestCodecs(
|
|
metadata,
|
|
isBrowserMediaTypeSupported
|
|
);
|
|
if (issue) {
|
|
this.config.emitPlaybackIssue(issue);
|
|
}
|
|
}
|
|
|
|
private handleHlsError(url: string, data: ErrorData): void {
|
|
const issue = classifyHlsPlaybackIssue(
|
|
createHlsPlaybackEvidence(data),
|
|
this.createSourceMetadata(url, 'application/x-mpegURL')
|
|
);
|
|
if (!issue) {
|
|
return;
|
|
}
|
|
|
|
this.config.emitPlaybackIssue(issue);
|
|
}
|
|
|
|
private createSourceMetadata(
|
|
url: string,
|
|
mimeType?: string,
|
|
audioCodecs: readonly string[] = [],
|
|
videoCodecs: readonly string[] = []
|
|
) {
|
|
return createPlaybackSourceMetadata({
|
|
url,
|
|
mimeType,
|
|
player: InlinePlaybackPlayer.ArtPlayer,
|
|
audioCodecs,
|
|
videoCodecs,
|
|
});
|
|
}
|
|
}
|