mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
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:
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();
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user