diff --git a/libs/m3u-state/src/lib/effects.spec.ts b/libs/m3u-state/src/lib/effects.spec.ts index 525953cf8..553cd1f20 100644 --- a/libs/m3u-state/src/lib/effects.spec.ts +++ b/libs/m3u-state/src/lib/effects.spec.ts @@ -1,6 +1,9 @@ -import { Channel, EpgProgram } from '@iptvnator/shared/interfaces'; +import { Channel, EpgProgram, VideoPlayer } from '@iptvnator/shared/interfaces'; import { EpgActions } from './actions'; -import { buildExternalPlayerPayload } from './external-player-payload.util'; +import { + buildExternalPlayerPayload, + shouldAutoLaunchExternalPlayer, +} from './external-player-payload.util'; import { resolveActiveEpgProgramAction } from './resolve-active-epg-program.util'; describe('buildExternalPlayerPayload', () => { @@ -44,6 +47,99 @@ describe('buildExternalPlayerPayload', () => { origin: 'https://origin.example.com', }); }); + + describe('shouldAutoLaunchExternalPlayer', () => { + const mpvSettings = { player: VideoPlayer.MPV }; + + it('launches the configured external player for regular channels', () => { + expect( + shouldAutoLaunchExternalPlayer( + mpvSettings, + true, + activeChannel, + VideoPlayer.MPV + ) + ).toBe(true); + expect( + shouldAutoLaunchExternalPlayer( + mpvSettings, + true, + activeChannel, + VideoPlayer.VLC + ) + ).toBe(false); + }); + + it('never launches external players for DASH (.mpd) channels', () => { + const dashChannel: Channel = { + ...activeChannel, + url: 'https://streams.example.com/live.mpd', + }; + + expect( + shouldAutoLaunchExternalPlayer( + mpvSettings, + true, + dashChannel, + VideoPlayer.MPV + ) + ).toBe(false); + expect( + shouldAutoLaunchExternalPlayer( + { player: VideoPlayer.VLC }, + true, + dashChannel, + VideoPlayer.VLC + ) + ).toBe(false); + }); + + it('never launches external players for radio channels', () => { + expect( + shouldAutoLaunchExternalPlayer( + mpvSettings, + true, + { ...activeChannel, radio: 'true' }, + VideoPlayer.MPV + ) + ).toBe(false); + }); + + it('respects the double-click setting and missing settings', () => { + expect( + shouldAutoLaunchExternalPlayer( + { ...mpvSettings, openStreamOnDoubleClick: true }, + undefined, + activeChannel, + VideoPlayer.MPV + ) + ).toBe(false); + expect( + shouldAutoLaunchExternalPlayer( + { ...mpvSettings, openStreamOnDoubleClick: true }, + true, + activeChannel, + VideoPlayer.MPV + ) + ).toBe(true); + expect( + shouldAutoLaunchExternalPlayer( + null, + true, + activeChannel, + VideoPlayer.MPV + ) + ).toBe(false); + expect( + shouldAutoLaunchExternalPlayer( + {}, + true, + activeChannel, + VideoPlayer.MPV + ) + ).toBe(false); + }); + }); }); describe('resolveActiveEpgProgramAction', () => { diff --git a/libs/m3u-state/src/lib/effects.ts b/libs/m3u-state/src/lib/effects.ts index 3931aafca..ae0e5f0f5 100644 --- a/libs/m3u-state/src/lib/effects.ts +++ b/libs/m3u-state/src/lib/effects.ts @@ -2,7 +2,11 @@ import { inject, Injectable } from '@angular/core'; import { MatSnackBar } from '@angular/material/snack-bar'; import { Router } from '@angular/router'; import { EpgService } from '@iptvnator/epg/data-access'; -import { normalizeEpgUrls } from '@iptvnator/shared/m3u-utils'; +import { + isDashChannel, + isDashStreamUrl, + normalizeEpgUrls, +} from '@iptvnator/shared/m3u-utils'; import { Actions, createEffect, ofType } from '@ngrx/effects'; import { Store } from '@ngrx/store'; import { StorageMap } from '@ngx-pwa/local-storage'; @@ -45,7 +49,10 @@ import { selectFavorites, } from './selectors'; import { resolveChannelEpgLookupKey } from './channel-epg-lookup.util'; -import { buildExternalPlayerPayload } from './external-player-payload.util'; +import { + buildExternalPlayerPayload, + shouldAutoLaunchExternalPlayer, +} from './external-player-payload.util'; import { resolvePlaylistScopedEpgFetchPlan } from './playlist-scoped-epg-fetch.util'; import { resolveActiveEpgProgramAction } from './resolve-active-epg-program.util'; @@ -189,16 +196,13 @@ export class PlaylistEffects { firstValueFrom(this.storage.get(STORE_KEY.Settings)).then( (settings: any) => { - const shouldOpenExternalPlayer = - !settings?.openStreamOnDoubleClick || - action.startPlayback === true; - if ( - settings && - Object.keys(settings).length > 0 && - shouldOpenExternalPlayer && - settings.player === VideoPlayer.MPV && - channel.radio !== 'true' + shouldAutoLaunchExternalPlayer( + settings, + action.startPlayback, + channel, + VideoPlayer.MPV + ) ) { this.dataService.sendIpcEvent(OPEN_MPV_PLAYER, { url: channel.url, @@ -208,12 +212,13 @@ export class PlaylistEffects { origin: channel.http.origin, }); } else if ( - settings && - Object.keys(settings).length > 0 && - shouldOpenExternalPlayer && - settings.player === VideoPlayer.VLC && - channel.radio !== 'true' - ) + shouldAutoLaunchExternalPlayer( + settings, + action.startPlayback, + channel, + VideoPlayer.VLC + ) + ) { this.dataService.sendIpcEvent(OPEN_VLC_PLAYER, { url: channel.url, title: channel.name ?? '', @@ -221,6 +226,7 @@ export class PlaylistEffects { referer: channel.http.referrer, origin: channel.http.origin, }); + } } ); @@ -253,6 +259,10 @@ export class PlaylistEffects { playbackUrl: string, activeChannel: Channel | undefined | null ): Promise { + if (isDashStreamUrl(playbackUrl) || isDashChannel(activeChannel)) { + return; + } + const payload = buildExternalPlayerPayload(activeChannel, playbackUrl); if (!payload) { return; diff --git a/libs/m3u-state/src/lib/external-player-payload.util.ts b/libs/m3u-state/src/lib/external-player-payload.util.ts index af82f2ca6..082770e2a 100644 --- a/libs/m3u-state/src/lib/external-player-payload.util.ts +++ b/libs/m3u-state/src/lib/external-player-payload.util.ts @@ -1,4 +1,34 @@ -import { Channel } from '@iptvnator/shared/interfaces'; +import { Channel, VideoPlayer } from '@iptvnator/shared/interfaces'; +import { isDashChannel } from '@iptvnator/shared/m3u-utils'; + +/** + * Decides whether activating a channel should auto-launch the configured + * external player. Radio channels use the inline audio player; DASH (`.mpd`) + * channels use the inline Shaka engine because MPV/VLC cannot receive the + * KODIPROP ClearKey configuration. + */ +export function shouldAutoLaunchExternalPlayer( + settings: + | { player?: VideoPlayer; openStreamOnDoubleClick?: boolean } + | null + | undefined, + startPlayback: boolean | undefined, + channel: Channel, + player: VideoPlayer.MPV | VideoPlayer.VLC +): boolean { + if (!settings || Object.keys(settings).length === 0) { + return false; + } + + const startRequested = + !settings.openStreamOnDoubleClick || startPlayback === true; + return ( + startRequested && + settings.player === player && + channel.radio !== 'true' && + !isDashChannel(channel) + ); +} export function buildExternalPlayerPayload( activeChannel: Channel | undefined | null, diff --git a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.html b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.html index aa4b12ff0..e529618be 100644 --- a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.html +++ b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.html @@ -76,7 +76,11 @@ [streamUrl]="embeddedPlayback()?.streamUrl ?? ''" [title]="inlinePlayerTitle()" [playback]="embeddedPlayback()" - [playerOverride]="playerSettings.player ?? null" + [playerOverride]=" + activeChannelIsDash() + ? dashPlayerOverride() + : (playerSettings.player ?? null) + " [volume]="volume()" [showCaptions]="!!playerSettings.showCaptions" (externalFallbackRequested)=" diff --git a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.spec.ts b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.spec.ts index 82403755d..9ffe560f6 100644 --- a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.spec.ts +++ b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.spec.ts @@ -627,6 +627,70 @@ describe('VideoPlayerComponent', () => { ).toBe(false); }); + it('keeps DASH channels inline on the HTML5 player even when MPV is configured', () => { + syncStoreState({ + ...sampleChannel, + url: 'http://localhost/live.mpd', + } as Channel); + player.set(VideoPlayer.MPV); + + fixture.detectChanges(); + + const playerView = fixture.debugElement.query( + By.directive(StubWebPlayerViewComponent) + ); + expect(playerView).not.toBeNull(); + const stub = + playerView.componentInstance as StubWebPlayerViewComponent; + expect(stub.playerOverride()).toBe(VideoPlayer.Html5Player); + expect(dataServiceMock.sendIpcEvent).not.toHaveBeenCalled(); + }); + + it('keeps ArtPlayer for DASH channels and forwards the ClearKey DRM config', () => { + const drm = { + licenseType: 'clearkey', + supported: true, + clearKeys: { '11223344556677889900aabbccddeeff': 'f'.repeat(32) }, + }; + syncStoreState({ + ...sampleChannel, + url: 'http://localhost/live.mpd', + drm, + } as Channel); + player.set(VideoPlayer.ArtPlayer); + + fixture.detectChanges(); + + const stub = fixture.debugElement.query( + By.directive(StubWebPlayerViewComponent) + ).componentInstance as StubWebPlayerViewComponent; + expect(stub.playerOverride()).toBe(VideoPlayer.ArtPlayer); + expect(stub.playback()).toEqual(expect.objectContaining({ drm })); + }); + + it('routes Video.js users to the HTML5 player only for DASH channels', () => { + syncStoreState({ + ...sampleChannel, + url: 'http://localhost/live.mpd', + } as Channel); + player.set(VideoPlayer.VideoJs); + + fixture.detectChanges(); + + let stub = fixture.debugElement.query( + By.directive(StubWebPlayerViewComponent) + ).componentInstance as StubWebPlayerViewComponent; + expect(stub.playerOverride()).toBe(VideoPlayer.Html5Player); + + syncStoreState(sampleChannel); + fixture.detectChanges(); + + stub = fixture.debugElement.query( + By.directive(StubWebPlayerViewComponent) + ).componentInstance as StubWebPlayerViewComponent; + expect(stub.playerOverride()).toBe(VideoPlayer.VideoJs); + }); + it('passes remote volume changes to the radio audio player', () => { const radioChannel = { ...sampleChannel, diff --git a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts index e7513d6b2..9d830c3a3 100644 --- a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts +++ b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts @@ -24,6 +24,7 @@ import { ResizableDirective } from '@iptvnator/ui/components'; import { applyChannelNameStrip, getM3uArchiveDays, + isDashChannel, isM3uCatchupPlaybackSupported, } from '@iptvnator/shared/m3u-utils'; import { PlaylistContextFacade } from '@iptvnator/playlist/shared/util'; @@ -173,6 +174,21 @@ export class VideoPlayerComponent implements OnInit, OnDestroy { readonly archivePlaybackAvailable = computed(() => isM3uCatchupPlaybackSupported(this.activeChannel()) ); + /** DASH (.mpd) channels always play inline via the Shaka engine. */ + readonly activeChannelIsDash = computed(() => + isDashChannel(this.activeChannel()) + ); + /** + * Player forced for DASH channels: ArtPlayer keeps ArtPlayer (it has a + * Shaka source engine); every other choice — Video.js (no DASH bridge), + * embedded/external MPV and VLC (no KODIPROP ClearKey support) — falls + * back to the HTML5 player. + */ + readonly dashPlayerOverride = computed(() => + this.settingsStore.player() === VideoPlayer.ArtPlayer + ? VideoPlayer.ArtPlayer + : VideoPlayer.Html5Player + ); /** Full multi-day programme window for the active channel (timeline). */ readonly epgPrograms = toSignal(this.epgService.currentEpgPrograms$, { initialValue: [] as EpgProgram[], @@ -280,6 +296,7 @@ export class VideoPlayerComponent implements OnInit, OnDestroy { userAgent: http['user-agent'] || undefined, referer: http.referrer || undefined, origin: http.origin || undefined, + drm: playbackTarget.drm, }; }); readonly sidebarStorageKey = computed(() => @@ -992,6 +1009,12 @@ export class VideoPlayerComponent implements OnInit, OnDestroy { return false; } + // DASH channels bypass the external-player setting (radio precedent): + // MPV/VLC cannot receive the KODIPROP ClearKey configuration. + if (isDashChannel(channel)) { + return true; + } + return !this.isExternalPlayer(this.playerSettings.player); }