import { Component, ElementRef, EventEmitter, inject, Input, OnChanges, OnDestroy, OnInit, Output, SimpleChanges, } from '@angular/core'; import Artplayer from 'artplayer'; import Hls from 'hls.js'; import { getExtensionFromUrl } from 'm3u-utils'; import mpegts from 'mpegts.js'; import { Channel } from 'shared-interfaces'; type AudioTrackSelector = { html: string | HTMLElement; default?: boolean; }; Artplayer.AUTO_PLAYBACK_TIMEOUT = 10000; @Component({ selector: 'app-art-player', imports: [], template: `
`, styles: [ ` :host { display: block; width: 100%; height: 100%; } .artplayer-container { width: 100%; height: 100%; } `, ], }) export class ArtPlayerComponent implements OnInit, OnDestroy, OnChanges { @Input() channel!: Channel; @Input() volume = 1; @Input() showCaptions = false; @Input() startTime = 0; @Output() timeUpdate = new EventEmitter<{ currentTime: number; duration: number; }>(); private player!: Artplayer; private hls: Hls | null = null; private mpegtsPlayer: mpegts.Player | null = null; private readonly elementRef = inject(ElementRef); ngOnInit(): void { this.initPlayer(); } ngOnDestroy(): void { this.destroyPlayer(); } ngOnChanges(changes: SimpleChanges): void { if (changes['channel'] && !changes['channel'].firstChange) { this.destroyPlayer(); this.initPlayer(); } } private destroyPlayer(): 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.hls = null; } if (this.player) { this.player.destroy(); } } private initPlayer(): void { const el = this.elementRef.nativeElement.querySelector( '.artplayer-container' ); const extension = getExtensionFromUrl(this.channel?.url ?? ''); const isLive = extension === 'm3u8' || extension === 'ts'; this.player = new Artplayer({ container: el, url: this.channel.url + (this.channel.epgParams || ''), volume: this.volume, isLive: isLive, autoplay: true, type: this.getVideoType(this.channel.url), pip: true, autoPlayback: true, autoSize: true, autoMini: true, screenshot: true, setting: true, playbackRate: true, aspectRatio: true, fullscreen: true, fullscreenWeb: true, playsInline: true, airplay: true, backdrop: true, mutex: true, theme: '#6366f1', customType: { m3u8: (video: HTMLVideoElement, url: string) => { if (Hls.isSupported()) { if (this.hls) { this.hls.destroy(); } this.hls = new Hls(); this.hls.loadSource(url); this.hls.attachMedia(video); this.setupHlsAudioTracks(); } else if ( video.canPlayType('application/vnd.apple.mpegurl') ) { video.src = url; } }, ts: (video: HTMLVideoElement, url: string) => { if (mpegts.isSupported()) { if (this.mpegtsPlayer) { this.mpegtsPlayer.destroy(); } this.mpegtsPlayer = mpegts.createPlayer({ type: 'mpegts', isLive: true, url: url, }); this.mpegtsPlayer.attachMediaElement(video); this.mpegtsPlayer.load(); this.mpegtsPlayer.play(); } }, mkv: function (video: HTMLVideoElement, url: string) { video.src = url; video.onerror = () => { console.error('Error loading MKV file:', video.error); video.src = url; }; }, }, }); if (this.startTime > 0) { this.player.on('ready', () => { this.player.seek = this.startTime; }); } this.player.on('video:timeupdate', () => { this.timeUpdate.emit({ currentTime: this.player.currentTime, duration: this.player.duration, }); }); } /** * Listens for HLS.js audio tracks and adds a settings menu entry * to ArtPlayer for switching between available audio tracks. */ private setupHlsAudioTracks(): void { if (!this.hls) return; const hls = this.hls; hls.on(Hls.Events.AUDIO_TRACKS_UPDATED, () => { const tracks = hls.audioTracks; if (!tracks || tracks.length <= 1) return; const audioTrackSetting = { html: 'Audio', icon: ``, width: 220, tooltip: tracks[hls.audioTrack]?.name || '', selector: tracks.map((track, index) => ({ html: track.name || track.lang || `Track ${index + 1}`, default: index === hls.audioTrack, })), onSelect: function (this: Artplayer, item: AudioTrackSelector) { const selectedLabel = typeof item.html === 'string' ? item.html : item.html.textContent ?? ''; const selectedIndex = tracks.findIndex( (t, i) => (t.name || t.lang || `Track ${i + 1}`) === selectedLabel ); if (selectedIndex >= 0) { hls.audioTrack = selectedIndex; } }, }; this.player.setting.add(audioTrackSetting); }); } private getVideoType(url: string): string { const extension = getExtensionFromUrl(url); switch (extension) { case 'mkv': return 'video/matroska'; case 'm3u8': return 'm3u8'; case 'mp4': return 'mp4'; case 'ts': return 'ts'; default: return 'auto'; } } }