mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-11 02:46:16 -08:00
feat(playback): recommend recovery actions (#1374)
* docs(playback): design recovery recommendations * docs(playback): plan recovery recommendations * refactor(playback): extract diagnostic utilities * feat(playback): define recovery recommendation contracts * feat(playback): rank recovery recommendations * feat(playback): track session recovery attempts * feat(playback): identify content recovery sessions * feat(ui): add ranked playback diagnostic panel * feat(playback): switch temporarily to recommended players * test(playback): cover temporary player recommendation * test(playback): verify recommendation capability guards * docs(playback): document recovery recommendations * fix(playback): keep recovery keys credential-free * fix(playback): remove derived tracking ownership * fix(playback): preserve distinct recovery fallbacks * fix(playback): reset resume for new sources * fix(playback): preserve desktop recovery guidance * docs(playback): clarify recovery policy exceptions * fix(playback): reject stale progress updates * fix(playback): keep protected recovery guidance neutral * test(playback): cover stale progress output * fix(playback): neutralize protected diagnostic copy * fix(playback): harden runtime guidance ownership * fix(playback): stabilize recovery application ownership * fix(ci): classify playback util coverage * fix(e2e): preserve playback fixture bytes
This commit is contained in:
1 parent
5e4f2ca3dd
commit
fd96b85c19
176 files changed
+15228
-1908
No files matched your search
@@ -1,278 +1,112 @@
|
||||
@if (selectedPlayer() === 'videojs') {
|
||||
@defer (on immediate) {
|
||||
<!-- The source hand-off is deferred until the scoped Electron header
|
||||
override is configured; mounting the player before `vjsOptions`
|
||||
exists would fire a source-less (or credential-less) first load. -->
|
||||
@if (vjsOptions) {
|
||||
<app-vjs-player
|
||||
[options]="vjsOptions"
|
||||
[mediaTitle]="resolvedMediaTitle()"
|
||||
[volume]="volume()"
|
||||
[showCaptions]="showCaptions()"
|
||||
[interactionEnabled]="playbackInteractionEnabled()"
|
||||
[startTime]="startTime()"
|
||||
[seriesNavigation]="seriesNavigation()"
|
||||
(timeUpdate)="timeUpdate.emit($event)"
|
||||
(playbackIssue)="handlePlaybackIssue($event)"
|
||||
(playbackEnded)="playbackEnded.emit()"
|
||||
(previousEpisodeRequested)="previousEpisodeRequested.emit()"
|
||||
(nextEpisodeRequested)="nextEpisodeRequested.emit()"
|
||||
/>
|
||||
@for (application of renderedApplications(); track application.token) {
|
||||
@if (application.binding; as binding) {
|
||||
@if (binding.target === 'videojs') {
|
||||
@defer (on immediate) {
|
||||
@if (vjsOptions) {
|
||||
<app-vjs-player
|
||||
[options]="vjsOptions"
|
||||
[mediaTitle]="resolvedMediaTitle()"
|
||||
[volume]="volume()"
|
||||
[showCaptions]="showCaptions()"
|
||||
[interactionEnabled]="playbackInteractionEnabled()"
|
||||
[startTime]="effectiveStartTime()"
|
||||
[seriesNavigation]="seriesNavigation()"
|
||||
(timeUpdate)="handleTimeUpdate($event, application)"
|
||||
(playbackIssue)="handlePlaybackIssue($event, binding)"
|
||||
(playbackEnded)="playbackEnded.emit()"
|
||||
(previousEpisodeRequested)="
|
||||
previousEpisodeRequested.emit()
|
||||
"
|
||||
(nextEpisodeRequested)="nextEpisodeRequested.emit()"
|
||||
/>
|
||||
}
|
||||
} @placeholder {
|
||||
<div
|
||||
class="web-player-defer-placeholder"
|
||||
aria-hidden="true"
|
||||
></div>
|
||||
}
|
||||
} @else if (binding.target === 'html5') {
|
||||
@defer (on immediate) {
|
||||
@if (channel) {
|
||||
<app-html-video-player
|
||||
[channel]="$any(channel)"
|
||||
[mediaTitle]="resolvedMediaTitle()"
|
||||
[volume]="volume()"
|
||||
[showCaptions]="showCaptions()"
|
||||
[isLive]="resolvedIsLive()"
|
||||
[interactionEnabled]="playbackInteractionEnabled()"
|
||||
[startTime]="effectiveStartTime()"
|
||||
[seriesNavigation]="seriesNavigation()"
|
||||
(timeUpdate)="handleTimeUpdate($event, application)"
|
||||
(playbackIssue)="handlePlaybackIssue($event, binding)"
|
||||
(playbackEnded)="playbackEnded.emit()"
|
||||
(previousEpisodeRequested)="
|
||||
previousEpisodeRequested.emit()
|
||||
"
|
||||
(nextEpisodeRequested)="nextEpisodeRequested.emit()"
|
||||
/>
|
||||
}
|
||||
} @placeholder {
|
||||
<div
|
||||
class="web-player-defer-placeholder"
|
||||
aria-hidden="true"
|
||||
></div>
|
||||
}
|
||||
} @else if (binding.target === 'artplayer') {
|
||||
@defer (on immediate) {
|
||||
@if (channel) {
|
||||
<app-art-player
|
||||
[channel]="$any(channel)"
|
||||
[mediaTitle]="resolvedMediaTitle()"
|
||||
[volume]="volume()"
|
||||
[showCaptions]="showCaptions()"
|
||||
[isLive]="resolvedIsLive()"
|
||||
[interactionEnabled]="playbackInteractionEnabled()"
|
||||
[startTime]="effectiveStartTime()"
|
||||
[seriesNavigation]="seriesNavigation()"
|
||||
(timeUpdate)="handleTimeUpdate($event, application)"
|
||||
(playbackIssue)="handlePlaybackIssue($event, binding)"
|
||||
(playbackEnded)="playbackEnded.emit()"
|
||||
(previousEpisodeRequested)="
|
||||
previousEpisodeRequested.emit()
|
||||
"
|
||||
(nextEpisodeRequested)="nextEpisodeRequested.emit()"
|
||||
/>
|
||||
}
|
||||
} @placeholder {
|
||||
<div
|
||||
class="web-player-defer-placeholder"
|
||||
aria-hidden="true"
|
||||
></div>
|
||||
}
|
||||
}
|
||||
} @placeholder {
|
||||
<div class="web-player-defer-placeholder" aria-hidden="true"></div>
|
||||
}
|
||||
} @else if (selectedPlayer() === 'html5') {
|
||||
@defer (on immediate) {
|
||||
@if (channel) {
|
||||
<app-html-video-player
|
||||
[channel]="$any(channel)"
|
||||
[mediaTitle]="resolvedMediaTitle()"
|
||||
[volume]="volume()"
|
||||
[showCaptions]="showCaptions()"
|
||||
[isLive]="resolvedIsLive()"
|
||||
[interactionEnabled]="playbackInteractionEnabled()"
|
||||
[startTime]="startTime()"
|
||||
[seriesNavigation]="seriesNavigation()"
|
||||
(timeUpdate)="timeUpdate.emit($event)"
|
||||
(playbackIssue)="handlePlaybackIssue($event)"
|
||||
(playbackEnded)="playbackEnded.emit()"
|
||||
(previousEpisodeRequested)="previousEpisodeRequested.emit()"
|
||||
(nextEpisodeRequested)="nextEpisodeRequested.emit()"
|
||||
/>
|
||||
}
|
||||
} @placeholder {
|
||||
<div class="web-player-defer-placeholder" aria-hidden="true"></div>
|
||||
}
|
||||
} @else if (selectedPlayer() === 'artplayer') {
|
||||
@defer (on immediate) {
|
||||
@if (channel) {
|
||||
<app-art-player
|
||||
[channel]="$any(channel)"
|
||||
[mediaTitle]="resolvedMediaTitle()"
|
||||
[volume]="volume()"
|
||||
[showCaptions]="showCaptions()"
|
||||
[isLive]="resolvedIsLive()"
|
||||
[interactionEnabled]="playbackInteractionEnabled()"
|
||||
[startTime]="startTime()"
|
||||
[seriesNavigation]="seriesNavigation()"
|
||||
(timeUpdate)="timeUpdate.emit($event)"
|
||||
(playbackIssue)="handlePlaybackIssue($event)"
|
||||
(playbackEnded)="playbackEnded.emit()"
|
||||
(previousEpisodeRequested)="previousEpisodeRequested.emit()"
|
||||
(nextEpisodeRequested)="nextEpisodeRequested.emit()"
|
||||
/>
|
||||
}
|
||||
} @placeholder {
|
||||
<div class="web-player-defer-placeholder" aria-hidden="true"></div>
|
||||
}
|
||||
} @else if (selectedPlayer() === 'embedded-mpv') {
|
||||
<app-embedded-mpv-player
|
||||
[playback]="resolvedPlayback()"
|
||||
[mediaTitle]="resolvedMediaTitle()"
|
||||
[recordingFolder]="recordingFolder()"
|
||||
[seriesNavigation]="seriesNavigation()"
|
||||
(timeUpdate)="timeUpdate.emit($event)"
|
||||
(playbackEnded)="playbackEnded.emit()"
|
||||
(previousEpisodeRequested)="previousEpisodeRequested.emit()"
|
||||
(nextEpisodeRequested)="nextEpisodeRequested.emit()"
|
||||
/>
|
||||
} @else {
|
||||
@defer (on immediate) {
|
||||
<app-vjs-player
|
||||
[options]="vjsOptions"
|
||||
} @else if (application.embeddedMpv) {
|
||||
<app-embedded-mpv-player
|
||||
[playback]="resolvedPlayback()"
|
||||
[mediaTitle]="resolvedMediaTitle()"
|
||||
[volume]="volume()"
|
||||
[showCaptions]="showCaptions()"
|
||||
[interactionEnabled]="playbackInteractionEnabled()"
|
||||
[startTime]="startTime()"
|
||||
[recordingFolder]="recordingFolder()"
|
||||
[seriesNavigation]="seriesNavigation()"
|
||||
(timeUpdate)="timeUpdate.emit($event)"
|
||||
(playbackIssue)="handlePlaybackIssue($event)"
|
||||
(timeUpdate)="handleTimeUpdate($event, application)"
|
||||
(playbackEnded)="playbackEnded.emit()"
|
||||
(previousEpisodeRequested)="previousEpisodeRequested.emit()"
|
||||
(nextEpisodeRequested)="nextEpisodeRequested.emit()"
|
||||
/>
|
||||
} @placeholder {
|
||||
<div class="web-player-defer-placeholder" aria-hidden="true"></div>
|
||||
}
|
||||
}
|
||||
|
||||
@if (visiblePlaybackDiagnostic(); as issue) {
|
||||
<section
|
||||
class="web-player-diagnostic"
|
||||
data-test-id="playback-diagnostic-banner"
|
||||
role="status"
|
||||
>
|
||||
<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">
|
||||
{{ diagnosticHeadlineKey() | 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"
|
||||
data-test-id="playback-codec-hint"
|
||||
>
|
||||
{{
|
||||
'PLAYBACK_DIAGNOSTICS.CODEC_HINT'
|
||||
| translate: { codecs: codecHint }
|
||||
}}
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (alternativeSources().length > 0) {
|
||||
<div
|
||||
class="web-player-diagnostic__alternatives"
|
||||
data-test-id="playback-alternative-sources"
|
||||
>
|
||||
<h3 class="web-player-diagnostic__alternatives-title">
|
||||
{{
|
||||
'PORTALS.MULTI_SOURCE.TRY_ANOTHER_SOURCE'
|
||||
| translate
|
||||
}}
|
||||
</h3>
|
||||
@for (source of visibleAlternatives(); track source.id) {
|
||||
<app-vod-source-row
|
||||
[source]="source"
|
||||
[showPin]="false"
|
||||
(playRequested)="alternativeSourceRequested.emit($event)"
|
||||
(checkRequested)="sourceCheckRequested.emit($event)"
|
||||
/>
|
||||
}
|
||||
@if (hiddenAlternativeCount() > 0) {
|
||||
<p class="web-player-diagnostic__alternatives-more">
|
||||
{{
|
||||
'PORTALS.MULTI_SOURCE.MORE_SOURCES'
|
||||
| translate
|
||||
: { count: hiddenAlternativeCount() }
|
||||
}}
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (canShowExternalFallbackActions()) {
|
||||
<div
|
||||
class="web-player-diagnostic__player-actions"
|
||||
aria-label="External player fallbacks"
|
||||
>
|
||||
<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>
|
||||
}
|
||||
|
||||
<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>
|
||||
<app-playback-diagnostic-panel
|
||||
[diagnostic]="issue"
|
||||
[recommendations]="recommendations()"
|
||||
[playback]="resolvedPlayback()"
|
||||
[supportsManagedExternalPlayers]="supportsManagedExternalPlayers"
|
||||
[playbackExternallyTransferable]="playbackExternallyTransferable()"
|
||||
[alternativeSources]="alternativeSources()"
|
||||
[pending]="recoveryPending()"
|
||||
(retryRequested)="retryPlayback()"
|
||||
(playerRequested)="requestRecommendedPlayer($event)"
|
||||
(alternativeSourceRequested)="alternativeSourceRequested.emit($event)"
|
||||
(sourceCheckRequested)="sourceCheckRequested.emit($event)"
|
||||
/>
|
||||
}
|
||||
Reference in new issue
Block a user