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

244 lines
8.7 KiB
TypeScript

import {
Component,
ElementRef,
inject,
input,
OnChanges,
OnDestroy,
OnInit,
output,
signal,
SimpleChanges,
viewChild,
} from '@angular/core';
import Artplayer from 'artplayer';
import { Channel, createDevLogger } from '@iptvnator/shared/interfaces';
import { releaseVideoPictureInPicture } from '../player-controls/web-video-picture-in-picture-lifecycle';
import type { PlaybackDiagnostic } 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 { attachArtPlayerLegacyShortcuts } from './art-player-legacy-shortcuts';
import {
buildArtPlayerChrome,
exitOwnedArtPlayerFullscreen,
getArtPlayerVideoType,
resolveArtPlayerIsLive,
} from './art-player-setup';
import { ArtPlayerSourceSession } from './art-player-source-session';
import { ArtPlayerVideoSession } from './art-player-video-session';
const debugArtPlayer = createDevLogger('ArtPlayer');
Artplayer.AUTO_PLAYBACK_TIMEOUT = 10000;
@Component({
selector: 'app-art-player',
imports: [
PlayerControlsComponent,
SeriesPlaybackNavigationControlsComponent,
],
providers: [WebVideoControlsAdapter],
templateUrl: './art-player.component.html',
styleUrls: ['./art-player.component.scss'],
})
export class ArtPlayerComponent implements OnInit, OnDestroy, OnChanges {
readonly channel = input.required<Channel>();
readonly volume = input(1);
readonly showCaptions = input(false);
readonly startTime = input(0);
readonly seriesNavigation = input<SeriesPlaybackNavigation | null>(null);
readonly isLive = input(true);
readonly interactionEnabled = input(true);
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);
readonly playerRoot = viewChild<ElementRef<HTMLElement>>('playerRoot');
private readonly artplayerContainer =
viewChild.required<ElementRef<HTMLDivElement>>('artplayer');
private readonly seriesNavigationSignal =
signal<SeriesPlaybackNavigation | null>(null);
private player: Artplayer | null = null;
private sourceSession: ArtPlayerSourceSession | null = null;
private videoSession: ArtPlayerVideoSession | null = null;
private legacyShortcuts: LegacyPlayerShortcuts | null = null;
ngOnInit(): void {
this.seriesNavigationSignal.set(this.seriesNavigation());
if (this.sharedControls) {
this.controlsAdapter.setContext({
seriesNavigation: this.seriesNavigationSignal,
});
} else {
// Survives the channel-change destroy/init cycle: the handlers
// read the current player lazily.
this.legacyShortcuts = attachArtPlayerLegacyShortcuts({
player: () => this.player,
hostElement: () => this.playerRoot()?.nativeElement ?? null,
isAvailable: () => this.interactionEnabled(),
isLive: () => this.isLive(),
});
}
this.initPlayer();
}
ngOnChanges(changes: SimpleChanges): void {
if (changes['seriesNavigation']) {
this.seriesNavigationSignal.set(this.seriesNavigation());
}
const channelChanged =
changes['channel'] && !changes['channel'].firstChange;
const authoritativeLiveChanged =
this.sharedControls &&
changes['isLive'] &&
!changes['isLive'].firstChange &&
changes['isLive'].previousValue !== changes['isLive'].currentValue;
if (this.player && (channelChanged || authoritativeLiveChanged)) {
this.destroyPlayer();
this.initPlayer();
}
if (changes['showCaptions']) {
this.sourceSession?.refreshInputs();
}
if (changes['interactionEnabled']?.currentValue === false) {
exitOwnedArtPlayerFullscreen(
this.sharedControls,
this.fullscreenTarget() ?? this.playerRoot()?.nativeElement,
(error) =>
debugArtPlayer(
'Failed to exit ArtPlayer fullscreen:',
error
)
);
}
if (changes['volume']?.currentValue !== undefined && this.player) {
this.applyVolume(changes['volume'].currentValue);
}
}
ngOnDestroy(): void {
this.legacyShortcuts?.detach();
this.legacyShortcuts = null;
this.destroyPlayer();
}
private initPlayer(): void {
this.playbackIssue.emit(null);
const channel = this.channel();
const sourceUrl = channel.url + (channel.epgParams ?? '');
const sourceSession = new ArtPlayerSourceSession({
sharedControls: this.sharedControls,
controlsAdapter: this.controlsAdapter,
isLive: () => this.isLive(),
showCaptions: () => this.showCaptions(),
emitPlaybackIssue: (issue) => this.playbackIssue.emit(issue),
getDrm: () => this.channel().drm,
});
this.sourceSession = sourceSession;
const player = new Artplayer({
container: this.artplayerContainer().nativeElement,
url: sourceUrl,
volume: this.clampVolume(this.volume()),
isLive: resolveArtPlayerIsLive(
this.sharedControls,
this.isLive(),
channel.url
),
autoplay: true,
type: getArtPlayerVideoType(channel.url),
playsInline: true,
backdrop: true,
mutex: true,
theme: '#ff0000',
...buildArtPlayerChrome(this.sharedControls),
customType: sourceSession.customType,
});
this.player = player;
sourceSession.attach(player);
const videoSession = new ArtPlayerVideoSession({
player,
sourceUrl: channel.url,
getStartTime: () => this.startTime(),
getDuration: () => sourceSession.resolveDuration(player.duration),
persistSharedVolume: this.sharedControls,
emitPlaybackIssue: (issue) => this.playbackIssue.emit(issue),
emitTimeUpdate: (value) => this.timeUpdate.emit(value),
emitPlaybackEnded: () => this.playbackEnded.emit(),
emitPlaybackStarted: () => this.playbackStarted.emit(),
});
this.videoSession = videoSession;
videoSession.attach();
// ArtPlayer synchronously restores `artplayer_settings.volume` after
// applying the constructor option. Shared controls use the app-wide
// volume as authoritative, so reapply it directly to the media element.
if (this.sharedControls) {
this.applyVolume(this.volume());
}
}
private destroyPlayer(): void {
if (!this.sharedControls) {
releaseVideoPictureInPicture(this.player?.video);
}
const sourceSession = this.sourceSession;
this.sourceSession = null;
sourceSession?.destroy();
const videoSession = this.videoSession;
this.videoSession = null;
videoSession?.destroy();
const player = this.player;
this.player = null;
player?.destroy();
}
private applyVolume(value: number): void {
const player = this.player;
if (!player) {
return;
}
const volume = this.clampVolume(value);
if (this.sharedControls) {
player.video.volume = volume;
player.video.muted = volume <= 0;
} else {
player.volume = volume;
}
}
private clampVolume(value: number): number {
const numericValue = Number(value);
if (!Number.isFinite(numericValue)) {
return 1;
}
return Math.max(0, Math.min(1, numericValue));
}
}