Files
iptvnator/libs/ui/playback/src/lib/art-player/art-player-source-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

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,
});
}
}