mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-16 22:26:16 -08:00
feat(html-player): add feature-flagged shared controls (#1194)
* feat(html-player): bridge engine state to shared controls * fix(html-player): avoid HLS subtitle event reentry * fix(html-player): restore delayed HLS default subtitles * refactor(html-player): split controls bridge collaborators * feat(html-player): add feature-flagged shared controls * test(html-player): cover shared-controls source ownership * feat(html-player): pass shared-controls playback metadata * docs(player-controls): describe HTML5 shared-controls bridge * docs(player-controls): clarify HTML5 rollout effect * fix(html-player): reveal diagnostics from fullscreen * fix(html-player): defer HLS event resolution * refactor(html-player): isolate video element session
This commit is contained in:
1 parent
f611ea3d7c
commit
c49ea2f6a8
29 files changed
+4185
-236
No files matched your search
@@ -3,30 +3,41 @@ import {
|
||||
ElementRef,
|
||||
EventEmitter,
|
||||
inject,
|
||||
input,
|
||||
Input,
|
||||
OnChanges,
|
||||
OnDestroy,
|
||||
OnInit,
|
||||
Output,
|
||||
SimpleChanges,
|
||||
viewChild,
|
||||
ViewChild,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import Hls, { type ErrorData, type ManifestParsedData } from 'hls.js';
|
||||
import mpegts from 'mpegts.js';
|
||||
import { DataService } from '@iptvnator/services';
|
||||
import { Channel, createDevLogger } from '@iptvnator/shared/interfaces';
|
||||
import {
|
||||
InlinePlaybackPlayer,
|
||||
PlaybackDiagnostic,
|
||||
classifyHlsPlaybackIssue,
|
||||
classifyMpegTsPlaybackIssue,
|
||||
classifyNativePlaybackIssue,
|
||||
classifyUnsupportedHlsManifestCodecs,
|
||||
createPlaybackSourceMetadata,
|
||||
getPlaybackMediaExtensionFromUrl,
|
||||
} from '../playback-diagnostics/playback-diagnostics.util';
|
||||
import {
|
||||
PlayerControlsComponent,
|
||||
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 { HtmlVideoElementSession } from './html-video-element-session';
|
||||
import {
|
||||
HtmlVideoPlayerControlsBridge,
|
||||
type HtmlVideoControlsSource,
|
||||
} from './html-video-player-controls.bridge';
|
||||
|
||||
const debugHtmlPlayer = createDevLogger('HtmlVideoPlayer');
|
||||
|
||||
@@ -37,7 +48,11 @@ const debugHtmlPlayer = createDevLogger('HtmlVideoPlayer');
|
||||
selector: 'app-html-video-player',
|
||||
templateUrl: './html-video-player.component.html',
|
||||
styleUrls: ['./html-video-player.component.scss'],
|
||||
imports: [SeriesPlaybackNavigationControlsComponent],
|
||||
imports: [
|
||||
PlayerControlsComponent,
|
||||
SeriesPlaybackNavigationControlsComponent,
|
||||
],
|
||||
providers: [WebVideoControlsAdapter],
|
||||
standalone: true,
|
||||
})
|
||||
export class HtmlVideoPlayerComponent implements OnInit, OnChanges, OnDestroy {
|
||||
@@ -46,6 +61,9 @@ export class HtmlVideoPlayerComponent implements OnInit, OnChanges, OnDestroy {
|
||||
@Input() volume = 1;
|
||||
@Input() startTime = 0;
|
||||
@Input() seriesNavigation: SeriesPlaybackNavigation | null = null;
|
||||
readonly isLive = input(true);
|
||||
readonly interactionEnabled = input(true);
|
||||
readonly showCaptions = input(false);
|
||||
@Output() timeUpdate = new EventEmitter<{
|
||||
currentTime: number;
|
||||
duration: number;
|
||||
@@ -55,90 +73,43 @@ export class HtmlVideoPlayerComponent implements OnInit, OnChanges, OnDestroy {
|
||||
@Output() previousEpisodeRequested = new EventEmitter<void>();
|
||||
@Output() nextEpisodeRequested = new EventEmitter<void>();
|
||||
|
||||
private readonly dataService = inject(DataService);
|
||||
readonly sharedControls = inject(WEB_PLAYER_SHARED_CONTROLS);
|
||||
readonly controlsAdapter = inject(WebVideoControlsAdapter);
|
||||
private readonly seriesNavigationSignal =
|
||||
signal<SeriesPlaybackNavigation | null>(null);
|
||||
|
||||
/** Video player DOM element */
|
||||
readonly playerRoot = viewChild<ElementRef<HTMLElement>>('playerRoot');
|
||||
|
||||
@ViewChild('videoPlayer', { static: true })
|
||||
videoPlayer!: ElementRef<HTMLVideoElement>;
|
||||
|
||||
/** HLS object */
|
||||
hls!: Hls;
|
||||
hls: Hls | null = null;
|
||||
/** mpegts.js player for raw MPEG-TS streams */
|
||||
private mpegtsPlayer: mpegts.Player | null = null;
|
||||
|
||||
/** Captions/subtitles indicator */
|
||||
@Input() showCaptions!: boolean;
|
||||
|
||||
private readonly handleNativePlaybackError = () => {
|
||||
const metadata = this.createSourceMetadata(
|
||||
this.channel?.url ?? this.videoPlayer.nativeElement.currentSrc
|
||||
);
|
||||
|
||||
this.playbackIssue.emit(
|
||||
classifyNativePlaybackIssue(
|
||||
this.videoPlayer.nativeElement.error,
|
||||
metadata
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
private readonly clearPlaybackIssue = () => {
|
||||
this.playbackIssue.emit(null);
|
||||
};
|
||||
|
||||
private readonly handleVolumeChange = (): void => {
|
||||
this.onVolumeChange();
|
||||
};
|
||||
|
||||
private readonly handleLoadedMetadata = (): void => {
|
||||
if (this.startTime > 0) {
|
||||
this.videoPlayer.nativeElement.currentTime = this.startTime;
|
||||
}
|
||||
};
|
||||
|
||||
private readonly handleTimeUpdate = (): void => {
|
||||
this.timeUpdate.emit({
|
||||
currentTime: this.videoPlayer.nativeElement.currentTime,
|
||||
duration: this.videoPlayer.nativeElement.duration,
|
||||
});
|
||||
};
|
||||
|
||||
private readonly handlePlaybackEnded = (): void => {
|
||||
this.playbackEnded.emit();
|
||||
};
|
||||
private controlsSource: HtmlVideoControlsSource | null = null;
|
||||
private controlsBridge: HtmlVideoPlayerControlsBridge | null = null;
|
||||
private videoSession: HtmlVideoElementSession | null = null;
|
||||
|
||||
ngOnInit() {
|
||||
this.videoPlayer.nativeElement.addEventListener(
|
||||
'volumechange',
|
||||
this.handleVolumeChange
|
||||
);
|
||||
|
||||
this.videoPlayer.nativeElement.addEventListener(
|
||||
'loadedmetadata',
|
||||
this.handleLoadedMetadata
|
||||
);
|
||||
|
||||
this.videoPlayer.nativeElement.addEventListener(
|
||||
'timeupdate',
|
||||
this.handleTimeUpdate
|
||||
);
|
||||
|
||||
this.videoPlayer.nativeElement.addEventListener(
|
||||
'error',
|
||||
this.handleNativePlaybackError
|
||||
);
|
||||
this.videoPlayer.nativeElement.addEventListener(
|
||||
'loadeddata',
|
||||
this.clearPlaybackIssue
|
||||
);
|
||||
this.videoPlayer.nativeElement.addEventListener(
|
||||
'playing',
|
||||
this.clearPlaybackIssue
|
||||
);
|
||||
this.videoPlayer.nativeElement.addEventListener(
|
||||
'ended',
|
||||
this.handlePlaybackEnded
|
||||
);
|
||||
if (this.sharedControls) {
|
||||
this.seriesNavigationSignal.set(this.seriesNavigation);
|
||||
this.controlsAdapter.setContext({
|
||||
seriesNavigation: this.seriesNavigationSignal,
|
||||
});
|
||||
this.controlsBridge = new HtmlVideoPlayerControlsBridge({
|
||||
video: this.videoPlayer.nativeElement,
|
||||
adapter: this.controlsAdapter,
|
||||
isLive: () => this.isLive(),
|
||||
showCaptions: () => this.showCaptions(),
|
||||
});
|
||||
this.controlsBridge.attach();
|
||||
if (this.controlsSource) {
|
||||
this.controlsBridge.setSource(this.controlsSource);
|
||||
}
|
||||
}
|
||||
this.getVideoSession().attach();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -146,9 +117,18 @@ export class HtmlVideoPlayerComponent implements OnInit, OnChanges, OnDestroy {
|
||||
* @param changes component changes
|
||||
*/
|
||||
ngOnChanges(changes: SimpleChanges): void {
|
||||
if (changes['seriesNavigation']) {
|
||||
this.seriesNavigationSignal.set(this.seriesNavigation);
|
||||
}
|
||||
if (changes['channel'] && changes['channel'].currentValue) {
|
||||
this.playChannel(changes['channel'].currentValue);
|
||||
}
|
||||
if (changes['isLive'] || changes['showCaptions']) {
|
||||
this.controlsBridge?.refreshInputs();
|
||||
}
|
||||
if (changes['interactionEnabled']?.currentValue === false) {
|
||||
this.exitOwnedFullscreen();
|
||||
}
|
||||
if (changes['volume']?.currentValue !== undefined) {
|
||||
debugHtmlPlayer(
|
||||
'Setting HTML5 player volume to:',
|
||||
@@ -159,19 +139,37 @@ export class HtmlVideoPlayerComponent implements OnInit, OnChanges, OnDestroy {
|
||||
}
|
||||
}
|
||||
|
||||
private exitOwnedFullscreen(): void {
|
||||
if (
|
||||
!this.sharedControls ||
|
||||
document.fullscreenElement !== this.playerRoot()?.nativeElement ||
|
||||
typeof document.exitFullscreen !== 'function'
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
void Promise.resolve(document.exitFullscreen()).catch(
|
||||
(error: unknown) => {
|
||||
debugHtmlPlayer(
|
||||
'Failed to exit HTML5 player fullscreen:',
|
||||
error
|
||||
);
|
||||
}
|
||||
);
|
||||
} catch (error: unknown) {
|
||||
debugHtmlPlayer('Failed to exit HTML5 player fullscreen:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts to play the given channel
|
||||
* @param channel given channel object
|
||||
*/
|
||||
playChannel(channel: Channel): void {
|
||||
if (this.mpegtsPlayer) {
|
||||
this.mpegtsPlayer.pause();
|
||||
this.mpegtsPlayer.unload();
|
||||
this.mpegtsPlayer.detachMediaElement();
|
||||
this.mpegtsPlayer.destroy();
|
||||
this.mpegtsPlayer = null;
|
||||
}
|
||||
if (this.hls) this.hls.destroy();
|
||||
this.clearControlsSource();
|
||||
this.destroyMpegtsPlayer();
|
||||
this.destroyHls();
|
||||
this.clearNativeVideoSources(this.videoPlayer.nativeElement);
|
||||
if (channel.url) {
|
||||
this.playbackIssue.emit(null);
|
||||
@@ -199,12 +197,13 @@ export class HtmlVideoPlayerComponent implements OnInit, OnChanges, OnDestroy {
|
||||
);
|
||||
this.mpegtsPlayer = mpegts.createPlayer({
|
||||
type: 'mpegts',
|
||||
isLive: true,
|
||||
isLive: this.isLive(),
|
||||
url: url,
|
||||
});
|
||||
this.mpegtsPlayer.attachMediaElement(
|
||||
this.videoPlayer.nativeElement
|
||||
);
|
||||
this.bindControlsSource({ kind: 'mpegts' });
|
||||
this.mpegtsPlayer.on(
|
||||
mpegts.Events.ERROR,
|
||||
(type: string, details: string, info: unknown): void => {
|
||||
@@ -229,15 +228,17 @@ export class HtmlVideoPlayerComponent implements OnInit, OnChanges, OnDestroy {
|
||||
Hls.isSupported()
|
||||
) {
|
||||
debugHtmlPlayer('Switching channel to:', channel.name, url);
|
||||
this.hls = new Hls();
|
||||
this.hls.on(Hls.Events.MANIFEST_PARSED, (_, data) => {
|
||||
const hls = new Hls();
|
||||
this.hls = hls;
|
||||
hls.on(Hls.Events.MANIFEST_PARSED, (_, data) => {
|
||||
this.handleHlsManifestParsed(url, data);
|
||||
});
|
||||
this.hls.on(Hls.Events.ERROR, (_, data) => {
|
||||
hls.on(Hls.Events.ERROR, (_, data) => {
|
||||
this.handleHlsError(url, data);
|
||||
});
|
||||
this.hls.attachMedia(this.videoPlayer.nativeElement);
|
||||
this.hls.loadSource(url);
|
||||
hls.attachMedia(this.videoPlayer.nativeElement);
|
||||
this.bindControlsSource({ kind: 'hls', hls });
|
||||
hls.loadSource(url);
|
||||
this.handlePlayOperation();
|
||||
} else {
|
||||
debugHtmlPlayer('Using native video player');
|
||||
@@ -266,40 +267,50 @@ export class HtmlVideoPlayerComponent implements OnInit, OnChanges, OnDestroy {
|
||||
source.src = url;
|
||||
source.type = type;
|
||||
element.appendChild(source);
|
||||
this.bindControlsSource({ kind: 'native' });
|
||||
element.load();
|
||||
}
|
||||
|
||||
private bindControlsSource(source: HtmlVideoControlsSource): void {
|
||||
this.controlsSource = source;
|
||||
this.controlsBridge?.setSource(source);
|
||||
}
|
||||
|
||||
private clearControlsSource(): void {
|
||||
this.controlsBridge?.clearSource();
|
||||
this.controlsSource = null;
|
||||
}
|
||||
|
||||
private destroyMpegtsPlayer(): void {
|
||||
const player = this.mpegtsPlayer;
|
||||
this.mpegtsPlayer = null;
|
||||
if (!player) {
|
||||
return;
|
||||
}
|
||||
player.pause();
|
||||
player.unload();
|
||||
player.detachMediaElement();
|
||||
player.destroy();
|
||||
}
|
||||
|
||||
private destroyHls(): void {
|
||||
const hls = this.hls;
|
||||
this.hls = null;
|
||||
hls?.destroy();
|
||||
}
|
||||
|
||||
/**
|
||||
* Disables text based captions based on the global settings
|
||||
*/
|
||||
disableCaptions(): void {
|
||||
for (
|
||||
let i = 0;
|
||||
i < this.videoPlayer.nativeElement.textTracks.length;
|
||||
i++
|
||||
) {
|
||||
this.videoPlayer.nativeElement.textTracks[i].mode = 'hidden';
|
||||
}
|
||||
this.getVideoSession().disableCaptions();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles promise based play operation
|
||||
*/
|
||||
handlePlayOperation(): void {
|
||||
const playPromise = this.videoPlayer.nativeElement.play();
|
||||
|
||||
if (playPromise !== undefined) {
|
||||
playPromise
|
||||
.then(() => {
|
||||
// Automatic playback started!
|
||||
if (!this.showCaptions) {
|
||||
this.disableCaptions();
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// Do nothing
|
||||
});
|
||||
}
|
||||
this.getVideoSession().play();
|
||||
}
|
||||
|
||||
private handleHlsManifestParsed(
|
||||
@@ -356,56 +367,30 @@ export class HtmlVideoPlayerComponent implements OnInit, OnChanges, OnDestroy {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Save volume when user changes it
|
||||
*/
|
||||
onVolumeChange(): void {
|
||||
const currentVolume = this.videoPlayer.nativeElement.volume;
|
||||
debugHtmlPlayer('Volume changed to:', currentVolume);
|
||||
localStorage.setItem('volume', currentVolume.toString());
|
||||
}
|
||||
|
||||
/**
|
||||
* Destroy hls instance on component destroy and clean up event listener
|
||||
*/
|
||||
ngOnDestroy(): void {
|
||||
this.videoPlayer.nativeElement.removeEventListener(
|
||||
'volumechange',
|
||||
this.handleVolumeChange
|
||||
);
|
||||
this.videoPlayer.nativeElement.removeEventListener(
|
||||
'loadedmetadata',
|
||||
this.handleLoadedMetadata
|
||||
);
|
||||
this.videoPlayer.nativeElement.removeEventListener(
|
||||
'timeupdate',
|
||||
this.handleTimeUpdate
|
||||
);
|
||||
this.videoPlayer.nativeElement.removeEventListener(
|
||||
'error',
|
||||
this.handleNativePlaybackError
|
||||
);
|
||||
this.videoPlayer.nativeElement.removeEventListener(
|
||||
'loadeddata',
|
||||
this.clearPlaybackIssue
|
||||
);
|
||||
this.videoPlayer.nativeElement.removeEventListener(
|
||||
'playing',
|
||||
this.clearPlaybackIssue
|
||||
);
|
||||
this.videoPlayer.nativeElement.removeEventListener(
|
||||
'ended',
|
||||
this.handlePlaybackEnded
|
||||
);
|
||||
if (this.mpegtsPlayer) {
|
||||
this.mpegtsPlayer.pause();
|
||||
this.mpegtsPlayer.unload();
|
||||
this.mpegtsPlayer.detachMediaElement();
|
||||
this.mpegtsPlayer.destroy();
|
||||
this.mpegtsPlayer = null;
|
||||
}
|
||||
if (this.hls) {
|
||||
this.hls.destroy();
|
||||
}
|
||||
this.controlsBridge?.destroy();
|
||||
this.controlsBridge = null;
|
||||
this.controlsSource = null;
|
||||
this.videoSession?.destroy();
|
||||
this.videoSession = null;
|
||||
this.destroyMpegtsPlayer();
|
||||
this.destroyHls();
|
||||
}
|
||||
|
||||
private getVideoSession(): HtmlVideoElementSession {
|
||||
this.videoSession ??= new HtmlVideoElementSession({
|
||||
video: this.videoPlayer.nativeElement,
|
||||
getChannelUrl: () => this.channel?.url,
|
||||
getStartTime: () => this.startTime,
|
||||
showCaptions: () => this.showCaptions(),
|
||||
sharedControls: () => this.sharedControls,
|
||||
emitPlaybackIssue: (issue) => this.playbackIssue.emit(issue),
|
||||
emitTimeUpdate: (value) => this.timeUpdate.emit(value),
|
||||
emitPlaybackEnded: () => this.playbackEnded.emit(),
|
||||
});
|
||||
return this.videoSession;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user