mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-11 02:46:16 -08:00
fix(playback): improve inline stream diagnostics
This commit is contained in:
1 parent
32cc0b74e8
commit
d7f914bc57
28 files changed
+772
-292
No files matched your search
@@ -118,6 +118,10 @@
|
||||
}
|
||||
},
|
||||
"serve-electron": {
|
||||
"dependsOn": [
|
||||
"electron-backend:build-worker",
|
||||
"electron-backend:build-embedded-mpv"
|
||||
],
|
||||
"executor": "nx-electron:execute",
|
||||
"options": {
|
||||
"buildTarget": "electron-backend:build",
|
||||
|
||||
@@ -395,9 +395,14 @@
|
||||
"TITLE": "The browser player reported a playback error",
|
||||
"DESCRIPTION": "The stream failed in the built-in player, but the error did not identify a specific codec, container, or network cause."
|
||||
},
|
||||
"NATIVE_FALLBACK_TITLE": "Open it in a native player instead",
|
||||
"INLINE_FAILURE_TITLE": "Check the stream or try again",
|
||||
"ACTION_OPEN_MPV": "Open in MPV",
|
||||
"ACTION_OPEN_VLC": "Open in VLC",
|
||||
"ACTION_OPEN_MPV_HINT": "configured player",
|
||||
"ACTION_OPEN_VLC_HINT": "configured player",
|
||||
"ACTION_COPY_URL": "Copy URL",
|
||||
"ACTION_RETRY": "Retry",
|
||||
"DETAILS_SUMMARY": "Technical details",
|
||||
"CODEC_HINT": "Detected {{codecs}}. Chromium/Electron browser players often have limited support for these codecs.",
|
||||
"DETAIL_CODE": "Diagnostic code",
|
||||
|
||||
@@ -395,9 +395,14 @@
|
||||
"TITLE": "The browser player reported a playback error",
|
||||
"DESCRIPTION": "The stream failed in the built-in player, but the error did not identify a specific codec, container, or network cause."
|
||||
},
|
||||
"NATIVE_FALLBACK_TITLE": "Open it in a native player instead",
|
||||
"INLINE_FAILURE_TITLE": "Check the stream or try again",
|
||||
"ACTION_OPEN_MPV": "Open in MPV",
|
||||
"ACTION_OPEN_VLC": "Open in VLC",
|
||||
"ACTION_OPEN_MPV_HINT": "configured player",
|
||||
"ACTION_OPEN_VLC_HINT": "configured player",
|
||||
"ACTION_COPY_URL": "Copy URL",
|
||||
"ACTION_RETRY": "Retry",
|
||||
"DETAILS_SUMMARY": "Technical details",
|
||||
"CODEC_HINT": "Detected {{codecs}}. Chromium/Electron browser players often have limited support for these codecs.",
|
||||
"DETAIL_CODE": "Diagnostic code",
|
||||
|
||||
@@ -395,9 +395,14 @@
|
||||
"TITLE": "The browser player reported a playback error",
|
||||
"DESCRIPTION": "The stream failed in the built-in player, but the error did not identify a specific codec, container, or network cause."
|
||||
},
|
||||
"NATIVE_FALLBACK_TITLE": "Open it in a native player instead",
|
||||
"INLINE_FAILURE_TITLE": "Check the stream or try again",
|
||||
"ACTION_OPEN_MPV": "Open in MPV",
|
||||
"ACTION_OPEN_VLC": "Open in VLC",
|
||||
"ACTION_OPEN_MPV_HINT": "configured player",
|
||||
"ACTION_OPEN_VLC_HINT": "configured player",
|
||||
"ACTION_COPY_URL": "Copy URL",
|
||||
"ACTION_RETRY": "Retry",
|
||||
"DETAILS_SUMMARY": "Technical details",
|
||||
"CODEC_HINT": "Detected {{codecs}}. Chromium/Electron browser players often have limited support for these codecs.",
|
||||
"DETAIL_CODE": "Diagnostic code",
|
||||
|
||||
@@ -395,9 +395,14 @@
|
||||
"TITLE": "The browser player reported a playback error",
|
||||
"DESCRIPTION": "The stream failed in the built-in player, but the error did not identify a specific codec, container, or network cause."
|
||||
},
|
||||
"NATIVE_FALLBACK_TITLE": "Open it in a native player instead",
|
||||
"INLINE_FAILURE_TITLE": "Check the stream or try again",
|
||||
"ACTION_OPEN_MPV": "Open in MPV",
|
||||
"ACTION_OPEN_VLC": "Open in VLC",
|
||||
"ACTION_OPEN_MPV_HINT": "configured player",
|
||||
"ACTION_OPEN_VLC_HINT": "configured player",
|
||||
"ACTION_COPY_URL": "Copy URL",
|
||||
"ACTION_RETRY": "Retry",
|
||||
"DETAILS_SUMMARY": "Technical details",
|
||||
"CODEC_HINT": "Detected {{codecs}}. Chromium/Electron browser players often have limited support for these codecs.",
|
||||
"DETAIL_CODE": "Diagnostic code",
|
||||
|
||||
@@ -395,9 +395,14 @@
|
||||
"TITLE": "The browser player reported a playback error",
|
||||
"DESCRIPTION": "The stream failed in the built-in player, but the error did not identify a specific codec, container, or network cause."
|
||||
},
|
||||
"NATIVE_FALLBACK_TITLE": "Open it in a native player instead",
|
||||
"INLINE_FAILURE_TITLE": "Check the stream or try again",
|
||||
"ACTION_OPEN_MPV": "Open in MPV",
|
||||
"ACTION_OPEN_VLC": "Open in VLC",
|
||||
"ACTION_OPEN_MPV_HINT": "configured player",
|
||||
"ACTION_OPEN_VLC_HINT": "configured player",
|
||||
"ACTION_COPY_URL": "Copy URL",
|
||||
"ACTION_RETRY": "Retry",
|
||||
"DETAILS_SUMMARY": "Technical details",
|
||||
"CODEC_HINT": "Detected {{codecs}}. Chromium/Electron browser players often have limited support for these codecs.",
|
||||
"DETAIL_CODE": "Diagnostic code",
|
||||
|
||||
@@ -395,9 +395,14 @@
|
||||
"TITLE": "The browser player reported a playback error",
|
||||
"DESCRIPTION": "The stream failed in the built-in player, but the error did not identify a specific codec, container, or network cause."
|
||||
},
|
||||
"NATIVE_FALLBACK_TITLE": "Open it in a native player instead",
|
||||
"INLINE_FAILURE_TITLE": "Check the stream or try again",
|
||||
"ACTION_OPEN_MPV": "Open in MPV",
|
||||
"ACTION_OPEN_VLC": "Open in VLC",
|
||||
"ACTION_COPY_URL": "Copy URL",
|
||||
"ACTION_OPEN_MPV_HINT": "configured player",
|
||||
"ACTION_OPEN_VLC_HINT": "configured player",
|
||||
"ACTION_COPY_URL": "Copy stream URL",
|
||||
"ACTION_RETRY": "Retry",
|
||||
"DETAILS_SUMMARY": "Technical details",
|
||||
"CODEC_HINT": "Detected {{codecs}}. Chromium/Electron browser players often have limited support for these codecs.",
|
||||
"DETAIL_CODE": "Diagnostic code",
|
||||
|
||||
@@ -395,9 +395,14 @@
|
||||
"TITLE": "The browser player reported a playback error",
|
||||
"DESCRIPTION": "The stream failed in the built-in player, but the error did not identify a specific codec, container, or network cause."
|
||||
},
|
||||
"NATIVE_FALLBACK_TITLE": "Open it in a native player instead",
|
||||
"INLINE_FAILURE_TITLE": "Check the stream or try again",
|
||||
"ACTION_OPEN_MPV": "Open in MPV",
|
||||
"ACTION_OPEN_VLC": "Open in VLC",
|
||||
"ACTION_OPEN_MPV_HINT": "configured player",
|
||||
"ACTION_OPEN_VLC_HINT": "configured player",
|
||||
"ACTION_COPY_URL": "Copy URL",
|
||||
"ACTION_RETRY": "Retry",
|
||||
"DETAILS_SUMMARY": "Technical details",
|
||||
"CODEC_HINT": "Detected {{codecs}}. Chromium/Electron browser players often have limited support for these codecs.",
|
||||
"DETAIL_CODE": "Diagnostic code",
|
||||
|
||||
@@ -395,9 +395,14 @@
|
||||
"TITLE": "The browser player reported a playback error",
|
||||
"DESCRIPTION": "The stream failed in the built-in player, but the error did not identify a specific codec, container, or network cause."
|
||||
},
|
||||
"NATIVE_FALLBACK_TITLE": "Open it in a native player instead",
|
||||
"INLINE_FAILURE_TITLE": "Check the stream or try again",
|
||||
"ACTION_OPEN_MPV": "Open in MPV",
|
||||
"ACTION_OPEN_VLC": "Open in VLC",
|
||||
"ACTION_OPEN_MPV_HINT": "configured player",
|
||||
"ACTION_OPEN_VLC_HINT": "configured player",
|
||||
"ACTION_COPY_URL": "Copy URL",
|
||||
"ACTION_RETRY": "Retry",
|
||||
"DETAILS_SUMMARY": "Technical details",
|
||||
"CODEC_HINT": "Detected {{codecs}}. Chromium/Electron browser players often have limited support for these codecs.",
|
||||
"DETAIL_CODE": "Diagnostic code",
|
||||
|
||||
@@ -395,9 +395,14 @@
|
||||
"TITLE": "The browser player reported a playback error",
|
||||
"DESCRIPTION": "The stream failed in the built-in player, but the error did not identify a specific codec, container, or network cause."
|
||||
},
|
||||
"NATIVE_FALLBACK_TITLE": "Open it in a native player instead",
|
||||
"INLINE_FAILURE_TITLE": "Check the stream or try again",
|
||||
"ACTION_OPEN_MPV": "Open in MPV",
|
||||
"ACTION_OPEN_VLC": "Open in VLC",
|
||||
"ACTION_OPEN_MPV_HINT": "configured player",
|
||||
"ACTION_OPEN_VLC_HINT": "configured player",
|
||||
"ACTION_COPY_URL": "Copy URL",
|
||||
"ACTION_RETRY": "Retry",
|
||||
"DETAILS_SUMMARY": "Technical details",
|
||||
"CODEC_HINT": "Detected {{codecs}}. Chromium/Electron browser players often have limited support for these codecs.",
|
||||
"DETAIL_CODE": "Diagnostic code",
|
||||
|
||||
@@ -395,9 +395,14 @@
|
||||
"TITLE": "The browser player reported a playback error",
|
||||
"DESCRIPTION": "The stream failed in the built-in player, but the error did not identify a specific codec, container, or network cause."
|
||||
},
|
||||
"NATIVE_FALLBACK_TITLE": "Open it in a native player instead",
|
||||
"INLINE_FAILURE_TITLE": "Check the stream or try again",
|
||||
"ACTION_OPEN_MPV": "Open in MPV",
|
||||
"ACTION_OPEN_VLC": "Open in VLC",
|
||||
"ACTION_OPEN_MPV_HINT": "configured player",
|
||||
"ACTION_OPEN_VLC_HINT": "configured player",
|
||||
"ACTION_COPY_URL": "Copy URL",
|
||||
"ACTION_RETRY": "Retry",
|
||||
"DETAILS_SUMMARY": "Technical details",
|
||||
"CODEC_HINT": "Detected {{codecs}}. Chromium/Electron browser players often have limited support for these codecs.",
|
||||
"DETAIL_CODE": "Diagnostic code",
|
||||
|
||||
@@ -395,9 +395,14 @@
|
||||
"TITLE": "The browser player reported a playback error",
|
||||
"DESCRIPTION": "The stream failed in the built-in player, but the error did not identify a specific codec, container, or network cause."
|
||||
},
|
||||
"NATIVE_FALLBACK_TITLE": "Open it in a native player instead",
|
||||
"INLINE_FAILURE_TITLE": "Check the stream or try again",
|
||||
"ACTION_OPEN_MPV": "Open in MPV",
|
||||
"ACTION_OPEN_VLC": "Open in VLC",
|
||||
"ACTION_OPEN_MPV_HINT": "configured player",
|
||||
"ACTION_OPEN_VLC_HINT": "configured player",
|
||||
"ACTION_COPY_URL": "Copy URL",
|
||||
"ACTION_RETRY": "Retry",
|
||||
"DETAILS_SUMMARY": "Technical details",
|
||||
"CODEC_HINT": "Detected {{codecs}}. Chromium/Electron browser players often have limited support for these codecs.",
|
||||
"DETAIL_CODE": "Diagnostic code",
|
||||
|
||||
@@ -395,9 +395,14 @@
|
||||
"TITLE": "The browser player reported a playback error",
|
||||
"DESCRIPTION": "The stream failed in the built-in player, but the error did not identify a specific codec, container, or network cause."
|
||||
},
|
||||
"NATIVE_FALLBACK_TITLE": "Open it in a native player instead",
|
||||
"INLINE_FAILURE_TITLE": "Check the stream or try again",
|
||||
"ACTION_OPEN_MPV": "Open in MPV",
|
||||
"ACTION_OPEN_VLC": "Open in VLC",
|
||||
"ACTION_OPEN_MPV_HINT": "configured player",
|
||||
"ACTION_OPEN_VLC_HINT": "configured player",
|
||||
"ACTION_COPY_URL": "Copy URL",
|
||||
"ACTION_RETRY": "Retry",
|
||||
"DETAILS_SUMMARY": "Technical details",
|
||||
"CODEC_HINT": "Detected {{codecs}}. Chromium/Electron browser players often have limited support for these codecs.",
|
||||
"DETAIL_CODE": "Diagnostic code",
|
||||
|
||||
@@ -395,9 +395,14 @@
|
||||
"TITLE": "The browser player reported a playback error",
|
||||
"DESCRIPTION": "The stream failed in the built-in player, but the error did not identify a specific codec, container, or network cause."
|
||||
},
|
||||
"NATIVE_FALLBACK_TITLE": "Open it in a native player instead",
|
||||
"INLINE_FAILURE_TITLE": "Check the stream or try again",
|
||||
"ACTION_OPEN_MPV": "Open in MPV",
|
||||
"ACTION_OPEN_VLC": "Open in VLC",
|
||||
"ACTION_OPEN_MPV_HINT": "configured player",
|
||||
"ACTION_OPEN_VLC_HINT": "configured player",
|
||||
"ACTION_COPY_URL": "Copy URL",
|
||||
"ACTION_RETRY": "Retry",
|
||||
"DETAILS_SUMMARY": "Technical details",
|
||||
"CODEC_HINT": "Detected {{codecs}}. Chromium/Electron browser players often have limited support for these codecs.",
|
||||
"DETAIL_CODE": "Diagnostic code",
|
||||
|
||||
@@ -395,9 +395,14 @@
|
||||
"TITLE": "The browser player reported a playback error",
|
||||
"DESCRIPTION": "The stream failed in the built-in player, but the error did not identify a specific codec, container, or network cause."
|
||||
},
|
||||
"NATIVE_FALLBACK_TITLE": "Open it in a native player instead",
|
||||
"INLINE_FAILURE_TITLE": "Check the stream or try again",
|
||||
"ACTION_OPEN_MPV": "Open in MPV",
|
||||
"ACTION_OPEN_VLC": "Open in VLC",
|
||||
"ACTION_OPEN_MPV_HINT": "configured player",
|
||||
"ACTION_OPEN_VLC_HINT": "configured player",
|
||||
"ACTION_COPY_URL": "Copy URL",
|
||||
"ACTION_RETRY": "Retry",
|
||||
"DETAILS_SUMMARY": "Technical details",
|
||||
"CODEC_HINT": "Detected {{codecs}}. Chromium/Electron browser players often have limited support for these codecs.",
|
||||
"DETAIL_CODE": "Diagnostic code",
|
||||
|
||||
@@ -395,9 +395,14 @@
|
||||
"TITLE": "Браузерный плеер сообщил ошибку воспроизведения",
|
||||
"DESCRIPTION": "Поток не воспроизвелся во встроенном плеере, но ошибка не указала конкретную причину в кодеке, контейнере или сети."
|
||||
},
|
||||
"NATIVE_FALLBACK_TITLE": "Откройте поток во внешнем плеере",
|
||||
"INLINE_FAILURE_TITLE": "Проверьте поток или повторите попытку",
|
||||
"ACTION_OPEN_MPV": "Открыть в MPV",
|
||||
"ACTION_OPEN_VLC": "Открыть в VLC",
|
||||
"ACTION_OPEN_MPV_HINT": "настроенный плеер",
|
||||
"ACTION_OPEN_VLC_HINT": "настроенный плеер",
|
||||
"ACTION_COPY_URL": "Скопировать URL",
|
||||
"ACTION_RETRY": "Повторить",
|
||||
"DETAILS_SUMMARY": "Технические сведения",
|
||||
"CODEC_HINT": "Обнаружены {{codecs}}. Браузерные плееры Chromium/Electron часто имеют ограниченную поддержку этих кодеков.",
|
||||
"DETAIL_CODE": "Код диагностики",
|
||||
|
||||
@@ -395,9 +395,14 @@
|
||||
"TITLE": "The browser player reported a playback error",
|
||||
"DESCRIPTION": "The stream failed in the built-in player, but the error did not identify a specific codec, container, or network cause."
|
||||
},
|
||||
"NATIVE_FALLBACK_TITLE": "Open it in a native player instead",
|
||||
"INLINE_FAILURE_TITLE": "Check the stream or try again",
|
||||
"ACTION_OPEN_MPV": "Open in MPV",
|
||||
"ACTION_OPEN_VLC": "Open in VLC",
|
||||
"ACTION_OPEN_MPV_HINT": "configured player",
|
||||
"ACTION_OPEN_VLC_HINT": "configured player",
|
||||
"ACTION_COPY_URL": "Copy URL",
|
||||
"ACTION_RETRY": "Retry",
|
||||
"DETAILS_SUMMARY": "Technical details",
|
||||
"CODEC_HINT": "Detected {{codecs}}. Chromium/Electron browser players often have limited support for these codecs.",
|
||||
"DETAIL_CODE": "Diagnostic code",
|
||||
|
||||
@@ -395,9 +395,14 @@
|
||||
"TITLE": "The browser player reported a playback error",
|
||||
"DESCRIPTION": "The stream failed in the built-in player, but the error did not identify a specific codec, container, or network cause."
|
||||
},
|
||||
"NATIVE_FALLBACK_TITLE": "Open it in a native player instead",
|
||||
"INLINE_FAILURE_TITLE": "Check the stream or try again",
|
||||
"ACTION_OPEN_MPV": "Open in MPV",
|
||||
"ACTION_OPEN_VLC": "Open in VLC",
|
||||
"ACTION_OPEN_MPV_HINT": "configured player",
|
||||
"ACTION_OPEN_VLC_HINT": "configured player",
|
||||
"ACTION_COPY_URL": "Copy URL",
|
||||
"ACTION_RETRY": "Retry",
|
||||
"DETAILS_SUMMARY": "Technical details",
|
||||
"CODEC_HINT": "Detected {{codecs}}. Chromium/Electron browser players often have limited support for these codecs.",
|
||||
"DETAIL_CODE": "Diagnostic code",
|
||||
|
||||
@@ -395,9 +395,14 @@
|
||||
"TITLE": "The browser player reported a playback error",
|
||||
"DESCRIPTION": "The stream failed in the built-in player, but the error did not identify a specific codec, container, or network cause."
|
||||
},
|
||||
"NATIVE_FALLBACK_TITLE": "Open it in a native player instead",
|
||||
"INLINE_FAILURE_TITLE": "Check the stream or try again",
|
||||
"ACTION_OPEN_MPV": "Open in MPV",
|
||||
"ACTION_OPEN_VLC": "Open in VLC",
|
||||
"ACTION_OPEN_MPV_HINT": "configured player",
|
||||
"ACTION_OPEN_VLC_HINT": "configured player",
|
||||
"ACTION_COPY_URL": "Copy URL",
|
||||
"ACTION_RETRY": "Retry",
|
||||
"DETAILS_SUMMARY": "Technical details",
|
||||
"CODEC_HINT": "Detected {{codecs}}. Chromium/Electron browser players often have limited support for these codecs.",
|
||||
"DETAIL_CODE": "Diagnostic code",
|
||||
|
||||
@@ -138,7 +138,7 @@ and playback-position saving keep the same behavior as manual episode clicks.
|
||||
|
||||
## Codec And Container Diagnostics
|
||||
|
||||
The shared `WebPlayerViewComponent` is the central browser-player viewport for M3U, Xtream, and Stalker inline playback. Video.js, HTML5, and ArtPlayer report native media errors, HLS.js errors, mpegts.js errors, and HLS manifest codec metadata into the shared diagnostics classifier.
|
||||
The shared `WebPlayerViewComponent` is the central browser-player viewport for M3U, Xtream, and Stalker inline playback, including live streams opened from favorites and recently viewed collections. Video.js, HTML5, and ArtPlayer report native media errors, HLS.js errors, mpegts.js errors, and HLS manifest codec metadata into the shared diagnostics classifier.
|
||||
|
||||
The diagnostics remain client-only:
|
||||
|
||||
@@ -161,13 +161,13 @@ Supported diagnostic codes are:
|
||||
|
||||
mpegts.js `Early-EOF` failures on MPEG-TS streams are classified as `media-decode-error` instead of generic `network-error`. These failures usually mean the fetch stream ended before mpegts.js expected a complete transport stream, and external players may still handle the same URL more tolerant of short reads or malformed TS boundaries.
|
||||
|
||||
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.
|
||||
The diagnostic surface covers the inline player viewport when playback fails, with a compact warning badge, a native-player fallback headline, and player-card actions for configured external players. It 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.
|
||||
|
||||
Portal VOD and episode payloads with `contentInfo` are treated as non-live by the Video.js MPEG-TS path unless `isLive` is explicitly set. If Chromium leaves the underlying MediaSource duration at `Infinity` for a finite TS VOD, the Video.js wrapper normalizes its UI duration from the finite `seekable` or `buffered` range. This removes the misleading `LIVE` control state without changing stream decoding, diagnostics, or external fallback behavior.
|
||||
|
||||
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.
|
||||
When a diagnostic is actionable in Electron, the diagnostic surface may offer `Open in MPV`, `Open in VLC`, `Copy URL`, technical details, and `Retry`. Web builds only expose copy/help text and retry. MPV/VLC fallback requests carry the original `ResolvedPortalPlayback` payload so headers, referer, origin, user-agent, content metadata, and resume offset stay intact. Retry clears the current diagnostic and rebuilds the active inline player inputs; it does not change the saved player setting.
|
||||
|
||||
`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.
|
||||
|
||||
|
||||
+9
-34
@@ -56,40 +56,15 @@
|
||||
"
|
||||
/>
|
||||
} @else {
|
||||
@if (player() === 'videojs') {
|
||||
<app-vjs-player
|
||||
[options]="{
|
||||
sources: [
|
||||
{
|
||||
src: currentStreamUrl(),
|
||||
type: 'application/x-mpegURL',
|
||||
},
|
||||
],
|
||||
}"
|
||||
[volume]="1"
|
||||
/>
|
||||
}
|
||||
@if (player() === 'html5') {
|
||||
<app-html-video-player
|
||||
[channel]="activeChannelForOverlay()!"
|
||||
[volume]="1"
|
||||
[showCaptions]="false"
|
||||
/>
|
||||
}
|
||||
@if (player() === 'artplayer') {
|
||||
<app-art-player
|
||||
[channel]="activeChannelForOverlay()!"
|
||||
[volume]="1"
|
||||
[showCaptions]="false"
|
||||
/>
|
||||
}
|
||||
@if (player() === 'embedded-mpv') {
|
||||
<app-web-player-view
|
||||
[streamUrl]="currentStreamUrl()"
|
||||
[title]="activeDetail()?.playback?.title ?? ''"
|
||||
[playback]="activeDetail()?.playback ?? null"
|
||||
/>
|
||||
}
|
||||
<app-web-player-view
|
||||
[streamUrl]="currentStreamUrl()"
|
||||
[title]="activeDetail()?.playback?.title ?? ''"
|
||||
[playback]="activeDetail()?.playback ?? null"
|
||||
[playerOverride]="player()"
|
||||
(externalFallbackRequested)="
|
||||
handleExternalFallbackRequest($event)
|
||||
"
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
+103
-50
@@ -9,16 +9,9 @@ import {
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { By } from '@angular/platform-browser';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import {
|
||||
GlobalFavoritesListComponent,
|
||||
UnifiedLiveTabComponent,
|
||||
} from '@iptvnator/portal/shared/ui';
|
||||
import {
|
||||
AudioPlayerComponent,
|
||||
ArtPlayerComponent,
|
||||
HtmlVideoPlayerComponent,
|
||||
VjsPlayerComponent,
|
||||
} from '@iptvnator/ui/playback';
|
||||
import { GlobalFavoritesListComponent } from '../global-favorites-list/global-favorites-list.component';
|
||||
import { UnifiedLiveTabComponent } from './unified-live-tab.component';
|
||||
import { AudioPlayerComponent } from '@iptvnator/ui/playback';
|
||||
import {
|
||||
EpgDateNavigationDirection,
|
||||
EpgListComponent,
|
||||
@@ -27,11 +20,19 @@ import {
|
||||
} from '@iptvnator/ui/epg';
|
||||
import { ResizableDirective } from '@iptvnator/ui/components';
|
||||
import { SettingsStore } from '@iptvnator/services';
|
||||
import { Channel, EpgItem, EpgProgram } from '@iptvnator/shared/interfaces';
|
||||
import {
|
||||
Channel,
|
||||
EpgItem,
|
||||
EpgProgram,
|
||||
ResolvedPortalPlayback,
|
||||
VideoPlayer,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import {
|
||||
EpgViewComponent,
|
||||
LiveEpgPanelComponent,
|
||||
LiveEpgPanelSummary,
|
||||
type PlaybackFallbackRequest,
|
||||
WebPlayerViewComponent,
|
||||
} from '@iptvnator/ui/shared-portals';
|
||||
import {
|
||||
DEFAULT_FAVORITES_CHANNEL_SORT_MODE,
|
||||
@@ -115,38 +116,6 @@ class StubLiveEpgPanelComponent {
|
||||
readonly dateNavigation = output<EpgDateNavigationDirection>();
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-vjs-player',
|
||||
template: '',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
class StubVjsPlayerComponent {
|
||||
readonly options = input<unknown>();
|
||||
readonly volume = input(1);
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-html-video-player',
|
||||
template: '',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
class StubHtmlVideoPlayerComponent {
|
||||
readonly channel = input<Channel | null>(null);
|
||||
readonly volume = input(1);
|
||||
readonly showCaptions = input(false);
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-art-player',
|
||||
template: '',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
class StubArtPlayerComponent {
|
||||
readonly channel = input<Channel | null>(null);
|
||||
readonly volume = input(1);
|
||||
readonly showCaptions = input(false);
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-audio-player',
|
||||
template: '<div class="stub-audio-player"></div>',
|
||||
@@ -158,9 +127,23 @@ class StubAudioPlayerComponent {
|
||||
readonly channelName = input('');
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-web-player-view',
|
||||
template: '<div class="stub-web-player-view"></div>',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
class StubWebPlayerViewComponent {
|
||||
readonly streamUrl = input.required<string>();
|
||||
readonly title = input('');
|
||||
readonly playback = input<ResolvedPortalPlayback | null>(null);
|
||||
readonly playerOverride = input<VideoPlayer | null>(null);
|
||||
readonly externalFallbackRequested = output<PlaybackFallbackRequest>();
|
||||
}
|
||||
|
||||
describe('UnifiedLiveTabComponent', () => {
|
||||
let fixture: ComponentFixture<UnifiedLiveTabComponent>;
|
||||
let component: UnifiedLiveTabComponent;
|
||||
let player: ReturnType<typeof signal<VideoPlayer>>;
|
||||
let streamResolver: {
|
||||
resolveLiveDetail: jest.Mock;
|
||||
resolveM3uPlaybackDetail: jest.Mock;
|
||||
@@ -173,6 +156,7 @@ describe('UnifiedLiveTabComponent', () => {
|
||||
let portalPlayer: {
|
||||
isEmbeddedPlayer: jest.Mock;
|
||||
openResolvedPlayback: jest.Mock;
|
||||
openExternalPlayback: jest.Mock;
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
@@ -187,9 +171,11 @@ describe('UnifiedLiveTabComponent', () => {
|
||||
recentData = {
|
||||
recordLivePlayback: jest.fn(),
|
||||
};
|
||||
player = signal(VideoPlayer.VideoJs);
|
||||
portalPlayer = {
|
||||
isEmbeddedPlayer: jest.fn().mockReturnValue(false),
|
||||
openResolvedPlayback: jest.fn(),
|
||||
openExternalPlayback: jest.fn(),
|
||||
};
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
@@ -201,7 +187,7 @@ describe('UnifiedLiveTabComponent', () => {
|
||||
provide: SettingsStore,
|
||||
useValue: {
|
||||
openStreamOnDoubleClick: signal(false),
|
||||
player: signal('videojs'),
|
||||
player,
|
||||
},
|
||||
},
|
||||
{ provide: PORTAL_PLAYER, useValue: portalPlayer },
|
||||
@@ -211,27 +197,23 @@ describe('UnifiedLiveTabComponent', () => {
|
||||
remove: {
|
||||
imports: [
|
||||
AudioPlayerComponent,
|
||||
ArtPlayerComponent,
|
||||
EpgListComponent,
|
||||
EpgViewComponent,
|
||||
GlobalFavoritesListComponent,
|
||||
HtmlVideoPlayerComponent,
|
||||
LiveEpgPanelComponent,
|
||||
ResizableDirective,
|
||||
VjsPlayerComponent,
|
||||
WebPlayerViewComponent,
|
||||
],
|
||||
},
|
||||
add: {
|
||||
imports: [
|
||||
StubAudioPlayerComponent,
|
||||
StubArtPlayerComponent,
|
||||
StubEpgListComponent,
|
||||
StubEpgViewComponent,
|
||||
StubGlobalFavoritesListComponent,
|
||||
StubHtmlVideoPlayerComponent,
|
||||
StubLiveEpgPanelComponent,
|
||||
StubResizableDirective,
|
||||
StubVjsPlayerComponent,
|
||||
StubWebPlayerViewComponent,
|
||||
],
|
||||
},
|
||||
})
|
||||
@@ -455,6 +437,77 @@ describe('UnifiedLiveTabComponent', () => {
|
||||
expect(localStorage.getItem('live-epg-panel-state')).toBe('collapsed');
|
||||
});
|
||||
|
||||
it('uses the shared web player wrapper for inline live playback diagnostics', async () => {
|
||||
portalPlayer.isEmbeddedPlayer.mockReturnValue(true);
|
||||
player.set(VideoPlayer.VideoJs);
|
||||
const item = buildLiveItem('xtream');
|
||||
streamResolver.resolveLiveDetail.mockResolvedValue({
|
||||
epgMode: 'portal',
|
||||
playback: {
|
||||
streamUrl: 'https://example.com/xtream.m3u8',
|
||||
title: 'Xtream Live',
|
||||
},
|
||||
epgItems: [],
|
||||
});
|
||||
recentData.recordLivePlayback.mockResolvedValue({
|
||||
...item,
|
||||
viewedAt: '2026-03-26T12:00:00.000Z',
|
||||
});
|
||||
|
||||
fixture.componentRef.setInput('items', [item]);
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
|
||||
await component.onChannelSelected(component.channelsForList()[0]);
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
|
||||
const webPlayer = fixture.debugElement.query(
|
||||
By.directive(StubWebPlayerViewComponent)
|
||||
).componentInstance as StubWebPlayerViewComponent;
|
||||
|
||||
expect(webPlayer.streamUrl()).toBe('https://example.com/xtream.m3u8');
|
||||
expect(webPlayer.title()).toBe('Xtream Live');
|
||||
const playback = webPlayer.playback();
|
||||
expect(playback).toEqual(
|
||||
expect.objectContaining({
|
||||
streamUrl: 'https://example.com/xtream.m3u8',
|
||||
title: 'Xtream Live',
|
||||
})
|
||||
);
|
||||
if (!playback) {
|
||||
throw new Error('Expected wrapper playback to be set');
|
||||
}
|
||||
expect(webPlayer.playerOverride()).toBe(VideoPlayer.VideoJs);
|
||||
expect(fixture.nativeElement.querySelector('app-vjs-player')).toBeNull();
|
||||
expect(
|
||||
fixture.nativeElement.querySelector('app-html-video-player')
|
||||
).toBeNull();
|
||||
expect(fixture.nativeElement.querySelector('app-art-player')).toBeNull();
|
||||
|
||||
webPlayer.externalFallbackRequested.emit({
|
||||
player: 'mpv',
|
||||
playback,
|
||||
diagnostic: {
|
||||
code: 'network-error',
|
||||
player: 'videojs',
|
||||
source: 'hls',
|
||||
container: '',
|
||||
mimeType: '',
|
||||
videoCodecs: [],
|
||||
audioCodecs: [],
|
||||
externalFallbackRecommended: false,
|
||||
},
|
||||
});
|
||||
|
||||
expect(portalPlayer.openExternalPlayback).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
streamUrl: 'https://example.com/xtream.m3u8',
|
||||
}),
|
||||
'mpv'
|
||||
);
|
||||
});
|
||||
|
||||
it('does not wait for M3U program lookup before opening playback', async () => {
|
||||
const item = buildLiveItem('m3u');
|
||||
const pendingPrograms = createDeferred<EpgProgram[]>();
|
||||
+15
-48
@@ -37,20 +37,16 @@ import {
|
||||
} from '@iptvnator/ui/epg';
|
||||
import { GlobalFavoritesListComponent } from '../global-favorites-list/global-favorites-list.component';
|
||||
import { PortalEmptyStateComponent } from '../portal-empty-state/portal-empty-state.component';
|
||||
import {
|
||||
AudioPlayerComponent,
|
||||
ArtPlayerComponent,
|
||||
HtmlVideoPlayerComponent,
|
||||
VjsPlayerComponent,
|
||||
WebPlayerViewComponent,
|
||||
} from '@iptvnator/ui/playback';
|
||||
import { AudioPlayerComponent } from '@iptvnator/ui/playback';
|
||||
import { ResizableDirective } from '@iptvnator/ui/components';
|
||||
import { SettingsStore } from '@iptvnator/services';
|
||||
import { Channel, EpgItem, EpgProgram } from '@iptvnator/shared/interfaces';
|
||||
import { EpgItem, EpgProgram } from '@iptvnator/shared/interfaces';
|
||||
import {
|
||||
EpgViewComponent,
|
||||
LiveEpgPanelComponent,
|
||||
LiveEpgPanelSummary,
|
||||
type PlaybackFallbackRequest,
|
||||
WebPlayerViewComponent,
|
||||
} from '@iptvnator/ui/shared-portals';
|
||||
|
||||
@Component({
|
||||
@@ -60,11 +56,9 @@ import {
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [
|
||||
AudioPlayerComponent,
|
||||
ArtPlayerComponent,
|
||||
EpgListComponent,
|
||||
EpgViewComponent,
|
||||
GlobalFavoritesListComponent,
|
||||
HtmlVideoPlayerComponent,
|
||||
LiveEpgPanelComponent,
|
||||
MatButtonModule,
|
||||
MatIconModule,
|
||||
@@ -72,7 +66,6 @@ import {
|
||||
PortalEmptyStateComponent,
|
||||
ResizableDirective,
|
||||
TranslatePipe,
|
||||
VjsPlayerComponent,
|
||||
WebPlayerViewComponent,
|
||||
],
|
||||
})
|
||||
@@ -163,38 +156,6 @@ export class UnifiedLiveTabComponent {
|
||||
return this.getLiveEpgPanelSummary(this.activeDetail());
|
||||
});
|
||||
|
||||
readonly activeChannelForOverlay = computed((): Channel | undefined => {
|
||||
const detail = this.activeDetail();
|
||||
if (!detail) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (detail.channel) {
|
||||
return detail.channel;
|
||||
}
|
||||
|
||||
return {
|
||||
id: this.activeUid() ?? '',
|
||||
name: detail.playback.title ?? '',
|
||||
url: detail.playback.streamUrl,
|
||||
tvg: {
|
||||
logo: detail.playback.thumbnail ?? '',
|
||||
id: '',
|
||||
name: '',
|
||||
rec: '',
|
||||
url: '',
|
||||
},
|
||||
group: { title: '' },
|
||||
http: {
|
||||
referrer: detail.playback.referer ?? '',
|
||||
'user-agent': detail.playback.userAgent ?? '',
|
||||
origin: detail.playback.origin ?? '',
|
||||
},
|
||||
radio: 'false',
|
||||
epgParams: '',
|
||||
} satisfies Channel;
|
||||
});
|
||||
|
||||
readonly channelsForList = computed((): UnifiedFavoriteChannel[] =>
|
||||
this.items().map((item) => ({
|
||||
uid: item.uid,
|
||||
@@ -331,6 +292,13 @@ export class UnifiedLiveTabComponent {
|
||||
this.selectedLiveEpgDate.set(selectedDate);
|
||||
}
|
||||
|
||||
handleExternalFallbackRequest(request: PlaybackFallbackRequest): void {
|
||||
void this.portalPlayer.openExternalPlayback(
|
||||
request.playback,
|
||||
request.player
|
||||
);
|
||||
}
|
||||
|
||||
onClose(): void {
|
||||
this.selectionRequestId += 1;
|
||||
this.isSelecting.set(false);
|
||||
@@ -348,14 +316,13 @@ export class UnifiedLiveTabComponent {
|
||||
isAutoOpen = false,
|
||||
startPlayback = false
|
||||
): Promise<void> {
|
||||
if (this.activeUid() === item.uid && this.activeDetail()) {
|
||||
const activeDetail = this.activeDetail();
|
||||
if (this.activeUid() === item.uid && activeDetail) {
|
||||
if (
|
||||
startPlayback &&
|
||||
this.shouldOpenExternalPlayback(this.activeDetail()!, true)
|
||||
this.shouldOpenExternalPlayback(activeDetail, true)
|
||||
) {
|
||||
void this.portalPlayer.openResolvedPlayback(
|
||||
this.activeDetail()!.playback
|
||||
);
|
||||
void this.portalPlayer.openResolvedPlayback(activeDetail.playback);
|
||||
}
|
||||
if (isAutoOpen) {
|
||||
this.autoOpenHandled.emit();
|
||||
|
||||
@@ -46,21 +46,26 @@
|
||||
data-test-id="playback-diagnostic-banner"
|
||||
role="status"
|
||||
>
|
||||
<div class="web-player-diagnostic__icon" aria-hidden="true">
|
||||
<mat-icon>warning</mat-icon>
|
||||
</div>
|
||||
<div class="web-player-diagnostic__body">
|
||||
<div class="web-player-diagnostic__title">
|
||||
{{ getDiagnosticTitleKey(issue) | translate }}
|
||||
<div class="web-player-diagnostic__content">
|
||||
<div class="web-player-diagnostic__badge">
|
||||
<mat-icon aria-hidden="true">warning</mat-icon>
|
||||
<span>{{ getDiagnosticTitleKey(issue) | translate }}</span>
|
||||
</div>
|
||||
|
||||
<h2 class="web-player-diagnostic__headline">
|
||||
{{ getDiagnosticHeadlineKey() | translate }}
|
||||
</h2>
|
||||
|
||||
<div class="web-player-diagnostic__description">
|
||||
{{ getDiagnosticDescriptionKey(issue) | translate }}
|
||||
</div>
|
||||
|
||||
@if (getDiagnosticMeta(issue); as meta) {
|
||||
<div class="web-player-diagnostic__meta">
|
||||
{{ meta }}
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (getDiagnosticCodecHint(issue); as codecHint) {
|
||||
<div
|
||||
class="web-player-diagnostic__codec-hint"
|
||||
@@ -72,58 +77,113 @@
|
||||
}}
|
||||
</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()) {
|
||||
<button
|
||||
mat-stroked-button
|
||||
type="button"
|
||||
data-test-id="playback-fallback-mpv"
|
||||
(click)="requestExternalFallback('mpv')"
|
||||
<div
|
||||
class="web-player-diagnostic__player-actions"
|
||||
aria-label="External player fallbacks"
|
||||
>
|
||||
<mat-icon>open_in_new</mat-icon>
|
||||
{{ 'PLAYBACK_DIAGNOSTICS.ACTION_OPEN_MPV' | translate }}
|
||||
</button>
|
||||
<button
|
||||
mat-stroked-button
|
||||
type="button"
|
||||
data-test-id="playback-fallback-vlc"
|
||||
(click)="requestExternalFallback('vlc')"
|
||||
>
|
||||
<mat-icon>open_in_new</mat-icon>
|
||||
{{ 'PLAYBACK_DIAGNOSTICS.ACTION_OPEN_VLC' | translate }}
|
||||
</button>
|
||||
<button
|
||||
class="web-player-diagnostic__player-card web-player-diagnostic__player-card--primary"
|
||||
type="button"
|
||||
data-test-id="playback-fallback-mpv"
|
||||
(click)="requestExternalFallback('mpv')"
|
||||
>
|
||||
<mat-icon aria-hidden="true">open_in_new</mat-icon>
|
||||
<span class="web-player-diagnostic__player-copy">
|
||||
<span class="web-player-diagnostic__player-label">
|
||||
{{
|
||||
'PLAYBACK_DIAGNOSTICS.ACTION_OPEN_MPV'
|
||||
| translate
|
||||
}}
|
||||
</span>
|
||||
<span class="web-player-diagnostic__player-hint">
|
||||
{{
|
||||
'PLAYBACK_DIAGNOSTICS.ACTION_OPEN_MPV_HINT'
|
||||
| translate
|
||||
}}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="web-player-diagnostic__player-card"
|
||||
type="button"
|
||||
data-test-id="playback-fallback-vlc"
|
||||
(click)="requestExternalFallback('vlc')"
|
||||
>
|
||||
<mat-icon aria-hidden="true">open_in_new</mat-icon>
|
||||
<span class="web-player-diagnostic__player-copy">
|
||||
<span class="web-player-diagnostic__player-label">
|
||||
{{
|
||||
'PLAYBACK_DIAGNOSTICS.ACTION_OPEN_VLC'
|
||||
| translate
|
||||
}}
|
||||
</span>
|
||||
<span class="web-player-diagnostic__player-hint">
|
||||
{{
|
||||
'PLAYBACK_DIAGNOSTICS.ACTION_OPEN_VLC_HINT'
|
||||
| translate
|
||||
}}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
<button
|
||||
mat-button
|
||||
type="button"
|
||||
[cdkCopyToClipboard]="resolvedPlayback().streamUrl"
|
||||
[matTooltip]="resolvedPlayback().streamUrl"
|
||||
data-test-id="playback-copy-url"
|
||||
>
|
||||
<mat-icon>content_copy</mat-icon>
|
||||
{{ 'PLAYBACK_DIAGNOSTICS.ACTION_COPY_URL' | translate }}
|
||||
</button>
|
||||
|
||||
<div class="web-player-diagnostic__utilities">
|
||||
<button
|
||||
class="web-player-diagnostic__utility-button"
|
||||
type="button"
|
||||
[cdkCopyToClipboard]="resolvedPlayback().streamUrl"
|
||||
[matTooltip]="resolvedPlayback().streamUrl"
|
||||
data-test-id="playback-copy-url"
|
||||
>
|
||||
<mat-icon aria-hidden="true">content_copy</mat-icon>
|
||||
{{ 'PLAYBACK_DIAGNOSTICS.ACTION_COPY_URL' | translate }}
|
||||
</button>
|
||||
|
||||
<span
|
||||
class="web-player-diagnostic__divider"
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
|
||||
<button
|
||||
class="web-player-diagnostic__utility-button"
|
||||
type="button"
|
||||
data-test-id="playback-retry"
|
||||
(click)="retryPlayback()"
|
||||
>
|
||||
<mat-icon aria-hidden="true">refresh</mat-icon>
|
||||
{{ 'PLAYBACK_DIAGNOSTICS.ACTION_RETRY' | translate }}
|
||||
</button>
|
||||
|
||||
<span
|
||||
class="web-player-diagnostic__divider"
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
|
||||
<details
|
||||
class="web-player-diagnostic__details"
|
||||
data-test-id="playback-diagnostic-details"
|
||||
>
|
||||
<summary>
|
||||
<mat-icon aria-hidden="true">expand_more</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>
|
||||
</section>
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
.web-player-view {
|
||||
container-type: inline-size;
|
||||
display: block;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
@@ -15,94 +16,235 @@ app-html-video-player {
|
||||
|
||||
.web-player-diagnostic {
|
||||
position: absolute;
|
||||
inset-inline: 16px;
|
||||
bottom: var(--web-player-diagnostic-bottom, 16px);
|
||||
inset: 0;
|
||||
z-index: 5;
|
||||
display: grid;
|
||||
grid-template-areas:
|
||||
'icon body'
|
||||
'icon actions';
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
gap: 8px 12px;
|
||||
align-items: start;
|
||||
padding: 14px 16px;
|
||||
max-block-size: calc(100% - 32px);
|
||||
overflow: auto;
|
||||
border: 1px solid color-mix(in srgb, var(--mat-sys-error) 36%, transparent);
|
||||
border-radius: 8px;
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--mat-sys-error-container) 92%,
|
||||
transparent
|
||||
);
|
||||
color: var(--mat-sys-on-error-container);
|
||||
box-shadow: 0 16px 42px rgb(0 0 0 / 28%);
|
||||
}
|
||||
|
||||
.web-player-diagnostic__icon {
|
||||
grid-area: icon;
|
||||
display: inline-flex;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding-top: 2px;
|
||||
padding: clamp(24px, 9vh, 92px) clamp(20px, 18vw, 216px);
|
||||
overflow: auto;
|
||||
background:
|
||||
radial-gradient(
|
||||
circle at 18% 18%,
|
||||
rgb(255 176 76 / 6%),
|
||||
transparent 28%
|
||||
),
|
||||
rgb(0 0 0 / 96%);
|
||||
color: #f6f6f6;
|
||||
}
|
||||
|
||||
.web-player-diagnostic__body {
|
||||
grid-area: body;
|
||||
max-width: 640px;
|
||||
.web-player-diagnostic__content {
|
||||
width: min(100%, 720px);
|
||||
}
|
||||
|
||||
.web-player-diagnostic__title {
|
||||
font-size: 0.92rem;
|
||||
font-weight: 650;
|
||||
line-height: 1.2;
|
||||
.web-player-diagnostic__badge {
|
||||
display: inline-flex;
|
||||
max-width: 100%;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 5px 11px;
|
||||
border: 1px solid rgb(255 174 66 / 42%);
|
||||
border-radius: 6px;
|
||||
background: rgb(255 148 35 / 15%);
|
||||
color: #ffb24c;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0;
|
||||
line-height: 1.1;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.web-player-diagnostic__description,
|
||||
.web-player-diagnostic__meta,
|
||||
.web-player-diagnostic__codec-hint {
|
||||
margin-top: 3px;
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.3;
|
||||
.web-player-diagnostic__badge mat-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
flex: 0 0 16px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.web-player-diagnostic__badge span {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.web-player-diagnostic__headline {
|
||||
max-width: 660px;
|
||||
margin: 20px 0 0;
|
||||
color: #fff;
|
||||
font-size: 1.65rem;
|
||||
font-weight: 760;
|
||||
letter-spacing: 0;
|
||||
line-height: 1.05;
|
||||
}
|
||||
|
||||
.web-player-diagnostic__description {
|
||||
max-width: 760px;
|
||||
margin-top: 12px;
|
||||
color: rgb(255 255 255 / 73%);
|
||||
font-size: 1rem;
|
||||
line-height: 1.48;
|
||||
}
|
||||
|
||||
.web-player-diagnostic__meta {
|
||||
opacity: 0.78;
|
||||
margin-top: 9px;
|
||||
color: rgb(255 255 255 / 48%);
|
||||
font-family: var(--app-monospace-font, monospace);
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.35;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.web-player-diagnostic__codec-hint {
|
||||
display: inline-block;
|
||||
max-width: 100%;
|
||||
margin-top: 8px;
|
||||
margin-top: 12px;
|
||||
padding: 4px 8px;
|
||||
border: 1px solid
|
||||
color-mix(in srgb, var(--mat-sys-on-error-container) 18%, transparent);
|
||||
border: 1px solid rgb(255 255 255 / 12%);
|
||||
border-radius: 6px;
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--mat-sys-on-error-container) 7%,
|
||||
transparent
|
||||
);
|
||||
font-weight: 550;
|
||||
background: rgb(255 255 255 / 5%);
|
||||
color: rgb(255 255 255 / 68%);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 620;
|
||||
line-height: 1.3;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.web-player-diagnostic__player-actions {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(168px, 192px));
|
||||
gap: 12px;
|
||||
margin-top: 28px;
|
||||
}
|
||||
|
||||
.web-player-diagnostic__player-card {
|
||||
display: grid;
|
||||
min-height: 60px;
|
||||
grid-template-columns: 22px minmax(0, 1fr);
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 16px;
|
||||
border: 1px solid rgb(255 255 255 / 13%);
|
||||
border-radius: 8px;
|
||||
background: rgb(0 0 0 / 22%);
|
||||
color: #f5f5f5;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition:
|
||||
background-color 140ms ease,
|
||||
border-color 140ms ease,
|
||||
color 140ms ease,
|
||||
transform 140ms ease;
|
||||
}
|
||||
|
||||
.web-player-diagnostic__player-card:hover {
|
||||
border-color: rgb(255 255 255 / 28%);
|
||||
background: rgb(255 255 255 / 6%);
|
||||
}
|
||||
|
||||
.web-player-diagnostic__player-card:active {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
|
||||
.web-player-diagnostic__player-card:focus-visible,
|
||||
.web-player-diagnostic__utility-button:focus-visible,
|
||||
.web-player-diagnostic__details summary:focus-visible {
|
||||
outline: 2px solid #ffb24c;
|
||||
outline-offset: 3px;
|
||||
}
|
||||
|
||||
.web-player-diagnostic__player-card--primary {
|
||||
border-color: transparent;
|
||||
background: #fff;
|
||||
color: #151515;
|
||||
}
|
||||
|
||||
.web-player-diagnostic__player-card--primary:hover {
|
||||
border-color: transparent;
|
||||
background: rgb(255 255 255 / 88%);
|
||||
}
|
||||
|
||||
.web-player-diagnostic__player-card mat-icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.web-player-diagnostic__player-copy {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.web-player-diagnostic__player-label {
|
||||
overflow: hidden;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 760;
|
||||
line-height: 1.05;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.web-player-diagnostic__player-hint {
|
||||
overflow: hidden;
|
||||
color: currentColor;
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.1;
|
||||
opacity: 0.62;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.web-player-diagnostic__utilities {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-start;
|
||||
gap: 8px 12px;
|
||||
margin-top: 22px;
|
||||
color: rgb(255 255 255 / 45%);
|
||||
}
|
||||
|
||||
.web-player-diagnostic__utility-button,
|
||||
.web-player-diagnostic__details summary {
|
||||
display: inline-flex;
|
||||
min-height: 24px;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-size: 0.86rem;
|
||||
font-weight: 640;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.web-player-diagnostic__utility-button:hover,
|
||||
.web-player-diagnostic__details summary:hover {
|
||||
color: rgb(255 255 255 / 76%);
|
||||
}
|
||||
|
||||
.web-player-diagnostic__utility-button mat-icon,
|
||||
.web-player-diagnostic__details summary mat-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.web-player-diagnostic__divider {
|
||||
width: 1px;
|
||||
height: 18px;
|
||||
margin-top: 3px;
|
||||
background: rgb(255 255 255 / 16%);
|
||||
}
|
||||
|
||||
.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;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
@@ -110,19 +252,23 @@ app-html-video-player {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.web-player-diagnostic__details summary mat-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
font-size: 16px;
|
||||
.web-player-diagnostic__details[open] {
|
||||
flex-basis: 100%;
|
||||
}
|
||||
|
||||
.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;
|
||||
gap: 7px 14px;
|
||||
max-width: min(100%, 640px);
|
||||
margin: 12px 0 0;
|
||||
padding: 12px;
|
||||
border: 1px solid rgb(255 255 255 / 10%);
|
||||
border-radius: 8px;
|
||||
background: rgb(255 255 255 / 5%);
|
||||
color: rgb(255 255 255 / 68%);
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.web-player-diagnostic__detail-row {
|
||||
@@ -130,12 +276,8 @@ app-html-video-player {
|
||||
}
|
||||
|
||||
.web-player-diagnostic__detail-row dt {
|
||||
color: color-mix(
|
||||
in srgb,
|
||||
var(--mat-sys-on-error-container) 76%,
|
||||
transparent
|
||||
);
|
||||
font-weight: 650;
|
||||
color: rgb(255 255 255 / 48%);
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@@ -145,34 +287,32 @@ app-html-video-player {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.web-player-diagnostic__actions {
|
||||
grid-area: actions;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-start;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.web-player-diagnostic__actions button {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
@container (max-width: 760px) {
|
||||
.web-player-diagnostic {
|
||||
inset-inline: 8px;
|
||||
bottom: var(--web-player-diagnostic-bottom, 8px);
|
||||
max-block-size: calc(100% - 16px);
|
||||
grid-template-areas:
|
||||
'icon body'
|
||||
'actions actions';
|
||||
align-items: flex-start;
|
||||
padding: 22px 16px;
|
||||
}
|
||||
|
||||
.web-player-diagnostic__actions {
|
||||
justify-content: flex-start;
|
||||
.web-player-diagnostic__badge {
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.web-player-diagnostic__badge span {
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.web-player-diagnostic__headline {
|
||||
margin-top: 16px;
|
||||
font-size: 1.48rem;
|
||||
}
|
||||
|
||||
.web-player-diagnostic__utilities {
|
||||
gap: 10px 12px;
|
||||
}
|
||||
|
||||
.web-player-diagnostic__details dl {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.web-player-diagnostic__detail-row {
|
||||
@@ -184,3 +324,116 @@ app-html-video-player {
|
||||
white-space: normal;
|
||||
}
|
||||
}
|
||||
|
||||
@container (max-width: 520px) {
|
||||
.web-player-diagnostic {
|
||||
align-items: flex-start;
|
||||
padding: clamp(14px, 5cqi, 22px);
|
||||
}
|
||||
|
||||
.web-player-diagnostic__content {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.web-player-diagnostic__badge {
|
||||
gap: 6px;
|
||||
padding: 4px 8px;
|
||||
font-size: 0.7rem;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.web-player-diagnostic__badge span {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.web-player-diagnostic__headline {
|
||||
margin-top: 14px;
|
||||
font-size: 1.35rem;
|
||||
line-height: 1.08;
|
||||
}
|
||||
|
||||
.web-player-diagnostic__description {
|
||||
margin-top: 10px;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.38;
|
||||
}
|
||||
|
||||
.web-player-diagnostic__meta {
|
||||
margin-top: 8px;
|
||||
font-size: 0.74rem;
|
||||
}
|
||||
|
||||
.web-player-diagnostic__codec-hint {
|
||||
margin-top: 10px;
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
|
||||
.web-player-diagnostic__player-actions {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: 8px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.web-player-diagnostic__player-card {
|
||||
min-height: 52px;
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.web-player-diagnostic__utilities {
|
||||
display: grid;
|
||||
gap: 7px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.web-player-diagnostic__divider {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.web-player-diagnostic__utility-button,
|
||||
.web-player-diagnostic__details summary {
|
||||
width: fit-content;
|
||||
min-height: 22px;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.web-player-diagnostic__details[open] {
|
||||
flex-basis: auto;
|
||||
}
|
||||
|
||||
.web-player-diagnostic__details dl {
|
||||
max-height: 260px;
|
||||
overflow: auto;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: 6px;
|
||||
margin-top: 8px;
|
||||
padding: 10px;
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
|
||||
.web-player-diagnostic__detail-row {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.web-player-diagnostic__detail-row dt {
|
||||
white-space: normal;
|
||||
}
|
||||
}
|
||||
|
||||
@container (max-width: 340px) {
|
||||
.web-player-diagnostic {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.web-player-diagnostic__headline {
|
||||
font-size: 1.22rem;
|
||||
}
|
||||
|
||||
.web-player-diagnostic__description {
|
||||
font-size: 0.84rem;
|
||||
}
|
||||
|
||||
.web-player-diagnostic__player-label {
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
}
|
||||
@@ -158,6 +158,9 @@ describe('WebPlayerViewComponent', () => {
|
||||
expect(banner.nativeElement.textContent).toContain(
|
||||
'PLAYBACK_DIAGNOSTICS.UNSUPPORTED_CONTAINER.TITLE'
|
||||
);
|
||||
expect(banner.nativeElement.textContent).toContain(
|
||||
'PLAYBACK_DIAGNOSTICS.NATIVE_FALLBACK_TITLE'
|
||||
);
|
||||
mpvButton.nativeElement.click();
|
||||
|
||||
expect(requests).toEqual([
|
||||
@@ -242,6 +245,27 @@ describe('WebPlayerViewComponent', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('uses an inline recovery headline when external fallback actions are unavailable', () => {
|
||||
fixture.detectChanges();
|
||||
component.handlePlaybackIssue(createNetworkDiagnostic());
|
||||
fixture.detectChanges();
|
||||
|
||||
const banner = fixture.debugElement.query(
|
||||
By.css('[data-test-id="playback-diagnostic-banner"]')
|
||||
);
|
||||
const mpvButton = fixture.debugElement.query(
|
||||
By.css('[data-test-id="playback-fallback-mpv"]')
|
||||
);
|
||||
|
||||
expect(mpvButton).toBeNull();
|
||||
expect(banner.nativeElement.textContent).toContain(
|
||||
'PLAYBACK_DIAGNOSTICS.NETWORK_ERROR.TITLE'
|
||||
);
|
||||
expect(banner.nativeElement.textContent).toContain(
|
||||
'PLAYBACK_DIAGNOSTICS.INLINE_FAILURE_TITLE'
|
||||
);
|
||||
});
|
||||
|
||||
it('renders technical details and codec-specific hints in the diagnostic banner', () => {
|
||||
fixture.detectChanges();
|
||||
const issue = createUnsupportedCodecDiagnostic();
|
||||
@@ -284,6 +308,31 @@ describe('WebPlayerViewComponent', () => {
|
||||
'PLAYBACK_DIAGNOSTICS.CODEC_HINT'
|
||||
);
|
||||
});
|
||||
|
||||
it('clears playback diagnostics when retrying inline playback', () => {
|
||||
fixture.detectChanges();
|
||||
component.handlePlaybackIssue(createUnsupportedCodecDiagnostic());
|
||||
fixture.detectChanges();
|
||||
|
||||
const retryButton = fixture.debugElement.query(
|
||||
By.css('[data-test-id="playback-retry"]')
|
||||
);
|
||||
const utilityControls = fixture.nativeElement.querySelectorAll(
|
||||
'[data-test-id="playback-retry"], [data-test-id="playback-diagnostic-details"]'
|
||||
);
|
||||
|
||||
expect(utilityControls[0]).toBe(retryButton.nativeElement);
|
||||
|
||||
retryButton.nativeElement.click();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.playbackDiagnostic()).toBeNull();
|
||||
expect(
|
||||
fixture.debugElement.query(
|
||||
By.css('[data-test-id="playback-diagnostic-banner"]')
|
||||
)
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
function createUnsupportedContainerDiagnostic(): PlaybackDiagnostic {
|
||||
@@ -329,3 +378,18 @@ function createUnsupportedCodecDiagnostic(): PlaybackDiagnostic {
|
||||
externalFallbackRecommended: true,
|
||||
};
|
||||
}
|
||||
|
||||
function createNetworkDiagnostic(): PlaybackDiagnostic {
|
||||
return {
|
||||
code: PlaybackDiagnosticCode.NetworkError,
|
||||
source: PlaybackDiagnosticSource.MpegTs,
|
||||
sourceUrl: 'https://example.com/live/channel.ts',
|
||||
container: 'ts',
|
||||
mimeType: 'video/mp2t',
|
||||
player: 'videojs',
|
||||
audioCodecs: [],
|
||||
videoCodecs: [],
|
||||
details: 'HttpStatusCodeInvalid {"code":456,"msg":"<none>"}',
|
||||
externalFallbackRecommended: false,
|
||||
};
|
||||
}
|
||||
@@ -165,6 +165,14 @@ export class WebPlayerViewComponent {
|
||||
});
|
||||
}
|
||||
|
||||
retryPlayback(): void {
|
||||
const playback = this.resolvedPlayback();
|
||||
|
||||
this.playbackDiagnostic.set(null);
|
||||
this.setChannel(playback.streamUrl);
|
||||
this.setVjsOptions(playback.streamUrl, this.isLivePlayback(playback));
|
||||
}
|
||||
|
||||
getDiagnosticTitleKey(issue: PlaybackDiagnostic): string {
|
||||
return `${this.getDiagnosticTranslationBase(issue)}.TITLE`;
|
||||
}
|
||||
@@ -173,6 +181,12 @@ export class WebPlayerViewComponent {
|
||||
return `${this.getDiagnosticTranslationBase(issue)}.DESCRIPTION`;
|
||||
}
|
||||
|
||||
getDiagnosticHeadlineKey(): string {
|
||||
return this.canShowExternalFallbackActions()
|
||||
? 'PLAYBACK_DIAGNOSTICS.NATIVE_FALLBACK_TITLE'
|
||||
: 'PLAYBACK_DIAGNOSTICS.INLINE_FAILURE_TITLE';
|
||||
}
|
||||
|
||||
getDiagnosticMeta(issue: PlaybackDiagnostic): string {
|
||||
const codecs = [...issue.videoCodecs, ...issue.audioCodecs].join(', ');
|
||||
if (codecs) {
|
||||
|
||||
@@ -73,10 +73,10 @@
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
left: 6px;
|
||||
// Player internals, including the radio audio stage, may use z-index
|
||||
// 2 inside the content area. Keep the restore affordance above them
|
||||
// for both paint order and hit testing.
|
||||
z-index: 5;
|
||||
// Player internals, including diagnostic overlays, may use z-index 5
|
||||
// inside the content area. Keep the restore affordance above them for
|
||||
// both paint order and hit testing.
|
||||
z-index: 6;
|
||||
background: rgba(128, 128, 128, 0.18);
|
||||
color: var(--mat-sys-on-surface, rgba(255, 255, 255, 0.75));
|
||||
box-shadow: none;
|
||||
|
||||
Reference in new issue
Block a user