feat(player): detect codec errors and support mpegts streams

Add codec error detection and reporting to the Video.js player component
and propagate codec error events to parent components. Introduce a
codecError event emitter with a CodecError interface and logic to avoid
duplicated or noisy error reporting. Improve player initialization to
handle raw MPEG-TS streams via mpegts.js: detect mpegts URLs, prevent
autoplay for those streams, and initialize a dedicated mpegts player
instance. Wire codec error output up in template so host components can
handle codec failures.

Also:
- Replace hardcoded HLS MIME type with runtime getMimeType() in templates.
- Swap to videojs-quality-selector-hls plugin and add safe init of plugins.
- Add safeguards for player destruction and more robust error handling
  (track fatal errors, prevent races).
- Persist volume changes and listen to video element errors to surface
  codec problems.

These changes enable better handling of unsupported audio/video codecs,
support raw .ts streams, and allow UI-level responses to codec failures.
This commit is contained in:
4gray committed 2026-01-07 23:52:46 +01:00
1 parent 80a2649a8b
commit aaabe6023a
5 files changed
+667 -44

No files matched your search

@@ -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)"
/>
}
<!-- default html player component -->
@@ -58,6 +59,7 @@
[channel]="activeChannel"
[volume]="volume"
[showCaptions]="playerSettings.showCaptions!"
(codecError)="handleCodecError($event)"
/>
}
@if (playerSettings.player === 'mpv') {
@@ -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 });
});
}
}
}
@@ -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<CodecError>();
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';
}
}
}
@@ -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;
}
}
}
@@ -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<CodecError>();
/** 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();
}