feat(m3u): route DASH channels to the inline Shaka-capable player

DASH (.mpd) channels always play in a built-in web engine (radio
precedent): external MPV/VLC cannot receive KODIPROP ClearKey
configuration (VLC upstream #29465) and Video.js has no DASH bridge yet.

- shouldShowInlinePlayer() bypasses the external-player setting for DASH
- new shouldAutoLaunchExternalPlayer() guard consolidates the MPV/VLC
  auto-launch conditions in the m3u-state effects (incl. catch-up path)
- the M3U page overrides the player for DASH channels: ArtPlayer stays
  ArtPlayer, everything else falls back to the HTML5 player
- ChannelDrm is passed through ResolvedPortalPlayback into the synthetic
  player-view channel

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5 committed 2026-07-18 19:16:52 +02:00
1 parent b1e9aeaf7b
commit 5d87e89ee3
6 files changed
+248 -21

No files matched your search

+98 -2
View File
@@ -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', () => {
+27 -17
View File
@@ -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<void> {
if (isDashStreamUrl(playbackUrl) || isDashChannel(activeChannel)) {
return;
}
const payload = buildExternalPlayerPayload(activeChannel, playbackUrl);
if (!payload) {
return;
@@ -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,
@@ -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)="
@@ -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,
@@ -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<VideoPlayer>(() =>
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);
}