Files
iptvnator/libs/ui/playback/src/lib/html-video-player/html-video-player.component.ts
T
4grayandClaude Fable 5 6dc8a10d52 feat(playback): show media title overlay in fullscreen shared controls (#1228)
* feat(playback): show media title overlay in fullscreen shared controls

In fullscreen with the shared player-controls layer, a pointer-transparent
overlay at the top of the player now names the content while controls are
revealed: one line for movies and live channels, two lines for series
(series name + S01E03 label). The overlay follows the bar's auto-hide
transition and stays hidden outside fullscreen, where page chrome already
names the content.

Data flows top-down: the Xtream/Stalker series detail views pass the series
name via a new PortalInlinePlayerComponent.seriesTitle input (Xtream episode
playback titles carry the episode name, not the series), the inline player
builds the two-line form from the episode metadata label, and
WebPlayerViewComponent falls back to playback.title for movies/channels
while skipping raw stream-URL fallbacks. All four shared-controls hosts
(HTML5, Video.js, ArtPlayer, Embedded MPV frame-copy) forward the value.

To stay under the max-lines limit, scrub/timeline state is extracted into
ControlsTimeline and the playback-diagnostics display helpers into
web-player-view-diagnostics.utils.ts, with behavior unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs: mirror fullscreen media-title contract into AGENTS.md

Addresses Codex review: the Shared Player Controls section in AGENTS.md
must stay in sync with the CLAUDE.md description of the new mediaTitle
input, fullscreen overlay behavior, and series-title wiring.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 18:41:52 +02:00

399 lines
13 KiB
TypeScript

import {
Component,
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 { Channel, createDevLogger } from '@iptvnator/shared/interfaces';
import {
InlinePlaybackPlayer,
PlaybackDiagnostic,
classifyHlsPlaybackIssue,
classifyMpegTsPlaybackIssue,
classifyUnsupportedHlsManifestCodecs,
createPlaybackSourceMetadata,
getPlaybackMediaExtensionFromUrl,
} from '../playback-diagnostics/playback-diagnostics.util';
import {
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 { HtmlVideoElementSession } from './html-video-element-session';
import {
HtmlVideoPlayerControlsBridge,
type HtmlVideoControlsSource,
} from './html-video-player-controls.bridge';
const debugHtmlPlayer = createDevLogger('HtmlVideoPlayer');
/**
* This component contains the implementation of HTML5 based video player
*/
@Component({
selector: 'app-html-video-player',
templateUrl: './html-video-player.component.html',
styleUrls: ['./html-video-player.component.scss'],
imports: [
PlayerControlsComponent,
SeriesPlaybackNavigationControlsComponent,
],
providers: [WebVideoControlsAdapter],
standalone: true,
})
export class HtmlVideoPlayerComponent implements OnInit, OnChanges, OnDestroy {
/** Channel to play */
@Input() channel!: Channel;
@Input() volume = 1;
@Input() startTime = 0;
@Input() seriesNavigation: SeriesPlaybackNavigation | null = null;
readonly isLive = input(true);
readonly interactionEnabled = input(true);
readonly showCaptions = input(false);
readonly mediaTitle = input<PlayerMediaTitle | null>(null);
@Output() timeUpdate = new EventEmitter<{
currentTime: number;
duration: number;
}>();
@Output() playbackIssue = new EventEmitter<PlaybackDiagnostic | null>();
@Output() playbackEnded = new EventEmitter<void>();
@Output() previousEpisodeRequested = new EventEmitter<void>();
@Output() nextEpisodeRequested = new EventEmitter<void>();
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 | null = null;
/** mpegts.js player for raw MPEG-TS streams */
private mpegtsPlayer: mpegts.Player | null = null;
private controlsSource: HtmlVideoControlsSource | null = null;
private controlsBridge: HtmlVideoPlayerControlsBridge | null = null;
private videoSession: HtmlVideoElementSession | null = null;
ngOnInit() {
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();
}
/**
* Listen for component input changes
* @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:',
changes['volume'].currentValue
);
this.videoPlayer.nativeElement.volume =
changes['volume'].currentValue;
}
}
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 {
this.clearControlsSource();
this.destroyMpegtsPlayer();
this.destroyHls();
this.clearNativeVideoSources(this.videoPlayer.nativeElement);
if (channel.url) {
this.playbackIssue.emit(null);
const url = channel.url + (channel.epgParams ?? '');
const extension = getPlaybackMediaExtensionFromUrl(channel.url);
void window.electron
?.setUserAgent(
channel.http?.['user-agent'],
channel.http?.referrer,
channel.url
)
.catch((error: unknown) => {
console.warn(
'[HtmlVideoPlayer] Failed to configure Electron request headers:',
error
);
});
if ((extension === 'ts' || !extension) && mpegts.isSupported()) {
debugHtmlPlayer(
'Using mpegts.js for TS stream:',
channel.name,
url
);
this.mpegtsPlayer = mpegts.createPlayer({
type: 'mpegts',
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 => {
this.playbackIssue.emit(
classifyMpegTsPlaybackIssue(
{
type,
details,
info,
},
this.createSourceMetadata(url, 'video/mp2t')
)
);
}
);
this.mpegtsPlayer.load();
this.handlePlayOperation();
} else if (
extension !== 'mp4' &&
extension !== 'mpv' &&
Hls &&
Hls.isSupported()
) {
debugHtmlPlayer('Switching channel to:', channel.name, url);
const hls = new Hls();
this.hls = hls;
hls.on(Hls.Events.MANIFEST_PARSED, (_, data) => {
this.handleHlsManifestParsed(url, data);
});
hls.on(Hls.Events.ERROR, (_, data) => {
this.handleHlsError(url, data);
});
hls.attachMedia(this.videoPlayer.nativeElement);
this.bindControlsSource({ kind: 'hls', hls });
hls.loadSource(url);
this.handlePlayOperation();
} else {
debugHtmlPlayer('Using native video player');
this.replaceNativeVideoSource(
this.videoPlayer.nativeElement,
url,
'video/mp4'
);
this.handlePlayOperation();
}
}
}
private clearNativeVideoSources(element: HTMLVideoElement): void {
element.removeAttribute('src');
element.replaceChildren();
}
private replaceNativeVideoSource(
element: HTMLVideoElement,
url: string,
type: string
): void {
this.clearNativeVideoSources(element);
const source = document.createElement('source');
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 {
this.getVideoSession().disableCaptions();
}
/**
* Handles promise based play operation
*/
handlePlayOperation(): void {
this.getVideoSession().play();
}
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);
if (issue) {
this.playbackIssue.emit(issue);
}
}
private handleHlsError(url: string, data: ErrorData): void {
if (!data.fatal) {
return;
}
this.playbackIssue.emit(
classifyHlsPlaybackIssue(
{
type: data.type,
details: data.details,
fatal: data.fatal,
message: data.error?.message,
error: data.error,
},
this.createSourceMetadata(url, 'application/x-mpegURL')
)
);
}
private createSourceMetadata(
url: string,
mimeType?: string,
audioCodecs: readonly string[] = [],
videoCodecs: readonly string[] = []
) {
return createPlaybackSourceMetadata({
url,
mimeType,
player: InlinePlaybackPlayer.Html5,
audioCodecs,
videoCodecs,
});
}
/**
* Destroy hls instance on component destroy and clean up event listener
*/
ngOnDestroy(): void {
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;
}
}