Files
iptvnator/libs/ui/playback/src/lib/vjs-player/vjs-player.component.ts
T
4gray 436825bdec fix(xtream): try advertised TS after initial web HLS HTTP failure (#1558)
* fix(xtream): try advertised TS after initial web HLS HTTP failure

* refactor(playback): extract fullscreen channel panel state

* test(xtream): keep synthetic media within the mock project
2026-09-06 11:00:09 +02:00

430 lines
15 KiB
TypeScript

import {
Component,
ElementRef,
OnChanges,
OnDestroy,
OnInit,
SimpleChanges,
ViewEncapsulation,
inject,
input,
output,
signal,
viewChild,
} from '@angular/core';
import '@yangkghjh/videojs-aspect-ratio-panel';
import { createDevLogger } from '@iptvnator/shared/interfaces';
import videoJs from 'video.js';
import 'videojs-contrib-quality-levels';
import 'videojs-quality-selector-hls';
import {
InlinePlaybackPlayer,
type PlaybackDiagnostic,
classifyNativePlaybackIssue,
classifyVhsPlaybackIssue,
createPlaybackSourceMetadata,
} from '@iptvnator/playback/util';
import {
type LegacyPlayerShortcuts,
PlayerControlsComponent,
type PlayerMediaTitle,
WEB_PLAYER_SHARED_CONTROLS,
WebVideoControlsAdapter,
} from '../player-controls';
import { SeriesPlaybackNavigationControlsComponent } from '../portal-inline-player/series-playback-navigation-controls.component';
import type { SeriesPlaybackNavigation } from '../portal-inline-player/series-playback-navigation';
import { logVjsAudioTracks, setupVjsAudioTrackMenu } from './vjs-audio-tracks';
import { VjsLegacyTracks } from './vjs-legacy-tracks';
import { VjsMpegTsSession } from './vjs-mpegts-session';
import { attachVjsLegacyShortcuts } from './vjs-legacy-shortcuts';
import { attachVjsPointerFocusRelease } from './vjs-pointer-focus-release';
import { VjsPlayerControlsBridge } from './vjs-player-controls.bridge';
import {
createVjsPlayerOptions,
exitOwnedVjsFullscreen,
initializeVjsPlugins,
queueVjsTask,
shouldChangeVjsSource,
} from './vjs-player-setup';
import { VjsPlayerResetCoordinator } from './vjs-player-reset-coordinator';
import {
type VideoJsPlayer,
type VideoPlayerOptions,
type VideoPlayerSource,
getVideoJsTechVideo,
hasActiveVhsSourceHandler,
} from './vjs-player.types';
import { VjsVideoElementSession } from './vjs-video-element-session';
const debugVjsPlayer = createDevLogger('VjsPlayer');
@Component({
selector: 'app-vjs-player',
templateUrl: './vjs-player.component.html',
styleUrls: ['./vjs-player.component.scss'],
encapsulation: ViewEncapsulation.None,
imports: [
PlayerControlsComponent,
SeriesPlaybackNavigationControlsComponent,
],
providers: [WebVideoControlsAdapter],
standalone: true,
})
export class VjsPlayerComponent implements OnInit, OnChanges, OnDestroy {
readonly target =
viewChild.required<ElementRef<HTMLVideoElement>>('target');
readonly playerRoot = viewChild<ElementRef<HTMLElement>>('playerRoot');
readonly options = input.required<VideoPlayerOptions>();
readonly volume = input(1);
readonly startTime = input(0);
readonly seriesNavigation = input<SeriesPlaybackNavigation | null>(null);
readonly interactionEnabled = input(true);
readonly showCaptions = input(false);
readonly mediaTitle = input<PlayerMediaTitle | null>(null);
/** See `PlayerControlsComponent.fullscreenTarget`; null keeps the shell. */
readonly fullscreenTarget = input<HTMLElement | null>(null);
readonly timeUpdate = output<{
currentTime: number;
duration: number;
}>();
readonly playbackIssue = output<PlaybackDiagnostic | null>();
readonly playbackEnded = output<void>();
readonly playbackStarted = output<void>();
readonly previousEpisodeRequested = output<void>();
readonly nextEpisodeRequested = output<void>();
readonly sharedControls = inject(WEB_PLAYER_SHARED_CONTROLS);
readonly controlsAdapter = inject(WebVideoControlsAdapter);
player!: VideoJsPlayer;
private readonly seriesNavigationSignal =
signal<SeriesPlaybackNavigation | null>(null);
private readonly videoSession = new VjsVideoElementSession({
releasePictureInPicture: !this.sharedControls,
clearPlaybackIssue: () => this.playbackIssue.emit(null),
emitPlaybackEnded: () => this.playbackEnded.emit(),
emitPlaybackStarted: () => this.playbackStarted.emit(),
});
private readonly mpegTsSession = new VjsMpegTsSession({
player: () => this.player,
isLive: () => this.options().isLive !== false,
emitPlaybackIssue: (issue) => this.playbackIssue.emit(issue),
});
private readonly resetCoordinator = new VjsPlayerResetCoordinator({
player: () => this.player,
fallbackVolume: () => this.volume(),
queueTask: queueVjsTask,
reportError: (error) =>
debugVjsPlayer('Failed to reset Video.js player:', error),
});
private controlsBridge: VjsPlayerControlsBridge | null = null;
/** Track ownership for the legacy chrome; null with shared controls. */
private legacyTracks: VjsLegacyTracks | null = null;
/** Keyboard shortcuts for the legacy chrome; null with shared controls. */
private legacyShortcuts: LegacyPlayerShortcuts | null = null;
/** Pointer focus release for the legacy chrome; null with shared controls. */
private detachPointerFocusRelease: (() => void) | null = null;
private desiredSource: VideoPlayerSource | null = null;
private readyHandled = false;
private destroyed = false;
ngOnInit(): void {
this.desiredSource = this.options().sources?.[0] ?? null;
this.seriesNavigationSignal.set(this.seriesNavigation());
if (this.sharedControls) {
this.controlsAdapter.setContext({
seriesNavigation: this.seriesNavigationSignal,
});
}
const vjsOptions = createVjsPlayerOptions(
this.options(),
this.mpegTsSession.isSupportedSource(this.desiredSource?.src),
this.sharedControls
);
this.player = videoJs(
this.target().nativeElement,
vjsOptions,
this.handlePlayerReady
) as unknown as VideoJsPlayer;
this.bindPlayerEvents(true);
initializeVjsPlugins(this.player);
if (!this.sharedControls) {
this.legacyShortcuts = attachVjsLegacyShortcuts({
player: () => this.player ?? null,
hostElement: () => this.playerRoot()?.nativeElement ?? null,
isAvailable: () => this.interactionEnabled(),
isLive: () => this.options().isLive !== false,
});
const shell = this.playerRoot()?.nativeElement;
if (shell) {
this.detachPointerFocusRelease =
attachVjsPointerFocusRelease(shell);
}
}
}
ngOnChanges(changes: SimpleChanges): void {
if (changes['seriesNavigation']) {
this.seriesNavigationSignal.set(this.seriesNavigation());
}
if (changes['options']?.previousValue && this.player) {
const previousOptions = changes['options']
.previousValue as VideoPlayerOptions;
const currentOptions = changes['options']
.currentValue as VideoPlayerOptions;
if (
shouldChangeVjsSource(previousOptions, currentOptions, (url) =>
this.mpegTsSession.isSupportedSource(url)
)
) {
this.changeSource(currentOptions.sources?.[0] ?? null);
} else {
this.controlsBridge?.refreshInputs();
}
}
if (changes['showCaptions']) {
this.controlsBridge?.refreshInputs();
this.legacyTracks?.refreshInputs();
}
if (changes['interactionEnabled']?.currentValue === false) {
exitOwnedVjsFullscreen(
this.sharedControls,
this.fullscreenTarget() ?? this.playerRoot()?.nativeElement,
(error) =>
debugVjsPlayer(
'Failed to exit Video.js player fullscreen:',
error
)
);
}
if (changes['volume']?.currentValue !== undefined && this.player) {
debugVjsPlayer(
'Setting VideoJS player volume to:',
changes['volume'].currentValue
);
this.player.volume(changes['volume'].currentValue);
}
}
ngOnDestroy(): void {
this.destroyed = true;
this.legacyShortcuts?.detach();
this.legacyShortcuts = null;
this.detachPointerFocusRelease?.();
this.detachPointerFocusRelease = null;
this.resetCoordinator.destroy();
this.controlsBridge?.destroy();
this.controlsBridge = null;
this.legacyTracks?.destroy();
this.legacyTracks = null;
this.mpegTsSession.destroy();
this.videoSession.destroy();
if (this.player) {
this.bindPlayerEvents(false);
this.player.dispose();
}
}
private readonly handlePlayerReady = () => {
if (this.destroyed || this.readyHandled) {
return;
}
if (!this.player) {
queueVjsTask(this.handlePlayerReady);
return;
}
this.readyHandled = true;
debugVjsPlayer(
'Setting VideoJS player initial volume to:',
this.volume()
);
this.player.volume(this.volume());
this.initializeLegacyTracks();
const video = this.bindCurrentTechVideo();
if (video && this.resetCoordinator.canApplyReadySource()) {
this.activateSource(this.desiredSource, video);
}
};
private readonly handleLoadedMetadata = () => {
this.bindCurrentTechVideo();
if (this.startTime() > 0) {
this.player.currentTime(this.startTime());
}
this.playbackIssue.emit(null);
this.legacyTracks?.bind();
logVjsAudioTracks(this.player);
setupVjsAudioTrackMenu(this.player);
this.controlsBridge?.refreshInputs();
};
private readonly handleVideoJsError = () => {
const source = this.desiredSource;
const video =
this.videoSession.video() ??
getVideoJsTechVideo(this.player) ??
this.target().nativeElement;
const playerError =
typeof this.player.error === 'function'
? this.player.error()
: null;
const metadata = createPlaybackSourceMetadata({
url: source?.src ?? video.currentSrc ?? '',
mimeType: source?.type,
player: InlinePlaybackPlayer.VideoJs,
});
this.mpegTsSession.syncDuration();
this.playbackIssue.emit(
playerError && hasActiveVhsSourceHandler(this.player)
? classifyVhsPlaybackIssue(playerError, metadata)
: classifyNativePlaybackIssue(
playerError ?? video.error,
metadata
)
);
};
private readonly handleVolumeChange = () => {
if (this.resetCoordinator.shouldSuppressVolumeChange()) {
return;
}
const currentVolume = this.player.volume() ?? this.volume();
localStorage.setItem('volume', currentVolume.toString());
};
private readonly handleTimeUpdate = () => {
this.timeUpdate.emit({
currentTime: this.player.currentTime() ?? 0,
duration: this.player.duration() ?? 0,
});
};
private readonly handlePlayerReset = () => {
this.player.volume(this.resetCoordinator.handlePlayerReset());
this.controlsBridge?.clearSource();
this.legacyTracks?.clear();
this.mpegTsSession.destroy();
this.restoreDesiredSourceAfterReset(true);
};
private readonly handlePauseForReset = () => {
this.resetCoordinator.handlePause();
};
private changeSource(source: VideoPlayerSource | null): void {
this.playbackIssue.emit(null);
this.controlsBridge?.clearSource();
this.legacyTracks?.clear();
this.mpegTsSession.destroy();
this.desiredSource = source;
this.resetCoordinator.clearSourceApplied();
if (!source || this.mpegTsSession.isSupportedSource(source.src)) {
this.resetCoordinator.requestReset();
return;
}
if (this.resetCoordinator.cancelPendingReset()) {
return;
}
this.player.src(source);
this.controlsBridge?.setSource();
this.resetCoordinator.markSourceApplied();
}
private restoreDesiredSourceAfterReset(allowRetry: boolean): void {
if (this.destroyed) {
return;
}
const video = this.bindCurrentTechVideo();
if (!video) {
if (allowRetry) {
queueVjsTask(() => this.restoreDesiredSourceAfterReset(false));
}
return;
}
const source = this.desiredSource;
if (!source) {
this.resetCoordinator.markSourceApplied();
return;
}
if (this.mpegTsSession.isSupportedSource(source.src)) {
this.activateSource(source, video);
return;
}
this.player.src(source);
this.controlsBridge?.setSource();
this.legacyTracks?.bind();
this.resetCoordinator.markSourceApplied();
}
private activateSource(
source: VideoPlayerSource | null,
video: HTMLVideoElement
): void {
if (!source) {
this.resetCoordinator.markSourceApplied();
return;
}
this.controlsBridge?.setSource();
this.legacyTracks?.bind();
if (this.mpegTsSession.isSupportedSource(source.src)) {
debugVjsPlayer('Using mpegts.js for TS stream:', source.src);
this.mpegTsSession.start(source.src, video);
}
this.resetCoordinator.markSourceApplied();
}
private bindCurrentTechVideo(): HTMLVideoElement | null {
const video = getVideoJsTechVideo(this.player);
if (!video) {
return null;
}
this.videoSession.bind(video);
if (this.sharedControls) {
if (!this.controlsBridge) {
this.controlsBridge = new VjsPlayerControlsBridge({
player: this.player,
adapter: this.controlsAdapter,
isLive: () => this.options().isLive !== false,
showCaptions: () => this.showCaptions(),
});
this.controlsBridge.attach(video);
} else {
this.controlsBridge.rebind(video);
}
}
return video;
}
private initializeLegacyTracks(): void {
if (this.sharedControls || this.legacyTracks) {
return;
}
this.legacyTracks = new VjsLegacyTracks({
player: this.player,
showCaptions: () => this.showCaptions(),
});
this.legacyTracks.bind();
}
private bindPlayerEvents(bind: boolean): void {
const events = {
loadedmetadata: this.handleLoadedMetadata,
error: this.handleVideoJsError,
volumechange: this.handleVolumeChange,
timeupdate: this.handleTimeUpdate,
pause: this.handlePauseForReset,
playerreset: this.handlePlayerReset,
};
for (const [event, listener] of Object.entries(events)) {
if (bind) this.player.on(event, listener);
else this.player.off(event, listener);
}
}
}