feat(playback): expose browser-player diagnostic details

Entire-Checkpoint: f957cd9849e0
This commit is contained in:
4gray committed 2026-05-14 22:11:05 +02:00
1 parent 96a010b166
commit da04f4c9e9
29 files changed
+696 -65

No files matched your search

+13 -1
View File
@@ -391,7 +391,19 @@
},
"ACTION_OPEN_MPV": "Open in MPV",
"ACTION_OPEN_VLC": "Open in VLC",
"ACTION_COPY_URL": "Copy URL"
"ACTION_COPY_URL": "Copy URL",
"DETAILS_SUMMARY": "Technical details",
"CODEC_HINT": "Detected {{codecs}}. Chromium/Electron browser players often have limited support for these codecs.",
"DETAIL_CODE": "Diagnostic code",
"DETAIL_PLAYER": "Player",
"DETAIL_SOURCE": "Source",
"DETAIL_CONTAINER": "Container",
"DETAIL_MIME_TYPE": "MIME type",
"DETAIL_VIDEO_CODECS": "Video codecs",
"DETAIL_AUDIO_CODECS": "Audio codecs",
"DETAIL_NATIVE_ERROR_CODE": "Native error code",
"DETAIL_NATIVE_ERROR_MESSAGE": "Native error message",
"DETAIL_ERROR_DETAILS": "Error details"
},
"LAYOUT": {
"HIDE_CHANNELS_LIST": "إخفاء قائمة القنوات",
+13 -1
View File
@@ -391,7 +391,19 @@
},
"ACTION_OPEN_MPV": "Open in MPV",
"ACTION_OPEN_VLC": "Open in VLC",
"ACTION_COPY_URL": "Copy URL"
"ACTION_COPY_URL": "Copy URL",
"DETAILS_SUMMARY": "Technical details",
"CODEC_HINT": "Detected {{codecs}}. Chromium/Electron browser players often have limited support for these codecs.",
"DETAIL_CODE": "Diagnostic code",
"DETAIL_PLAYER": "Player",
"DETAIL_SOURCE": "Source",
"DETAIL_CONTAINER": "Container",
"DETAIL_MIME_TYPE": "MIME type",
"DETAIL_VIDEO_CODECS": "Video codecs",
"DETAIL_AUDIO_CODECS": "Audio codecs",
"DETAIL_NATIVE_ERROR_CODE": "Native error code",
"DETAIL_NATIVE_ERROR_MESSAGE": "Native error message",
"DETAIL_ERROR_DETAILS": "Error details"
},
"LAYOUT": {
"HIDE_CHANNELS_LIST": "إخفاء قائمة القنوات",
+13 -1
View File
@@ -391,7 +391,19 @@
},
"ACTION_OPEN_MPV": "Open in MPV",
"ACTION_OPEN_VLC": "Open in VLC",
"ACTION_COPY_URL": "Copy URL"
"ACTION_COPY_URL": "Copy URL",
"DETAILS_SUMMARY": "Technical details",
"CODEC_HINT": "Detected {{codecs}}. Chromium/Electron browser players often have limited support for these codecs.",
"DETAIL_CODE": "Diagnostic code",
"DETAIL_PLAYER": "Player",
"DETAIL_SOURCE": "Source",
"DETAIL_CONTAINER": "Container",
"DETAIL_MIME_TYPE": "MIME type",
"DETAIL_VIDEO_CODECS": "Video codecs",
"DETAIL_AUDIO_CODECS": "Audio codecs",
"DETAIL_NATIVE_ERROR_CODE": "Native error code",
"DETAIL_NATIVE_ERROR_MESSAGE": "Native error message",
"DETAIL_ERROR_DETAILS": "Error details"
},
"LAYOUT": {
"HIDE_CHANNELS_LIST": "Схаваць спіс каналаў",
+13 -1
View File
@@ -391,7 +391,19 @@
},
"ACTION_OPEN_MPV": "Open in MPV",
"ACTION_OPEN_VLC": "Open in VLC",
"ACTION_COPY_URL": "Copy URL"
"ACTION_COPY_URL": "Copy URL",
"DETAILS_SUMMARY": "Technical details",
"CODEC_HINT": "Detected {{codecs}}. Chromium/Electron browser players often have limited support for these codecs.",
"DETAIL_CODE": "Diagnostic code",
"DETAIL_PLAYER": "Player",
"DETAIL_SOURCE": "Source",
"DETAIL_CONTAINER": "Container",
"DETAIL_MIME_TYPE": "MIME type",
"DETAIL_VIDEO_CODECS": "Video codecs",
"DETAIL_AUDIO_CODECS": "Audio codecs",
"DETAIL_NATIVE_ERROR_CODE": "Native error code",
"DETAIL_NATIVE_ERROR_MESSAGE": "Native error message",
"DETAIL_ERROR_DETAILS": "Error details"
},
"LAYOUT": {
"HIDE_CHANNELS_LIST": "Senderliste ausblenden",
+13 -1
View File
@@ -391,7 +391,19 @@
},
"ACTION_OPEN_MPV": "Open in MPV",
"ACTION_OPEN_VLC": "Open in VLC",
"ACTION_COPY_URL": "Copy URL"
"ACTION_COPY_URL": "Copy URL",
"DETAILS_SUMMARY": "Technical details",
"CODEC_HINT": "Detected {{codecs}}. Chromium/Electron browser players often have limited support for these codecs.",
"DETAIL_CODE": "Diagnostic code",
"DETAIL_PLAYER": "Player",
"DETAIL_SOURCE": "Source",
"DETAIL_CONTAINER": "Container",
"DETAIL_MIME_TYPE": "MIME type",
"DETAIL_VIDEO_CODECS": "Video codecs",
"DETAIL_AUDIO_CODECS": "Audio codecs",
"DETAIL_NATIVE_ERROR_CODE": "Native error code",
"DETAIL_NATIVE_ERROR_MESSAGE": "Native error message",
"DETAIL_ERROR_DETAILS": "Error details"
},
"LAYOUT": {
"HIDE_CHANNELS_LIST": "Απόκρυψη λίστας καναλιών",
+13 -1
View File
@@ -397,7 +397,19 @@
},
"ACTION_OPEN_MPV": "Open in MPV",
"ACTION_OPEN_VLC": "Open in VLC",
"ACTION_COPY_URL": "Copy URL"
"ACTION_COPY_URL": "Copy URL",
"DETAILS_SUMMARY": "Technical details",
"CODEC_HINT": "Detected {{codecs}}. Chromium/Electron browser players often have limited support for these codecs.",
"DETAIL_CODE": "Diagnostic code",
"DETAIL_PLAYER": "Player",
"DETAIL_SOURCE": "Source",
"DETAIL_CONTAINER": "Container",
"DETAIL_MIME_TYPE": "MIME type",
"DETAIL_VIDEO_CODECS": "Video codecs",
"DETAIL_AUDIO_CODECS": "Audio codecs",
"DETAIL_NATIVE_ERROR_CODE": "Native error code",
"DETAIL_NATIVE_ERROR_MESSAGE": "Native error message",
"DETAIL_ERROR_DETAILS": "Error details"
},
"LAYOUT": {
"HIDE_CHANNELS_LIST": "Hide channels list",
+13 -1
View File
@@ -391,7 +391,19 @@
},
"ACTION_OPEN_MPV": "Open in MPV",
"ACTION_OPEN_VLC": "Open in VLC",
"ACTION_COPY_URL": "Copy URL"
"ACTION_COPY_URL": "Copy URL",
"DETAILS_SUMMARY": "Technical details",
"CODEC_HINT": "Detected {{codecs}}. Chromium/Electron browser players often have limited support for these codecs.",
"DETAIL_CODE": "Diagnostic code",
"DETAIL_PLAYER": "Player",
"DETAIL_SOURCE": "Source",
"DETAIL_CONTAINER": "Container",
"DETAIL_MIME_TYPE": "MIME type",
"DETAIL_VIDEO_CODECS": "Video codecs",
"DETAIL_AUDIO_CODECS": "Audio codecs",
"DETAIL_NATIVE_ERROR_CODE": "Native error code",
"DETAIL_NATIVE_ERROR_MESSAGE": "Native error message",
"DETAIL_ERROR_DETAILS": "Error details"
},
"LAYOUT": {
"HIDE_CHANNELS_LIST": "Ocultar lista de canales",
+13 -1
View File
@@ -391,7 +391,19 @@
},
"ACTION_OPEN_MPV": "Open in MPV",
"ACTION_OPEN_VLC": "Open in VLC",
"ACTION_COPY_URL": "Copy URL"
"ACTION_COPY_URL": "Copy URL",
"DETAILS_SUMMARY": "Technical details",
"CODEC_HINT": "Detected {{codecs}}. Chromium/Electron browser players often have limited support for these codecs.",
"DETAIL_CODE": "Diagnostic code",
"DETAIL_PLAYER": "Player",
"DETAIL_SOURCE": "Source",
"DETAIL_CONTAINER": "Container",
"DETAIL_MIME_TYPE": "MIME type",
"DETAIL_VIDEO_CODECS": "Video codecs",
"DETAIL_AUDIO_CODECS": "Audio codecs",
"DETAIL_NATIVE_ERROR_CODE": "Native error code",
"DETAIL_NATIVE_ERROR_MESSAGE": "Native error message",
"DETAIL_ERROR_DETAILS": "Error details"
},
"LAYOUT": {
"HIDE_CHANNELS_LIST": "Masquer la liste des chaînes",
+13 -1
View File
@@ -391,7 +391,19 @@
},
"ACTION_OPEN_MPV": "Open in MPV",
"ACTION_OPEN_VLC": "Open in VLC",
"ACTION_COPY_URL": "Copy URL"
"ACTION_COPY_URL": "Copy URL",
"DETAILS_SUMMARY": "Technical details",
"CODEC_HINT": "Detected {{codecs}}. Chromium/Electron browser players often have limited support for these codecs.",
"DETAIL_CODE": "Diagnostic code",
"DETAIL_PLAYER": "Player",
"DETAIL_SOURCE": "Source",
"DETAIL_CONTAINER": "Container",
"DETAIL_MIME_TYPE": "MIME type",
"DETAIL_VIDEO_CODECS": "Video codecs",
"DETAIL_AUDIO_CODECS": "Audio codecs",
"DETAIL_NATIVE_ERROR_CODE": "Native error code",
"DETAIL_NATIVE_ERROR_MESSAGE": "Native error message",
"DETAIL_ERROR_DETAILS": "Error details"
},
"LAYOUT": {
"HIDE_CHANNELS_LIST": "Nascondi elenco canali",
+13 -1
View File
@@ -391,7 +391,19 @@
},
"ACTION_OPEN_MPV": "Open in MPV",
"ACTION_OPEN_VLC": "Open in VLC",
"ACTION_COPY_URL": "Copy URL"
"ACTION_COPY_URL": "Copy URL",
"DETAILS_SUMMARY": "Technical details",
"CODEC_HINT": "Detected {{codecs}}. Chromium/Electron browser players often have limited support for these codecs.",
"DETAIL_CODE": "Diagnostic code",
"DETAIL_PLAYER": "Player",
"DETAIL_SOURCE": "Source",
"DETAIL_CONTAINER": "Container",
"DETAIL_MIME_TYPE": "MIME type",
"DETAIL_VIDEO_CODECS": "Video codecs",
"DETAIL_AUDIO_CODECS": "Audio codecs",
"DETAIL_NATIVE_ERROR_CODE": "Native error code",
"DETAIL_NATIVE_ERROR_MESSAGE": "Native error message",
"DETAIL_ERROR_DETAILS": "Error details"
},
"LAYOUT": {
"HIDE_CHANNELS_LIST": "チャンネル一覧を非表示",
+13 -1
View File
@@ -391,7 +391,19 @@
},
"ACTION_OPEN_MPV": "Open in MPV",
"ACTION_OPEN_VLC": "Open in VLC",
"ACTION_COPY_URL": "Copy URL"
"ACTION_COPY_URL": "Copy URL",
"DETAILS_SUMMARY": "Technical details",
"CODEC_HINT": "Detected {{codecs}}. Chromium/Electron browser players often have limited support for these codecs.",
"DETAIL_CODE": "Diagnostic code",
"DETAIL_PLAYER": "Player",
"DETAIL_SOURCE": "Source",
"DETAIL_CONTAINER": "Container",
"DETAIL_MIME_TYPE": "MIME type",
"DETAIL_VIDEO_CODECS": "Video codecs",
"DETAIL_AUDIO_CODECS": "Audio codecs",
"DETAIL_NATIVE_ERROR_CODE": "Native error code",
"DETAIL_NATIVE_ERROR_MESSAGE": "Native error message",
"DETAIL_ERROR_DETAILS": "Error details"
},
"LAYOUT": {
"HIDE_CHANNELS_LIST": "채널 목록 숨기기",
+13 -1
View File
@@ -391,7 +391,19 @@
},
"ACTION_OPEN_MPV": "Open in MPV",
"ACTION_OPEN_VLC": "Open in VLC",
"ACTION_COPY_URL": "Copy URL"
"ACTION_COPY_URL": "Copy URL",
"DETAILS_SUMMARY": "Technical details",
"CODEC_HINT": "Detected {{codecs}}. Chromium/Electron browser players often have limited support for these codecs.",
"DETAIL_CODE": "Diagnostic code",
"DETAIL_PLAYER": "Player",
"DETAIL_SOURCE": "Source",
"DETAIL_CONTAINER": "Container",
"DETAIL_MIME_TYPE": "MIME type",
"DETAIL_VIDEO_CODECS": "Video codecs",
"DETAIL_AUDIO_CODECS": "Audio codecs",
"DETAIL_NATIVE_ERROR_CODE": "Native error code",
"DETAIL_NATIVE_ERROR_MESSAGE": "Native error message",
"DETAIL_ERROR_DETAILS": "Error details"
},
"LAYOUT": {
"HIDE_CHANNELS_LIST": "Zenderlijst verbergen",
+13 -1
View File
@@ -391,7 +391,19 @@
},
"ACTION_OPEN_MPV": "Open in MPV",
"ACTION_OPEN_VLC": "Open in VLC",
"ACTION_COPY_URL": "Copy URL"
"ACTION_COPY_URL": "Copy URL",
"DETAILS_SUMMARY": "Technical details",
"CODEC_HINT": "Detected {{codecs}}. Chromium/Electron browser players often have limited support for these codecs.",
"DETAIL_CODE": "Diagnostic code",
"DETAIL_PLAYER": "Player",
"DETAIL_SOURCE": "Source",
"DETAIL_CONTAINER": "Container",
"DETAIL_MIME_TYPE": "MIME type",
"DETAIL_VIDEO_CODECS": "Video codecs",
"DETAIL_AUDIO_CODECS": "Audio codecs",
"DETAIL_NATIVE_ERROR_CODE": "Native error code",
"DETAIL_NATIVE_ERROR_MESSAGE": "Native error message",
"DETAIL_ERROR_DETAILS": "Error details"
},
"LAYOUT": {
"HIDE_CHANNELS_LIST": "Ukryj listę kanałów",
+13 -1
View File
@@ -391,7 +391,19 @@
},
"ACTION_OPEN_MPV": "Open in MPV",
"ACTION_OPEN_VLC": "Open in VLC",
"ACTION_COPY_URL": "Copy URL"
"ACTION_COPY_URL": "Copy URL",
"DETAILS_SUMMARY": "Technical details",
"CODEC_HINT": "Detected {{codecs}}. Chromium/Electron browser players often have limited support for these codecs.",
"DETAIL_CODE": "Diagnostic code",
"DETAIL_PLAYER": "Player",
"DETAIL_SOURCE": "Source",
"DETAIL_CONTAINER": "Container",
"DETAIL_MIME_TYPE": "MIME type",
"DETAIL_VIDEO_CODECS": "Video codecs",
"DETAIL_AUDIO_CODECS": "Audio codecs",
"DETAIL_NATIVE_ERROR_CODE": "Native error code",
"DETAIL_NATIVE_ERROR_MESSAGE": "Native error message",
"DETAIL_ERROR_DETAILS": "Error details"
},
"LAYOUT": {
"HIDE_CHANNELS_LIST": "Ocultar lista de canais",
+13 -1
View File
@@ -395,7 +395,19 @@
},
"ACTION_OPEN_MPV": "Открыть в MPV",
"ACTION_OPEN_VLC": "Открыть в VLC",
"ACTION_COPY_URL": "Скопировать URL"
"ACTION_COPY_URL": "Скопировать URL",
"DETAILS_SUMMARY": "Технические сведения",
"CODEC_HINT": "Обнаружены {{codecs}}. Браузерные плееры Chromium/Electron часто имеют ограниченную поддержку этих кодеков.",
"DETAIL_CODE": "Код диагностики",
"DETAIL_PLAYER": "Плеер",
"DETAIL_SOURCE": "Источник",
"DETAIL_CONTAINER": "Контейнер",
"DETAIL_MIME_TYPE": "MIME-тип",
"DETAIL_VIDEO_CODECS": "Видео кодеки",
"DETAIL_AUDIO_CODECS": "Аудио кодеки",
"DETAIL_NATIVE_ERROR_CODE": "Код ошибки браузера",
"DETAIL_NATIVE_ERROR_MESSAGE": "Сообщение браузера",
"DETAIL_ERROR_DETAILS": "Детали ошибки"
},
"LAYOUT": {
"HIDE_CHANNELS_LIST": "Скрыть список каналов",
+13 -1
View File
@@ -391,7 +391,19 @@
},
"ACTION_OPEN_MPV": "Open in MPV",
"ACTION_OPEN_VLC": "Open in VLC",
"ACTION_COPY_URL": "Copy URL"
"ACTION_COPY_URL": "Copy URL",
"DETAILS_SUMMARY": "Technical details",
"CODEC_HINT": "Detected {{codecs}}. Chromium/Electron browser players often have limited support for these codecs.",
"DETAIL_CODE": "Diagnostic code",
"DETAIL_PLAYER": "Player",
"DETAIL_SOURCE": "Source",
"DETAIL_CONTAINER": "Container",
"DETAIL_MIME_TYPE": "MIME type",
"DETAIL_VIDEO_CODECS": "Video codecs",
"DETAIL_AUDIO_CODECS": "Audio codecs",
"DETAIL_NATIVE_ERROR_CODE": "Native error code",
"DETAIL_NATIVE_ERROR_MESSAGE": "Native error message",
"DETAIL_ERROR_DETAILS": "Error details"
},
"LAYOUT": {
"HIDE_CHANNELS_LIST": "Kanal listesini gizle",
+13 -1
View File
@@ -391,7 +391,19 @@
},
"ACTION_OPEN_MPV": "Open in MPV",
"ACTION_OPEN_VLC": "Open in VLC",
"ACTION_COPY_URL": "Copy URL"
"ACTION_COPY_URL": "Copy URL",
"DETAILS_SUMMARY": "Technical details",
"CODEC_HINT": "Detected {{codecs}}. Chromium/Electron browser players often have limited support for these codecs.",
"DETAIL_CODE": "Diagnostic code",
"DETAIL_PLAYER": "Player",
"DETAIL_SOURCE": "Source",
"DETAIL_CONTAINER": "Container",
"DETAIL_MIME_TYPE": "MIME type",
"DETAIL_VIDEO_CODECS": "Video codecs",
"DETAIL_AUDIO_CODECS": "Audio codecs",
"DETAIL_NATIVE_ERROR_CODE": "Native error code",
"DETAIL_NATIVE_ERROR_MESSAGE": "Native error message",
"DETAIL_ERROR_DETAILS": "Error details"
},
"LAYOUT": {
"HIDE_CHANNELS_LIST": "隐藏频道列表",
+13 -1
View File
@@ -391,7 +391,19 @@
},
"ACTION_OPEN_MPV": "Open in MPV",
"ACTION_OPEN_VLC": "Open in VLC",
"ACTION_COPY_URL": "Copy URL"
"ACTION_COPY_URL": "Copy URL",
"DETAILS_SUMMARY": "Technical details",
"CODEC_HINT": "Detected {{codecs}}. Chromium/Electron browser players often have limited support for these codecs.",
"DETAIL_CODE": "Diagnostic code",
"DETAIL_PLAYER": "Player",
"DETAIL_SOURCE": "Source",
"DETAIL_CONTAINER": "Container",
"DETAIL_MIME_TYPE": "MIME type",
"DETAIL_VIDEO_CODECS": "Video codecs",
"DETAIL_AUDIO_CODECS": "Audio codecs",
"DETAIL_NATIVE_ERROR_CODE": "Native error code",
"DETAIL_NATIVE_ERROR_MESSAGE": "Native error message",
"DETAIL_ERROR_DETAILS": "Error details"
},
"LAYOUT": {
"HIDE_CHANNELS_LIST": "隱藏頻道列表",
@@ -153,9 +153,16 @@ Supported diagnostic codes are:
- `unsupported-codec`
- `media-decode-error`
- `network-error`
- `browser-access-error`
- `drm-or-encryption`
- `unknown-playback-error`
`network-error` is reserved for provider/network loading failures. Browser security failures such as CORS, mixed content, Content Security Policy, and private-network-access blocks are classified as `browser-access-error` so the UI can explain that the browser player was blocked before playback reached decoding.
The banner keeps the primary message compact, then exposes technical details on demand: diagnostic code, reporting player/source, detected container/MIME, video/audio codecs, native browser error fields, and raw HLS/mpegts details. HLS manifest codec metadata also drives a concise browser-support hint for codecs that Chromium/Electron commonly cannot decode inline, such as HEVC, AC-3, E-AC-3, DTS, and MPEG-2 video.
URL extension metadata is filtered before diagnostics and player selection use it. Web script extensions such as `.php` are not shown as stream containers; explicit media query metadata such as `extension=ts` or `format=m3u8` is preferred when present.
When a diagnostic is actionable in Electron, the inline banner may offer `Open in MPV`, `Open in VLC`, and `Copy URL`. Web builds only expose copy/help text. MPV/VLC fallback requests carry the original `ResolvedPortalPlayback` payload so headers, referer, origin, user-agent, content metadata, and resume offset stay intact.
`PortalPlayer.openExternalPlayback(playback, player)` is the forced external launch API. It sends the playback payload to MPV or VLC regardless of the current saved player setting, so fallback buttons do not mutate preferences.
@@ -12,7 +12,6 @@ import {
} from '@angular/core';
import Artplayer from 'artplayer';
import Hls, { type ErrorData, type ManifestParsedData } from 'hls.js';
import { getStreamExtensionFromUrl } from 'm3u-utils';
import mpegts from 'mpegts.js';
import { Channel } from 'shared-interfaces';
import {
@@ -23,6 +22,7 @@ import {
classifyNativePlaybackIssue,
classifyUnsupportedHlsManifestCodecs,
createPlaybackSourceMetadata,
getPlaybackMediaExtensionFromUrl,
} from '../playback-diagnostics/playback-diagnostics.util';
type AudioTrackSelector = {
@@ -131,7 +131,9 @@ export class ArtPlayerComponent implements OnInit, OnDestroy, OnChanges {
const el = this.elementRef.nativeElement.querySelector(
'.artplayer-container'
);
const extension = getStreamExtensionFromUrl(this.channel?.url ?? '');
const extension = getPlaybackMediaExtensionFromUrl(
this.channel?.url ?? ''
);
const isLive = extension === 'm3u8' || extension === 'ts' || !extension;
this.player = new Artplayer({
@@ -225,7 +227,10 @@ export class ArtPlayerComponent implements OnInit, OnDestroy, OnChanges {
'error',
this.handleNativePlaybackError
);
this.player.video.addEventListener('loadeddata', this.clearPlaybackIssue);
this.player.video.addEventListener(
'loadeddata',
this.clearPlaybackIssue
);
this.player.video.addEventListener('playing', this.clearPlaybackIssue);
if (this.startTime > 0) {
@@ -308,7 +313,7 @@ export class ArtPlayerComponent implements OnInit, OnDestroy, OnChanges {
const selectedLabel =
typeof item.html === 'string'
? item.html
: item.html.textContent ?? '';
: (item.html.textContent ?? '');
const selectedIndex = tracks.findIndex(
(t, i) =>
(t.name || t.lang || `Track ${i + 1}`) ===
@@ -325,7 +330,7 @@ export class ArtPlayerComponent implements OnInit, OnDestroy, OnChanges {
}
private getVideoType(url: string): string {
const extension = getStreamExtensionFromUrl(url);
const extension = getPlaybackMediaExtensionFromUrl(url);
switch (extension) {
case 'mkv':
return 'video/matroska';
@@ -13,7 +13,6 @@ import {
} from '@angular/core';
import Hls, { type ErrorData, type ManifestParsedData } from 'hls.js';
import mpegts from 'mpegts.js';
import { getStreamExtensionFromUrl } from 'm3u-utils';
import { DataService } from 'services';
import { Channel } from 'shared-interfaces';
import {
@@ -24,6 +23,7 @@ import {
classifyNativePlaybackIssue,
classifyUnsupportedHlsManifestCodecs,
createPlaybackSourceMetadata,
getPlaybackMediaExtensionFromUrl,
} from '../playback-diagnostics/playback-diagnostics.util';
/**
@@ -82,11 +82,14 @@ export class HtmlVideoPlayerComponent implements OnInit, OnChanges, OnDestroy {
this.onVolumeChange();
});
this.videoPlayer.nativeElement.addEventListener('loadedmetadata', () => {
if (this.startTime > 0) {
this.videoPlayer.nativeElement.currentTime = this.startTime;
this.videoPlayer.nativeElement.addEventListener(
'loadedmetadata',
() => {
if (this.startTime > 0) {
this.videoPlayer.nativeElement.currentTime = this.startTime;
}
}
});
);
this.videoPlayer.nativeElement.addEventListener('timeupdate', () => {
this.timeUpdate.emit({
@@ -143,7 +146,7 @@ export class HtmlVideoPlayerComponent implements OnInit, OnChanges, OnDestroy {
if (channel.url) {
this.playbackIssue.emit(null);
const url = channel.url + (channel.epgParams ?? '');
const extension = getStreamExtensionFromUrl(channel.url);
const extension = getPlaybackMediaExtensionFromUrl(channel.url);
// Set user agent if specified on channel
if (channel.http?.['user-agent']) {
@@ -154,7 +157,11 @@ export class HtmlVideoPlayerComponent implements OnInit, OnChanges, OnDestroy {
}
if ((extension === 'ts' || !extension) && mpegts.isSupported()) {
console.log('Using mpegts.js for TS stream:', channel.name, url);
console.log(
'Using mpegts.js for TS stream:',
channel.name,
url
);
this.mpegtsPlayer = mpegts.createPlayer({
type: 'mpegts',
isLive: true,
@@ -165,11 +172,7 @@ export class HtmlVideoPlayerComponent implements OnInit, OnChanges, OnDestroy {
);
this.mpegtsPlayer.on(
mpegts.Events.ERROR,
(
type: string,
details: string,
info: unknown
): void => {
(type: string, details: string, info: unknown): void => {
this.playbackIssue.emit(
classifyMpegTsPlaybackIssue(
{
@@ -4,6 +4,8 @@ import {
classifyMpegTsPlaybackIssue,
classifyNativePlaybackIssue,
createPlaybackSourceMetadata,
getLikelyBrowserUnsupportedCodecLabels,
getPlaybackMediaExtensionFromUrl,
} from './playback-diagnostics.util';
describe('playback diagnostics', () => {
@@ -107,6 +109,24 @@ describe('playback diagnostics', () => {
expect(issue.externalFallbackRecommended).toBe(true);
});
it('classifies browser security policy blocks as browser access errors', () => {
const issue = classifyHlsPlaybackIssue(
{
type: 'networkError',
details:
'manifestLoadError Refused to connect because it violates the following Content Security Policy directive: "connect-src"',
fatal: true,
},
createPlaybackSourceMetadata({
url: 'http://provider.example/live.m3u8',
player: 'videojs',
})
);
expect(issue.code).toBe(PlaybackDiagnosticCode.BrowserAccessError);
expect(issue.externalFallbackRecommended).toBe(true);
});
it('classifies native CORS failures as browser access errors', () => {
const issue = classifyNativePlaybackIssue(
{
@@ -178,4 +198,36 @@ describe('playback diagnostics', () => {
expect(metadata.extension).toBe('');
expect(metadata.container).toBe('');
});
it('uses declared media query metadata without exposing web script extensions', () => {
expect(
getPlaybackMediaExtensionFromUrl(
'http://portal.example/play/live.php?format=m3u8&stream=123'
)
).toBe('m3u8');
expect(
getPlaybackMediaExtensionFromUrl(
'http://portal.example/play/live.php?type=m3u8&stream=123'
)
).toBe('m3u8');
expect(
getPlaybackMediaExtensionFromUrl(
'http://portal.example/play/live.php?output=ts&stream=123'
)
).toBe('ts');
expect(
getPlaybackMediaExtensionFromUrl(
'http://portal.example/play/live.php?stream=123'
)
).toBe('');
});
it('detects codecs with limited Chromium browser-player support', () => {
expect(
getLikelyBrowserUnsupportedCodecLabels({
audioCodecs: ['mp4a.40.2', 'ac-3', 'ec-3'],
videoCodecs: ['avc1.64001f', 'hvc1.1.6.L93.B0'],
})
).toEqual(['HEVC', 'AC-3', 'E-AC-3']);
});
});
@@ -115,6 +115,38 @@ const UNSUPPORTED_CONTAINER_EXTENSIONS = new Set([
'wmv',
]);
const DECLARED_MEDIA_EXTENSION_QUERY_KEYS = [
'extension',
'ext',
'format',
'container',
'type',
'output',
];
const DECLARED_MEDIA_EXTENSION_ALIASES = new Map([
['hls', 'm3u8'],
['mpegts', 'ts'],
['mpeg-ts', 'ts'],
]);
const DECLARED_MEDIA_EXTENSIONS = new Set([
...UNSUPPORTED_CONTAINER_EXTENSIONS,
'aac',
'flac',
'm3u',
'm3u8',
'm4s',
'mp3',
'mp4',
'mpd',
'mpv',
'oga',
'ogg',
'ogv',
'webm',
]);
const NON_MEDIA_URL_EXTENSIONS = new Set([
'asp',
'aspx',
@@ -128,10 +160,21 @@ const SOURCE_NOT_SUPPORTED_CODE = 4;
const DECODE_ERROR_CODE = 3;
const NETWORK_ERROR_CODE = 2;
const BROWSER_LIMITED_CODEC_PATTERNS: ReadonlyArray<{
readonly label: string;
readonly pattern: RegExp;
}> = [
{ label: 'HEVC', pattern: /^(hev1|hvc1|hevc|h265)/ },
{ label: 'AC-3', pattern: /^(ac-?3|dac3)$/ },
{ label: 'E-AC-3', pattern: /^(ec-?3|eac-?3|dec3)$/ },
{ label: 'DTS', pattern: /^(dts|dtsc|dtse|dtsh|dtsl)/ },
{ label: 'MPEG-2 Video', pattern: /^(mp2v|mpeg2video)/ },
];
export function createPlaybackSourceMetadata(
input: PlaybackSourceMetadataInput
): PlaybackSourceMetadata {
const extension = getMediaExtensionFromUrl(input.url);
const extension = getPlaybackMediaExtensionFromUrl(input.url);
const mimeType = input.mimeType?.trim() || undefined;
return {
@@ -145,6 +188,32 @@ export function createPlaybackSourceMetadata(
};
}
export function getPlaybackMediaExtensionFromUrl(url: string): string {
const queryExtension = getMediaExtensionFromQuery(url);
if (queryExtension) {
return queryExtension;
}
const pathExtension = normalizeExtensionToken(getExtensionFromUrl(url));
if (NON_MEDIA_URL_EXTENSIONS.has(pathExtension)) {
return '';
}
return pathExtension;
}
export function getLikelyBrowserUnsupportedCodecLabels(
metadata: Pick<PlaybackSourceMetadata, 'audioCodecs' | 'videoCodecs'>
): string[] {
const codecs = [...metadata.videoCodecs, ...metadata.audioCodecs].map(
normalizeToken
);
return BROWSER_LIMITED_CODEC_PATTERNS.filter(({ pattern }) =>
codecs.some((codec) => pattern.test(codec))
).map(({ label }) => label);
}
export function classifyNativePlaybackIssue(
error: NativePlaybackErrorInput | MediaError | null | undefined,
metadata: PlaybackSourceMetadata
@@ -411,39 +480,36 @@ function normalizeExtensionToken(value: string | undefined): string {
return normalizeToken(value).replace(/^\.+/, '');
}
function getMediaExtensionFromUrl(url: string): string {
const queryExtension = getMediaExtensionFromQuery(url);
if (queryExtension) {
return queryExtension;
}
const pathExtension = normalizeExtensionToken(getExtensionFromUrl(url));
if (NON_MEDIA_URL_EXTENSIONS.has(pathExtension)) {
return '';
}
return pathExtension;
}
function getMediaExtensionFromQuery(url: string): string {
try {
const parsedUrl = new URL(url, 'http://iptvnator.local');
const declaredExtension = normalizeExtensionToken(
parsedUrl.searchParams.get('extension') ?? undefined
);
if (!declaredExtension) {
return '';
for (const key of DECLARED_MEDIA_EXTENSION_QUERY_KEYS) {
const declaredExtension = normalizeDeclaredMediaExtension(
parsedUrl.searchParams.get(key) ?? undefined
);
if (declaredExtension) {
return declaredExtension;
}
}
return NON_MEDIA_URL_EXTENSIONS.has(declaredExtension)
? ''
: declaredExtension;
return '';
} catch {
return '';
}
}
function normalizeDeclaredMediaExtension(value: string | undefined): string {
const extension = normalizeExtensionToken(value);
const normalized =
DECLARED_MEDIA_EXTENSION_ALIASES.get(extension) ?? extension;
if (!normalized || NON_MEDIA_URL_EXTENSIONS.has(normalized)) {
return '';
}
return DECLARED_MEDIA_EXTENSIONS.has(normalized) ? normalized : '';
}
function inferContainerFromMimeType(mimeType: string | undefined): string {
if (!mimeType) {
return '';
@@ -500,7 +566,9 @@ function isBrowserAccessFailure(details: string): boolean {
details.includes('cross origin') ||
details.includes('access-control') ||
details.includes('access control') ||
details.includes('content security policy') ||
details.includes('mixed content') ||
details.includes('private network access') ||
details.includes('blocked by') ||
details.includes('has been blocked') ||
details.includes('not allowed to load local resource') ||
@@ -163,6 +163,9 @@ describe('VjsPlayerComponent', () => {
expect(
isMpegTsSource('https://example.com/live.php?extension=ts')
).toBe(true);
expect(isMpegTsSource('https://example.com/live.php?stream=123')).toBe(
true
);
});
it('emits a playback issue when VideoJS reports an unsupported source', () => {
@@ -11,7 +11,6 @@ import {
viewChild,
} from '@angular/core';
import '@yangkghjh/videojs-aspect-ratio-panel';
import { getStreamExtensionFromUrl } from 'm3u-utils';
import mpegts from 'mpegts.js';
import videoJs from 'video.js';
import 'videojs-contrib-quality-levels';
@@ -22,6 +21,7 @@ import {
classifyMpegTsPlaybackIssue,
classifyNativePlaybackIssue,
createPlaybackSourceMetadata,
getPlaybackMediaExtensionFromUrl,
} from '../playback-diagnostics/playback-diagnostics.util';
/**
@@ -254,7 +254,7 @@ export class VjsPlayerComponent implements OnInit, OnChanges, OnDestroy {
private isMpegTsSource(url?: string): boolean {
if (!url) return false;
const extension = getStreamExtensionFromUrl(url);
const extension = getPlaybackMediaExtensionFromUrl(url);
return (extension === 'ts' || !extension) && mpegts.isSupported();
}
@@ -61,6 +61,37 @@
{{ meta }}
</div>
}
@if (getDiagnosticCodecHint(issue); as codecHint) {
<div
class="web-player-diagnostic__codec-hint"
data-test-id="playback-codec-hint"
>
{{
'PLAYBACK_DIAGNOSTICS.CODEC_HINT'
| translate: { codecs: codecHint }
}}
</div>
}
<details
class="web-player-diagnostic__details"
data-test-id="playback-diagnostic-details"
>
<summary>
<mat-icon aria-hidden="true">info</mat-icon>
{{ 'PLAYBACK_DIAGNOSTICS.DETAILS_SUMMARY' | translate }}
</summary>
<dl>
@for (
detail of getDiagnosticDetails(issue);
track detail.labelKey
) {
<div class="web-player-diagnostic__detail-row">
<dt>{{ detail.labelKey | translate }}</dt>
<dd>{{ detail.value }}</dd>
</div>
}
</dl>
</details>
</div>
<div class="web-player-diagnostic__actions">
@if (canShowExternalFallbackActions()) {
@@ -59,7 +59,8 @@ app-html-video-player {
}
.web-player-diagnostic__description,
.web-player-diagnostic__meta {
.web-player-diagnostic__meta,
.web-player-diagnostic__codec-hint {
margin-top: 3px;
font-size: 0.82rem;
line-height: 1.3;
@@ -70,6 +71,80 @@ app-html-video-player {
overflow-wrap: anywhere;
}
.web-player-diagnostic__codec-hint {
display: inline-block;
max-width: 100%;
margin-top: 8px;
padding: 4px 8px;
border: 1px solid
color-mix(in srgb, var(--mat-sys-on-error-container) 18%, transparent);
border-radius: 6px;
background: color-mix(
in srgb,
var(--mat-sys-on-error-container) 7%,
transparent
);
font-weight: 550;
overflow-wrap: anywhere;
}
.web-player-diagnostic__details {
margin-top: 10px;
border-top: 1px solid
color-mix(in srgb, var(--mat-sys-on-error-container) 16%, transparent);
padding-top: 8px;
}
.web-player-diagnostic__details summary {
display: inline-flex;
align-items: center;
gap: 5px;
cursor: pointer;
font-size: 0.78rem;
font-weight: 650;
opacity: 0.84;
list-style: none;
}
.web-player-diagnostic__details summary::-webkit-details-marker {
display: none;
}
.web-player-diagnostic__details summary mat-icon {
width: 16px;
height: 16px;
font-size: 16px;
}
.web-player-diagnostic__details dl {
display: grid;
grid-template-columns: max-content minmax(0, 1fr);
gap: 5px 12px;
margin: 8px 0 0;
font-size: 0.76rem;
line-height: 1.25;
}
.web-player-diagnostic__detail-row {
display: contents;
}
.web-player-diagnostic__detail-row dt {
color: color-mix(
in srgb,
var(--mat-sys-on-error-container) 76%,
transparent
);
font-weight: 650;
white-space: nowrap;
}
.web-player-diagnostic__detail-row dd {
min-width: 0;
margin: 0;
overflow-wrap: anywhere;
}
.web-player-diagnostic__actions {
grid-area: actions;
display: flex;
@@ -95,4 +170,17 @@ app-html-video-player {
.web-player-diagnostic__actions {
justify-content: flex-start;
}
.web-player-diagnostic__details dl {
grid-template-columns: minmax(0, 1fr);
}
.web-player-diagnostic__detail-row {
display: grid;
gap: 2px;
}
.web-player-diagnostic__detail-row dt {
white-space: normal;
}
}
@@ -188,6 +188,19 @@ describe('WebPlayerViewComponent', () => {
]);
});
it('treats web script playback URLs without declared media extension as MPEG-TS', () => {
const streamUrl = 'https://example.com/live.php?stream=123&token=x';
component.setVjsOptions(streamUrl);
expect(component.vjsOptions.sources).toEqual([
{
src: streamUrl,
type: 'video/mp2t',
},
]);
});
it('uses browser access diagnostic translation keys', () => {
const issue = createBrowserAccessDiagnostic();
@@ -198,6 +211,49 @@ describe('WebPlayerViewComponent', () => {
'PLAYBACK_DIAGNOSTICS.BROWSER_ACCESS_ERROR.DESCRIPTION'
);
});
it('renders technical details and codec-specific hints in the diagnostic banner', () => {
fixture.detectChanges();
const issue = createUnsupportedCodecDiagnostic();
component.handlePlaybackIssue(issue);
fixture.detectChanges();
const details = fixture.debugElement.query(
By.css('[data-test-id="playback-diagnostic-details"]')
);
const codecHint = fixture.debugElement.query(
By.css('[data-test-id="playback-codec-hint"]')
);
expect(details.nativeElement.textContent).toContain(
'PLAYBACK_DIAGNOSTICS.DETAILS_SUMMARY'
);
expect(component.getDiagnosticCodecHint(issue)).toBe('HEVC, AC-3');
expect(component.getDiagnosticDetails(issue)).toEqual(
expect.arrayContaining([
{
labelKey: 'PLAYBACK_DIAGNOSTICS.DETAIL_CODE',
value: 'unsupported-codec',
},
{
labelKey: 'PLAYBACK_DIAGNOSTICS.DETAIL_PLAYER',
value: 'Video.js',
},
{
labelKey: 'PLAYBACK_DIAGNOSTICS.DETAIL_VIDEO_CODECS',
value: 'hvc1.1.6.L93.B0',
},
{
labelKey: 'PLAYBACK_DIAGNOSTICS.DETAIL_AUDIO_CODECS',
value: 'ac-3',
},
])
);
expect(codecHint.nativeElement.textContent).toContain(
'PLAYBACK_DIAGNOSTICS.CODEC_HINT'
);
});
});
function createUnsupportedContainerDiagnostic(): PlaybackDiagnostic {
@@ -228,3 +284,18 @@ function createBrowserAccessDiagnostic(): PlaybackDiagnostic {
externalFallbackRecommended: true,
};
}
function createUnsupportedCodecDiagnostic(): PlaybackDiagnostic {
return {
code: PlaybackDiagnosticCode.UnsupportedCodec,
source: PlaybackDiagnosticSource.Hls,
sourceUrl: 'https://example.com/live/index.m3u8',
container: 'm3u8',
mimeType: 'application/x-mpegURL',
player: 'videojs',
audioCodecs: ['ac-3'],
videoCodecs: ['hvc1.1.6.L93.B0'],
details: 'manifestIncompatibleCodecsError',
externalFallbackRecommended: true,
};
}
@@ -17,7 +17,6 @@ import { MatIconModule } from '@angular/material/icon';
import { MatTooltipModule } from '@angular/material/tooltip';
import { StorageMap } from '@ngx-pwa/local-storage';
import { TranslatePipe } from '@ngx-translate/core';
import { getStreamExtensionFromUrl } from 'm3u-utils';
import {
ResolvedPortalPlayback,
Settings,
@@ -32,9 +31,16 @@ import {
type PlaybackDiagnostic,
PlaybackDiagnosticCode,
type PlaybackFallbackRequest,
getLikelyBrowserUnsupportedCodecLabels,
getPlaybackMediaExtensionFromUrl,
} from '../playback-diagnostics/playback-diagnostics.util';
import { VjsPlayerComponent } from '../vjs-player/vjs-player.component';
type PlaybackDiagnosticDetail = {
readonly labelKey: string;
readonly value: string;
};
@Component({
selector: 'app-web-player-view',
templateUrl: './web-player-view.component.html',
@@ -118,7 +124,7 @@ export class WebPlayerViewComponent {
}
setVjsOptions(streamUrl: string) {
const extension = getStreamExtensionFromUrl(streamUrl);
const extension = getPlaybackMediaExtensionFromUrl(streamUrl);
const mimeType =
extension === 'm3u' || extension === 'm3u8'
? 'application/x-mpegURL'
@@ -168,7 +174,58 @@ export class WebPlayerViewComponent {
return codecs;
}
return issue.container || issue.mimeType || issue.details || '';
return issue.container || issue.mimeType || '';
}
getDiagnosticCodecHint(issue: PlaybackDiagnostic): string {
return getLikelyBrowserUnsupportedCodecLabels(issue).join(', ');
}
getDiagnosticDetails(
issue: PlaybackDiagnostic
): readonly PlaybackDiagnosticDetail[] {
return [
{
labelKey: 'PLAYBACK_DIAGNOSTICS.DETAIL_CODE',
value: issue.code,
},
{
labelKey: 'PLAYBACK_DIAGNOSTICS.DETAIL_PLAYER',
value: this.formatPlayer(issue.player),
},
{
labelKey: 'PLAYBACK_DIAGNOSTICS.DETAIL_SOURCE',
value: this.formatDiagnosticSource(issue.source),
},
{
labelKey: 'PLAYBACK_DIAGNOSTICS.DETAIL_CONTAINER',
value: issue.container,
},
{
labelKey: 'PLAYBACK_DIAGNOSTICS.DETAIL_MIME_TYPE',
value: issue.mimeType ?? '',
},
{
labelKey: 'PLAYBACK_DIAGNOSTICS.DETAIL_VIDEO_CODECS',
value: issue.videoCodecs.join(', '),
},
{
labelKey: 'PLAYBACK_DIAGNOSTICS.DETAIL_AUDIO_CODECS',
value: issue.audioCodecs.join(', '),
},
{
labelKey: 'PLAYBACK_DIAGNOSTICS.DETAIL_NATIVE_ERROR_CODE',
value: issue.nativeErrorCode?.toString() ?? '',
},
{
labelKey: 'PLAYBACK_DIAGNOSTICS.DETAIL_NATIVE_ERROR_MESSAGE',
value: issue.nativeErrorMessage ?? '',
},
{
labelKey: 'PLAYBACK_DIAGNOSTICS.DETAIL_ERROR_DETAILS',
value: issue.details ?? '',
},
].filter(({ value }) => value.trim().length > 0);
}
private getDiagnosticTranslationBase(issue: PlaybackDiagnostic): string {
@@ -194,4 +251,34 @@ export class WebPlayerViewComponent {
private detectDesktop(): boolean {
return typeof window !== 'undefined' && !!window.electron;
}
private formatPlayer(player: PlaybackDiagnostic['player']): string {
switch (player) {
case 'videojs':
return 'Video.js';
case 'html5':
return 'HTML5';
case 'artplayer':
return 'ArtPlayer';
default:
return '';
}
}
private formatDiagnosticSource(
source: PlaybackDiagnostic['source']
): string {
switch (source) {
case 'hls':
return 'HLS.js';
case 'mpegts':
return 'mpegts.js';
case 'native':
return 'Native media element';
case 'source':
return 'Stream metadata';
default:
return source;
}
}
}