diff --git a/apps/web/src/app/home/video-player/video-player.component.html b/apps/web/src/app/home/video-player/video-player.component.html index 5926a7050..7ee04d1c8 100644 --- a/apps/web/src/app/home/video-player/video-player.component.html +++ b/apps/web/src/app/home/video-player/video-player.component.html @@ -36,12 +36,13 @@ src: activeChannel.url + activeChannel.epgParams, - type: 'application/x-mpegURL', + type: getMimeType(activeChannel.url), }, ], }" [volume]="volume" [class.hide-captions]="!playerSettings.showCaptions" + (codecError)="handleCodecError($event)" /> } @@ -58,6 +59,7 @@ [channel]="activeChannel" [volume]="volume" [showCaptions]="playerSettings.showCaptions!" + (codecError)="handleCodecError($event)" /> } @if (playerSettings.player === 'mpv') { diff --git a/apps/web/src/app/home/video-player/video-player.component.ts b/apps/web/src/app/home/video-player/video-player.component.ts index da9cc285e..40acefd32 100644 --- a/apps/web/src/app/home/video-player/video-player.component.ts +++ b/apps/web/src/app/home/video-player/video-player.component.ts @@ -12,12 +12,15 @@ import { } from '@angular/core'; import { MatDialog } from '@angular/material/dialog'; import { MatSidenavModule } from '@angular/material/sidenav'; +import { MatSnackBar } from '@angular/material/snack-bar'; import { ActivatedRoute, Router, RouterLink } from '@angular/router'; import { Store } from '@ngrx/store'; import { StorageMap } from '@ngx-pwa/local-storage'; +import { TranslateService } from '@ngx-translate/core'; import { ArtPlayerComponent, AudioPlayerComponent, + CodecError, COMPONENT_OVERLAY_REF, EpgListComponent, HtmlVideoPlayerComponent, @@ -80,8 +83,10 @@ export class VideoPlayerComponent implements OnInit { private readonly playlistsService = inject(PlaylistsService); private readonly router = inject(Router); private readonly settingsStore = inject(SettingsStore); + private readonly snackBar = inject(MatSnackBar); private readonly storage = inject(StorageMap); private readonly store = inject(Store); + private readonly translateService = inject(TranslateService); /** Active selected channel */ readonly activeChannel$ = this.store @@ -474,4 +479,65 @@ export class VideoPlayerComponent implements OnInit { target instanceof HTMLTextAreaElement ); } + + /** + * Get the MIME type for a stream URL based on its extension + * @param url Stream URL + * @returns MIME type string for Video.js + */ + getMimeType(url: string): string { + const extension = url.split(/[#?]/)[0].split('.').pop()?.toLowerCase(); + switch (extension) { + case 'ts': + return 'video/mp2t'; + case 'm3u8': + return 'application/x-mpegURL'; + case 'mp4': + return 'video/mp4'; + case 'webm': + return 'video/webm'; + case 'mkv': + return 'video/x-matroska'; + case 'flv': + return 'video/x-flv'; + default: + return 'application/x-mpegURL'; + } + } + + /** + * Handle codec error from video players + * Shows a snackbar suggesting to use VLC or MPV for unsupported codecs + */ + handleCodecError(error: CodecError): void { + console.log('handleCodecError received:', error); + const codecName = error.codec || 'unknown'; + const isAudio = error.type === 'unsupported_audio'; + const codecType = isAudio ? 'audio' : 'video'; + + // Show snackbar with helpful message + const message = this.translateService.instant('PLAYER.UNSUPPORTED_CODEC', { + codecType: codecType, + codec: codecName + }) || `Unsupported ${codecType} codec: ${codecName}. Use VLC or MPV for full codec support.`; + + const actionText = this.isDesktop + ? this.translateService.instant('PLAYER.SWITCH_TO_VLC') || 'Switch to VLC' + : this.translateService.instant('CLOSE') || 'Close'; + + const snackBarRef = this.snackBar.open(message, actionText, { + duration: 10000, + horizontalPosition: 'center', + verticalPosition: 'bottom', + panelClass: ['codec-error-snackbar'] + }); + + // If on desktop, offer to switch to VLC + if (this.isDesktop) { + snackBarRef.onAction().subscribe(() => { + // Update player setting to VLC + this.settingsStore.updateSettings({ player: VideoPlayer.VLC }); + }); + } + } } diff --git a/libs/ui/components/src/lib/art-player/art-player.component.ts b/libs/ui/components/src/lib/art-player/art-player.component.ts index 4f6580452..abe231ea2 100644 --- a/libs/ui/components/src/lib/art-player/art-player.component.ts +++ b/libs/ui/components/src/lib/art-player/art-player.component.ts @@ -1,16 +1,30 @@ import { Component, ElementRef, + EventEmitter, inject, Input, OnChanges, OnDestroy, OnInit, + Output, SimpleChanges, } from '@angular/core'; import Artplayer from 'artplayer'; +import * as dashjs from 'dashjs'; +import flvjs from 'flv.js'; import Hls from 'hls.js'; +import mpegts from 'mpegts.js'; import { Channel } from 'shared-interfaces'; +import { CodecError } from '../vjs-player/vjs-player.component'; + +// Extend Artplayer type to include custom player instances +interface ArtplayerExtended extends Artplayer { + hls?: Hls; + mpegts?: mpegts.Player; + flv?: flvjs.Player; + dash?: dashjs.MediaPlayerClass; +} Artplayer.AUTO_PLAYBACK_TIMEOUT = 10000; @@ -36,9 +50,12 @@ export class ArtPlayerComponent implements OnInit, OnDestroy, OnChanges { @Input() channel!: Channel; @Input() volume = 1; @Input() showCaptions = false; + /** Emits when a codec error is detected */ + @Output() codecError = new EventEmitter(); private player!: Artplayer; - private hls: Hls | null = null; + /** Track if we've already shown a codec error for current stream */ + private codecErrorShown = false; private readonly elementRef = inject(ElementRef); @@ -52,34 +69,194 @@ export class ArtPlayerComponent implements OnInit, OnDestroy, OnChanges { ngOnChanges(changes: SimpleChanges): void { if (changes['channel'] && !changes['channel'].firstChange) { + this.codecErrorShown = false; this.destroyPlayer(); this.initPlayer(); } } private destroyPlayer(): void { - if (this.hls) { - this.hls.destroy(); - this.hls = null; - } if (this.player) { this.player.destroy(); } } + /** + * HLS.js handler for .m3u8 streams + */ + private playM3u8 = ( + video: HTMLVideoElement, + url: string, + art: Artplayer + ): void => { + const player = art as ArtplayerExtended; + if (Hls.isSupported()) { + if (player.hls) { + player.hls.destroy(); + } + const hls = new Hls(); + hls.loadSource(url); + hls.attachMedia(video); + player.hls = hls; + art.on('destroy', () => hls.destroy()); + } else if (video.canPlayType('application/vnd.apple.mpegurl')) { + video.src = url; + } else { + art.notice.show = 'Unsupported playback format: m3u8'; + } + }; + + /** + * mpegts.js handler for .ts MPEG-TS streams + */ + private playTs = ( + video: HTMLVideoElement, + url: string, + art: Artplayer + ): void => { + const player = art as ArtplayerExtended; + if (mpegts.isSupported()) { + if (player.mpegts) { + player.mpegts.destroy(); + } + const mpegtsPlayer = mpegts.createPlayer({ + type: 'mpegts', + isLive: true, + url: url, + }); + + // Listen for mpegts.js errors + mpegtsPlayer.on(mpegts.Events.ERROR, (errorType, errorDetail, errorInfo) => { + this.handleMpegtsError(errorType, errorDetail, errorInfo); + }); + + mpegtsPlayer.attachMediaElement(video); + mpegtsPlayer.load(); + player.mpegts = mpegtsPlayer; + art.on('destroy', () => mpegtsPlayer.destroy()); + } else { + art.notice.show = 'Unsupported playback format: ts'; + } + }; + + /** + * flv.js handler for .flv streams + */ + private playFlv = ( + video: HTMLVideoElement, + url: string, + art: Artplayer + ): void => { + const player = art as ArtplayerExtended; + if (flvjs.isSupported()) { + if (player.flv) { + player.flv.destroy(); + } + const flv = flvjs.createPlayer({ type: 'flv', url }); + flv.attachMediaElement(video); + flv.load(); + player.flv = flv; + art.on('destroy', () => flv.destroy()); + } else { + art.notice.show = 'Unsupported playback format: flv'; + } + }; + + /** + * dash.js handler for .mpd MPEG-DASH streams + */ + private playMpd = ( + video: HTMLVideoElement, + url: string, + art: Artplayer + ): void => { + const player = art as ArtplayerExtended; + if (dashjs.supportsMediaSource()) { + if (player.dash) { + player.dash.destroy(); + } + const dash = dashjs.MediaPlayer().create(); + dash.initialize(video, url, art.option.autoplay); + player.dash = dash; + art.on('destroy', () => dash.destroy()); + } else { + art.notice.show = 'Unsupported playback format: mpd'; + } + }; + + /** + * Handle mpegts.js errors and detect codec issues + */ + private handleMpegtsError(errorType: string, errorDetail: string, errorInfo?: any): void { + console.error('mpegts.js error:', errorType, errorDetail, errorInfo); + + // Check for codec-related errors + const errorMessage = errorInfo?.msg || errorInfo?.message || String(errorInfo) || ''; + + // Detect unsupported audio codecs (EC-3, AC-3, etc.) + if (errorMessage.includes('ec-3') || + errorMessage.includes('ac-3') || + errorMessage.includes('audio/mp4') && errorMessage.includes('unsupported')) { + if (!this.codecErrorShown) { + this.codecErrorShown = true; + const codec = this.extractCodecFromError(errorMessage); + this.codecError.emit({ + type: 'unsupported_audio', + codec: codec, + message: `Unsupported audio codec: ${codec}. Use VLC or MPV for full codec support.` + }); + } + } + // Detect unsupported video codecs (HEVC in some browsers, etc.) + else if (errorMessage.includes('hev1') || + errorMessage.includes('hvc1') || + errorMessage.includes('video/mp4') && errorMessage.includes('unsupported')) { + if (!this.codecErrorShown) { + this.codecErrorShown = true; + const codec = this.extractCodecFromError(errorMessage); + this.codecError.emit({ + type: 'unsupported_video', + codec: codec, + message: `Unsupported video codec: ${codec}. Use VLC or MPV for full codec support.` + }); + } + } + } + + /** + * Extract codec name from error message + */ + private extractCodecFromError(errorMessage: string): string { + // Match common codec patterns + const codecPatterns = [ + /codecs=['"]?([a-zA-Z0-9.-]+)['"]?/i, + /(ec-3|ac-3|hev1|hvc1|avc1|mp4a)/i + ]; + + for (const pattern of codecPatterns) { + const match = errorMessage.match(pattern); + if (match) { + return match[1].toUpperCase(); + } + } + return 'unknown'; + } + private initPlayer(): void { const el = this.elementRef.nativeElement.querySelector( '.artplayer-container' ); - const isLive = this.channel?.url?.toLowerCase().includes('m3u8'); + const url = this.channel.url + (this.channel.epgParams || ''); + const videoType = this.getVideoType(this.channel.url); + const isLive = ['m3u8', 'ts', 'flv'].includes(videoType); this.player = new Artplayer({ container: el, - url: this.channel.url + (this.channel.epgParams || ''), + url: url, volume: this.volume, isLive: isLive, autoplay: true, - type: this.getVideoType(this.channel.url), + type: videoType, pip: true, autoPlayback: true, autoSize: true, @@ -96,44 +273,43 @@ export class ArtPlayerComponent implements OnInit, OnDestroy, OnChanges { mutex: true, theme: '#ff0000', 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); - } else if ( - video.canPlayType('application/vnd.apple.mpegurl') - ) { - video.src = url; - } - }, - mkv: function (video: HTMLVideoElement, url: string) { + m3u8: this.playM3u8, + ts: this.playTs, + flv: this.playFlv, + mpd: this.playMpd, + mkv: (video: HTMLVideoElement, url: string) => { video.src = url; - // Add error handling - video.onerror = () => { - console.error('Error loading MKV file:', video.error); - // Fallback to treating it as a regular video - video.src = url; - }; }, }, }); } + /** + * Get the video type based on URL extension + */ private getVideoType(url: string): string { - const extension = url.split('.').pop()?.toLowerCase(); + // Remove query params and hash, then get extension + const cleanUrl = url.split(/[#?]/)[0]; + const extension = cleanUrl.split('.').pop()?.toLowerCase(); + switch (extension) { - case 'mkv': - return 'video/matroska'; // Changed from 'mkv' case 'm3u8': return 'm3u8'; + case 'ts': + return 'ts'; + case 'flv': + return 'flv'; + case 'mpd': + return 'mpd'; + case 'mkv': + return 'mkv'; case 'mp4': return 'mp4'; + case 'webm': + return 'webm'; default: - return 'auto'; + // Default to m3u8 for unknown types (common in IPTV) + return 'm3u8'; } } } diff --git a/libs/ui/components/src/lib/html-video-player/html-video-player.component.ts b/libs/ui/components/src/lib/html-video-player/html-video-player.component.ts index fc3512c68..653be4837 100644 --- a/libs/ui/components/src/lib/html-video-player/html-video-player.component.ts +++ b/libs/ui/components/src/lib/html-video-player/html-video-player.component.ts @@ -10,6 +10,7 @@ import { ViewChild, } from '@angular/core'; import Hls from 'hls.js'; +import mpegts from 'mpegts.js'; import { getExtensionFromUrl } from 'm3u-utils'; import { DataService } from 'services'; import { Channel } from 'shared-interfaces'; @@ -37,6 +38,9 @@ export class HtmlVideoPlayerComponent implements OnInit, OnChanges, OnDestroy { /** HLS object */ hls!: Hls; + /** MPEG-TS player object for raw .ts streams */ + mpegtsPlayer: mpegts.Player | null = null; + /** Captions/subtitles indicator */ @Input() showCaptions!: boolean; @@ -69,7 +73,13 @@ export class HtmlVideoPlayerComponent implements OnInit, OnChanges, OnDestroy { * @param channel given channel object */ playChannel(channel: Channel): void { + // Clean up existing players if (this.hls) this.hls.destroy(); + if (this.mpegtsPlayer) { + this.mpegtsPlayer.destroy(); + this.mpegtsPlayer = null; + } + if (channel.url) { const url = channel.url + (channel.epgParams ?? ''); const extension = getExtensionFromUrl(channel.url); @@ -82,18 +92,37 @@ export class HtmlVideoPlayerComponent implements OnInit, OnChanges, OnDestroy { ); } - if ( + // Use mpegts.js for raw .ts MPEG-TS streams + if (extension === 'ts' && mpegts.isSupported()) { + console.log( + '... switching channel (mpegts.js) to ', + channel.name, + url + ); + this.mpegtsPlayer = mpegts.createPlayer({ + type: 'mpegts', + isLive: true, + url: url, + }); + this.mpegtsPlayer.attachMediaElement( + this.videoPlayer.nativeElement + ); + this.mpegtsPlayer.load(); + this.handlePlayOperation(); + } else if ( extension !== 'mp4' && extension !== 'mpv' && Hls && Hls.isSupported() ) { - console.log('... switching channel to ', channel.name, url); + // Use HLS.js for .m3u8 and other HLS streams + console.log('... switching channel (hls.js) to ', channel.name, url); this.hls = new Hls(); this.hls.attachMedia(this.videoPlayer.nativeElement); this.hls.loadSource(url); this.handlePlayOperation(); } else { + // Use native video player for mp4 and other formats console.log('Using native video player...'); this.addSourceToVideo( this.videoPlayer.nativeElement, @@ -155,7 +184,7 @@ export class HtmlVideoPlayerComponent implements OnInit, OnChanges, OnDestroy { } /** - * Destroy hls instance on component destroy and clean up event listener + * Destroy player instances on component destroy and clean up event listener */ ngOnDestroy(): void { this.videoPlayer.nativeElement.removeEventListener( @@ -165,5 +194,9 @@ export class HtmlVideoPlayerComponent implements OnInit, OnChanges, OnDestroy { if (this.hls) { this.hls.destroy(); } + if (this.mpegtsPlayer) { + this.mpegtsPlayer.destroy(); + this.mpegtsPlayer = null; + } } } diff --git a/libs/ui/components/src/lib/vjs-player/vjs-player.component.ts b/libs/ui/components/src/lib/vjs-player/vjs-player.component.ts index c1db22c9c..37f3b0b78 100644 --- a/libs/ui/components/src/lib/vjs-player/vjs-player.component.ts +++ b/libs/ui/components/src/lib/vjs-player/vjs-player.component.ts @@ -1,18 +1,27 @@ import { Component, ElementRef, + EventEmitter, Input, OnChanges, OnDestroy, OnInit, + Output, SimpleChanges, ViewChild, ViewEncapsulation, } from '@angular/core'; import '@yangkghjh/videojs-aspect-ratio-panel'; +import mpegts from 'mpegts.js'; import videoJs from 'video.js'; import 'videojs-contrib-quality-levels'; -//import 'videojs-hls-quality-selector'; +import 'videojs-quality-selector-hls'; + +export interface CodecError { + type: 'unsupported_audio' | 'unsupported_video' | 'unknown'; + codec?: string; + message: string; +} /** * This component contains the implementation of video player that is based on video.js library @@ -31,17 +40,37 @@ export class VjsPlayerComponent implements OnInit, OnChanges, OnDestroy { @Input() options!: any; /** VideoJs object */ player!: any; + /** mpegts.js player instance for raw .ts streams */ + mpegtsPlayer: mpegts.Player | null = null; @Input() volume = 1; + /** Emits when a codec error is detected */ + @Output() codecError = new EventEmitter(); + /** Track if we've already shown a codec error for current stream */ + private codecErrorShown = false; + /** Track if we've already encountered a fatal error */ + private fatalErrorOccurred = false; + /** Track if the player is being destroyed to prevent race conditions */ + private isDestroying = false; /** * Instantiate Video.js on component init */ ngOnInit(): void { + this.initPlayer(); + } + + /** + * Initialize the player with appropriate handling for different stream types + */ + private initPlayer(): void { + const sourceUrl = this.options?.sources?.[0]?.src || ''; + const isMpegTs = this.isMpegTsStream(sourceUrl); + this.player = videoJs( this.target.nativeElement, { ...this.options, - autoplay: true, + autoplay: !isMpegTs, // Don't autoplay for mpegts, we'll handle it manually }, () => { console.log( @@ -54,12 +83,312 @@ export class VjsPlayerComponent implements OnInit, OnChanges, OnDestroy { const currentVolume = this.player.volume(); localStorage.setItem('volume', currentVolume.toString()); }); + + // Listen for video element errors + this.player.on('error', () => { + this.handleVideoJsError(); + }); + + // Initialize plugins if available (must be inside ready callback) + // Using videojs-quality-selector-hls (Video.js 8+ compatible fork) + try { + if (typeof this.player.qualitySelectorHls === 'function') { + this.player.qualitySelectorHls({ + displayCurrentQuality: true, + }); + } + } catch (e) { + console.warn('qualitySelectorHls plugin failed to initialize:', e); + } + try { + if (typeof this.player['aspectRatioPanel'] === 'function') { + this.player['aspectRatioPanel'](); + } + } catch (e) { + console.warn('aspectRatioPanel plugin failed to initialize:', e); + } + + // If it's an MPEG-TS stream, set up mpegts.js + if (isMpegTs) { + this.setupMpegTs(sourceUrl); + } } ); - this.player.hlsQualitySelector({ - displayCurrentQuality: true, + } + + /** + * Check if the URL is a raw MPEG-TS stream + */ + private isMpegTsStream(url: string): boolean { + const cleanUrl = url.split(/[#?]/)[0]; + const extension = cleanUrl.split('.').pop()?.toLowerCase(); + return extension === 'ts'; + } + + /** + * Detect if a URL is likely a live stream vs VOD + * By default, assume live stream unless we detect VOD patterns + * + * VOD patterns (Xtream/Stalker): + * - /movie/ - Xtream VOD movies + * - /series/ - Xtream series episodes + * - /vod/ - Generic VOD path + * - /films/ - Alternative VOD path + * + * Live patterns are the default for .ts files + */ + private isLikelyLiveStream(url: string): boolean { + const lowerUrl = url.toLowerCase(); + + // Explicit VOD patterns - if these match, it's NOT live + const vodPatterns = [ + /\/movie\//i, // Xtream VOD movies + /\/series\//i, // Xtream series episodes + /\/vod\//i, // Generic VOD path + /\/films?\//i, // Film/Films path + ]; + + // If URL contains VOD patterns, it's NOT a live stream + if (vodPatterns.some(pattern => pattern.test(lowerUrl))) { + return false; + } + + // Otherwise, default to live stream for .ts files + // (live streams are more common for raw .ts) + return true; + } + + /** + * Set up mpegts.js for raw MPEG-TS streams + */ + private setupMpegTs(url: string): void { + if (!mpegts.isSupported()) { + console.warn('mpegts.js is not supported in this browser'); + return; + } + + this.destroyMpegTs(); + this.codecErrorShown = false; + this.fatalErrorOccurred = false; + this.isDestroying = false; + + const videoElement = this.player.tech().el() as HTMLVideoElement; + const isLive = this.isLikelyLiveStream(url); + console.log(`... switching channel (mpegts.js, ${isLive ? 'live' : 'VOD'}) to`, url); + + this.mpegtsPlayer = mpegts.createPlayer( + { + type: 'mpegts', + isLive: isLive, + url: url, + }, + { + // Worker can cause race conditions on destroy, disable for stability + enableWorker: false, + // Stash buffer settings - larger for VOD to buffer more before playing + enableStashBuffer: true, + stashInitialSize: isLive ? 128 * 1024 : 512 * 1024, + // Source buffer cleanup + autoCleanupSourceBuffer: true, + autoCleanupMaxBackwardDuration: isLive ? 30 : 120, + autoCleanupMinBackwardDuration: isLive ? 10 : 60, + // Fix timestamp issues common in VOD .ts files + fixAudioTimestampGap: true, + // For VOD: use range requests for better seeking + seekType: isLive ? 'param' : 'range', + // Disable live-specific features for VOD + liveBufferLatencyChasing: isLive, + liveBufferLatencyMaxLatency: isLive ? 1.5 : 0, + liveBufferLatencyMinRemain: isLive ? 0.5 : 0, + // VOD-specific: lazy loading and deferred load + lazyLoad: !isLive, + lazyLoadMaxDuration: isLive ? 0 : 300, + deferLoadAfterSourceOpen: !isLive, + } + ); + + // Listen for mpegts.js errors + this.mpegtsPlayer.on(mpegts.Events.ERROR, (errorType, errorDetail, errorInfo) => { + this.handleMpegtsError(errorType, errorDetail, errorInfo); }); - this.player['aspectRatioPanel'](); + + this.mpegtsPlayer.attachMediaElement(videoElement); + this.mpegtsPlayer.load(); + + // For VOD, wait for enough data to buffer before playing to avoid StartupStallJumper issues + if (!isLive) { + // Wait for canplay event before starting playback + const onCanPlay = () => { + videoElement.removeEventListener('canplay', onCanPlay); + if (this.mpegtsPlayer && !this.fatalErrorOccurred) { + this.mpegtsPlayer.play(); + } + }; + videoElement.addEventListener('canplay', onCanPlay); + // Fallback: start playing after 3 seconds if canplay hasn't fired + setTimeout(() => { + if (this.mpegtsPlayer && !this.fatalErrorOccurred) { + videoElement.removeEventListener('canplay', onCanPlay); + this.mpegtsPlayer.play(); + } + }, 3000); + } else { + this.mpegtsPlayer.play(); + } + } + + /** + * Handle Video.js errors (from the video element directly) + */ + private handleVideoJsError(): void { + if (this.fatalErrorOccurred || this.isDestroying) { + return; + } + + const error = this.player.error(); + if (!error) return; + + console.error('Video.js error:', error.code, error.message); + + // MEDIA_ERR_DECODE (code 3) - decoding failed + if (error.code === 3 && !this.codecErrorShown) { + console.log('Emitting codec error for MEDIA_ERR_DECODE'); + this.codecErrorShown = true; + this.fatalErrorOccurred = true; + const codecErrorPayload = { + type: 'unknown' as const, + codec: 'MPEG-TS', + message: 'This stream format is not fully supported by the browser. Use VLC or MPV for better compatibility.' + }; + console.log('Codec error payload:', codecErrorPayload); + this.codecError.emit(codecErrorPayload); + // Stop mpegts.js to prevent further errors + this.destroyMpegTs(); + } + } + + /** + * Handle mpegts.js errors and detect codec issues + */ + private handleMpegtsError(errorType: string, errorDetail: string, errorInfo?: any): void { + // Prevent errors during destruction or after fatal error + if (this.fatalErrorOccurred || this.isDestroying) { + return; + } + + console.error('mpegts.js error:', errorType, errorDetail, errorInfo); + + // Check for codec-related errors + const errorMessage = errorInfo?.msg || errorInfo?.message || String(errorInfo) || ''; + + // Detect fatal MSE errors that cause infinite loops + // These occur when the SourceBuffer is in an error state but mpegts.js keeps trying to append + const isFatalMseError = + errorMessage.includes('appendBuffer') || + errorMessage.includes('SourceBuffer') || + errorMessage.includes('HTMLMediaElement.error') || + errorDetail === 'MEDIA_ERROR' || + errorType === 'MediaError'; + + if (isFatalMseError) { + console.warn('mpegts.js: Fatal MSE error detected, stopping playback to prevent error loop'); + this.fatalErrorOccurred = true; + + // Check if this is a decode error (browser can't handle the stream) + if (!this.codecErrorShown && ( + errorMessage.includes('MEDIA_ERR_DECODE') || + errorMessage.includes('corruption') || + errorMessage.includes('features your browser did not support') + )) { + this.codecErrorShown = true; + this.codecError.emit({ + type: 'unknown', + codec: 'MPEG-TS', + message: 'This stream format is not fully supported by the browser. Use VLC or MPV for better compatibility.' + }); + } + + // Destroy the mpegts player to stop further errors + this.destroyMpegTs(); + return; + } + + // Detect unsupported audio codecs (EC-3, AC-3, etc.) + if (errorMessage.includes('ec-3') || + errorMessage.includes('ac-3') || + (errorMessage.includes('audio/mp4') && errorMessage.includes('unsupported'))) { + if (!this.codecErrorShown) { + this.codecErrorShown = true; + this.fatalErrorOccurred = true; + const codec = this.extractCodecFromError(errorMessage); + this.codecError.emit({ + type: 'unsupported_audio', + codec: codec, + message: `Unsupported audio codec: ${codec}. Use VLC or MPV for full codec support.` + }); + // Stop playback to prevent error loop + this.destroyMpegTs(); + } + } + // Detect unsupported video codecs (HEVC in some browsers, etc.) + else if (errorMessage.includes('hev1') || + errorMessage.includes('hvc1') || + (errorMessage.includes('video/mp4') && errorMessage.includes('unsupported'))) { + if (!this.codecErrorShown) { + this.codecErrorShown = true; + this.fatalErrorOccurred = true; + const codec = this.extractCodecFromError(errorMessage); + this.codecError.emit({ + type: 'unsupported_video', + codec: codec, + message: `Unsupported video codec: ${codec}. Use VLC or MPV for full codec support.` + }); + // Stop playback to prevent error loop + this.destroyMpegTs(); + } + } + } + + /** + * Extract codec name from error message + */ + private extractCodecFromError(errorMessage: string): string { + // Match common codec patterns + const codecPatterns = [ + /codecs=['"]?([a-zA-Z0-9.-]+)['"]?/i, + /(ec-3|ac-3|hev1|hvc1|avc1|mp4a)/i + ]; + + for (const pattern of codecPatterns) { + const match = errorMessage.match(pattern); + if (match) { + return match[1].toUpperCase(); + } + } + return 'unknown'; + } + + /** + * Destroy mpegts.js player instance + */ + private destroyMpegTs(): void { + if (this.mpegtsPlayer) { + // Set flag first to prevent any error handlers from running + this.isDestroying = true; + try { + // Remove all event listeners first + this.mpegtsPlayer.off(mpegts.Events.ERROR, this.handleMpegtsError); + this.mpegtsPlayer.pause(); + this.mpegtsPlayer.unload(); + this.mpegtsPlayer.detachMediaElement(); + this.mpegtsPlayer.destroy(); + } catch (e) { + // Ignore errors during cleanup + console.warn('mpegts.js cleanup warning:', e); + } + this.mpegtsPlayer = null; + this.isDestroying = false; + } } /** @@ -67,8 +396,24 @@ export class VjsPlayerComponent implements OnInit, OnChanges, OnDestroy { * @param changes contains changed channel object */ ngOnChanges(changes: SimpleChanges): void { - if (changes['options'].previousValue) { - this.player.src(changes['options'].currentValue.sources[0]); + if (changes['options']?.previousValue && this.player) { + const newSource = changes['options'].currentValue.sources[0]; + const newUrl = newSource?.src || ''; + const isMpegTs = this.isMpegTsStream(newUrl); + + // Reset codec error state for new stream + this.codecErrorShown = false; + + // Clean up existing mpegts player if switching sources + this.destroyMpegTs(); + + if (isMpegTs) { + // For MPEG-TS, set up mpegts.js + this.setupMpegTs(newUrl); + } else { + // For other formats, use Video.js native handling + this.player.src(newSource); + } } if (changes['volume']?.currentValue !== undefined && this.player) { console.log( @@ -83,6 +428,7 @@ export class VjsPlayerComponent implements OnInit, OnChanges, OnDestroy { * Removes the players HTML reference on destroy */ ngOnDestroy(): void { + this.destroyMpegTs(); if (this.player) { this.player.dispose(); }