diff --git a/libs/ui/playback/src/lib/web-player-view/web-player-recovery-policy.spec.ts b/libs/ui/playback/src/lib/web-player-view/web-player-recovery-policy.spec.ts index 17f0ebbf1..1512996e6 100644 --- a/libs/ui/playback/src/lib/web-player-view/web-player-recovery-policy.spec.ts +++ b/libs/ui/playback/src/lib/web-player-view/web-player-recovery-policy.spec.ts @@ -94,6 +94,52 @@ describe('createWebPlayerRecommendations external outcomes', () => { expect(targets(result)).not.toContain(InlinePlaybackPlayer.Html5); }); + it('keeps a failed external target and reveals its capped sibling', () => { + const result = recommendations({ + diagnostic: { + ...DIAGNOSTIC, + code: PlaybackDiagnosticCode.DrmOrEncryption, + source: PlaybackDiagnosticSource.Hls, + container: 'm3u8', + mimeType: 'application/vnd.apple.mpegurl', + }, + attemptedTargets: new Set(['mpv']), + externalStates: externalStates({ + mpv: { attempts: 1, sessionId: null, status: 'error' }, + }), + }); + + expect(targets(result)).toEqual([ + InlinePlaybackPlayer.Html5, + 'vlc', + 'mpv', + ]); + expect(result).toHaveLength(3); + }); + + it('retains a previously attempted target that the initial cap omitted', () => { + const result = recommendations({ + diagnostic: { + ...DIAGNOSTIC, + code: PlaybackDiagnosticCode.DrmOrEncryption, + source: PlaybackDiagnosticSource.Hls, + container: 'm3u8', + mimeType: 'application/vnd.apple.mpegurl', + }, + attemptedTargets: new Set(['vlc']), + externalStates: externalStates({ + vlc: { attempts: 1, sessionId: null, status: 'error' }, + }), + }); + + expect(targets(result)).toEqual([ + InlinePlaybackPlayer.Html5, + 'mpv', + 'vlc', + ]); + expect(result).toHaveLength(3); + }); + it('preserves policy order for equal attempt counts and stays capped at three', () => { const result = recommendations({ attemptedTargets: new Set(['mpv', 'vlc']), diff --git a/libs/ui/playback/src/lib/web-player-view/web-player-recovery-policy.ts b/libs/ui/playback/src/lib/web-player-view/web-player-recovery-policy.ts index e6ec40755..75e8d727b 100644 --- a/libs/ui/playback/src/lib/web-player-view/web-player-recovery-policy.ts +++ b/libs/ui/playback/src/lib/web-player-view/web-player-recovery-policy.ts @@ -5,6 +5,7 @@ import { resolvePlaybackSourceKind, type PlaybackDiagnostic, type PlaybackRecommendation, + type PlaybackRecommendationContext, type PlaybackRecommendationTarget, } from '@iptvnator/playback/util'; import { @@ -34,7 +35,7 @@ export function createWebPlayerRecommendations(options: { (target) => target !== 'mpv' && target !== 'vlc' ) ); - const recommendations = recommendPlaybackRecovery({ + const context: PlaybackRecommendationContext = { diagnostic: options.diagnostic, activeTarget: options.binding.target, attemptedTargets: attemptedInlineTargets, @@ -52,13 +53,69 @@ export function createWebPlayerRecommendations(options: { externalTransferable: options.playbackExternallyTransferable, }, alternativeSourceCount: options.alternativeSourceCount, - }); + }; + const recommendations = revealCappedExternalSiblings( + recommendPlaybackRecovery(context), + context, + options.externalStates + ); return rerankExternalRecommendations( recommendations, options.externalStates ); } +function revealCappedExternalSiblings( + recommendations: readonly PlaybackRecommendation[], + context: PlaybackRecommendationContext, + states: ExternalRecoveryStates +): readonly PlaybackRecommendation[] { + const externalTargets: readonly ExternalPlayerName[] = ['mpv', 'vlc']; + if (!externalTargets.some((target) => states[target].attempts > 0)) { + return recommendations; + } + const retained = [...recommendations]; + for (const target of externalTargets) { + if ( + retained.some( + (recommendation) => + isExternalRecommendation(recommendation) && + recommendation.target === target + ) + ) { + continue; + } + const siblingTarget: ExternalPlayerName = + target === 'mpv' ? 'vlc' : 'mpv'; + const candidate = recommendPlaybackRecovery({ + ...context, + attemptedTargets: new Set([ + ...context.attemptedTargets, + siblingTarget, + ]), + }).find( + (recommendation) => + isExternalRecommendation(recommendation) && + recommendation.target === target + ); + if (!candidate) { + continue; + } + if (retained.length >= 3) { + const replaceIndex = retained.findLastIndex( + (recommendation, index) => + index > 0 && !isExternalRecommendation(recommendation) + ); + if (replaceIndex < 0) { + continue; + } + retained.splice(replaceIndex, 1); + } + retained.push(candidate); + } + return retained; +} + function rerankExternalRecommendations( recommendations: readonly PlaybackRecommendation[], states: ExternalRecoveryStates @@ -99,6 +156,17 @@ function rerankExternalRecommendations( }); } +function isExternalRecommendation( + recommendation: PlaybackRecommendation +): recommendation is Extract & { + readonly target: ExternalPlayerName; +} { + return ( + recommendation.action === 'player' && + (recommendation.target === 'mpv' || recommendation.target === 'vlc') + ); +} + export function isPlaybackExternallyTransferable( playback: ResolvedPortalPlayback ): boolean {