mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-10 10:06:15 -08:00
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:
1 parent
b1e9aeaf7b
commit
5d87e89ee3
6 files changed
+248
-21
No files matched your search
@@ -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', () => {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user