feat(player): add embedded-mpv player for macOS as experimental feature

- Introduced tooling for building and staging the macOS `libmpv` runtime for IPTVnator's embedded MPV player.
- Added `build-macos-runtime.mjs` for building an LGPL-compatible runtime from source.
- Created `stage-macos-runtime.mjs` for staging the built runtime artifacts.
- Implemented validation for the packaged embedded MPV runtime in `electron-after-pack.cjs` and `embedded-mpv-macos.cjs`.
- Updated packaging scripts to ensure the embedded MPV runtime is correctly integrated and validated during the build process.
- Added README files to document the expected layout and usage for the embedded MPV runtime artifacts.

Entire-Checkpoint: c6e522b4276c
This commit is contained in:
4gray committed 2026-04-27 00:32:14 +02:00
1 parent 10139955e7
commit 9f873e6bed
53 files changed
+6099 -49

No files matched your search

@@ -47,6 +47,7 @@ describe('PlayerService', () => {
expect(service.isEmbeddedPlayer(VideoPlayer.VideoJs)).toBe(true);
expect(service.isEmbeddedPlayer(VideoPlayer.Html5Player)).toBe(true);
expect(service.isEmbeddedPlayer(VideoPlayer.ArtPlayer)).toBe(true);
expect(service.isEmbeddedPlayer(VideoPlayer.EmbeddedMpv)).toBe(true);
expect(service.isEmbeddedPlayer(VideoPlayer.MPV)).toBe(false);
expect(service.isEmbeddedPlayer(VideoPlayer.VLC)).toBe(false);
});
@@ -57,7 +58,19 @@ describe('PlayerService', () => {
title: 'Example Video',
});
expect(dialog.open).toHaveBeenCalled();
expect(dialog.open).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
data: expect.objectContaining({
streamUrl: 'https://example.com/video.mp4',
title: 'Example Video',
playback: expect.objectContaining({
streamUrl: 'https://example.com/video.mp4',
title: 'Example Video',
}),
}),
})
);
expect(dataService.sendIpcEvent).not.toHaveBeenCalled();
});
+9 -2
View File
@@ -26,7 +26,8 @@ export class PlayerService {
return (
player === VideoPlayer.VideoJs ||
player === VideoPlayer.Html5Player ||
player === VideoPlayer.ArtPlayer
player === VideoPlayer.ArtPlayer ||
player === VideoPlayer.EmbeddedMpv
);
}
@@ -118,7 +119,13 @@ export class PlayerService {
return import('@iptvnator/portal/xtream/feature').then(
({ PlayerDialogComponent }) => {
this.dialog.open(PlayerDialogComponent, {
data: { streamUrl, title, contentInfo, startTime },
data: {
streamUrl,
title,
contentInfo,
startTime,
playback,
},
width: '80%',
maxWidth: '1200px',
maxHeight: '90vh',
@@ -402,7 +402,7 @@ describe('SettingsComponent', () => {
});
});
/* it('hides the embedded mpv option when the desktop support probe reports unsupported', async () => {
it('hides the embedded mpv option when the desktop support probe reports unsupported', async () => {
window.electron = {
...window.electron,
getEmbeddedMpvSupport: jest.fn().mockResolvedValue({
@@ -416,9 +416,9 @@ describe('SettingsComponent', () => {
await fixture.whenStable();
expect(
component.players().some(
(player) => player.id === VideoPlayer.EmbeddedMpv
)
component
.players()
.some((player) => player.id === VideoPlayer.EmbeddedMpv)
).toBe(false);
});
@@ -435,9 +435,9 @@ describe('SettingsComponent', () => {
await fixture.whenStable();
expect(
component.players().some(
(player) => player.id === VideoPlayer.EmbeddedMpv
)
component
.players()
.some((player) => player.id === VideoPlayer.EmbeddedMpv)
).toBe(true);
});
@@ -458,9 +458,9 @@ describe('SettingsComponent', () => {
expect(component.settingsForm.value).toEqual(DEFAULT_SETTINGS);
expect(window.electron.getEmbeddedMpvSupport).toHaveBeenCalled();
expect(
component.players().some(
(player) => player.id === VideoPlayer.EmbeddedMpv
)
component
.players()
.some((player) => player.id === VideoPlayer.EmbeddedMpv)
).toBe(false);
resolveSupport!({
@@ -470,11 +470,11 @@ describe('SettingsComponent', () => {
await fixture.whenStable();
expect(
component.players().some(
(player) => player.id === VideoPlayer.EmbeddedMpv
)
component
.players()
.some((player) => player.id === VideoPlayer.EmbeddedMpv)
).toBe(true);
}); */
});
});
describe('Version check', () => {
@@ -56,7 +56,7 @@ import {
PlaylistsService,
} from 'services';
import {
/* EmbeddedMpvSupport, */
EmbeddedMpvSupport,
Language,
StartupBehavior,
StreamFormat,
@@ -151,10 +151,10 @@ export class SettingsComponent implements OnInit, OnDestroy {
/** Flag that indicates whether the app runs in electron environment */
readonly isDesktop = !!window.electron;
/* readonly embeddedMpvSupport = signal<EmbeddedMpvSupport | null>(null);
readonly embeddedMpvSupport = signal<EmbeddedMpvSupport | null>(null);
readonly supportsEmbeddedMpv = computed(
() => this.isDesktop && !!this.embeddedMpvSupport()?.supported
); */
);
isPwa = this.dataService.getAppEnvironment() === 'pwa';
@@ -162,14 +162,14 @@ export class SettingsComponent implements OnInit, OnDestroy {
readonly activeSection = this.settingsCtx.activeSection;
readonly osPlayers = computed(() => [
/* ...(this.supportsEmbeddedMpv()
...(this.supportsEmbeddedMpv()
? [
{
id: VideoPlayer.EmbeddedMpv,
labelKey: 'SETTINGS.PLAYER_EMBEDDED_MPV',
},
]
: []), */
: []),
{
id: VideoPlayer.MPV,
labelKey: 'SETTINGS.PLAYER_MPV',
@@ -437,7 +437,7 @@ export class SettingsComponent implements OnInit, OnDestroy {
// Wait for settings to load before setting the form
await this.settingsStore.loadSettings();
this.setSettings();
/* void this.loadEmbeddedMpvSupport(); */
void this.loadEmbeddedMpvSupport();
this.checkAppVersion();
void this.fetchLocalIpAddresses();
@@ -448,7 +448,7 @@ export class SettingsComponent implements OnInit, OnDestroy {
requestAnimationFrame(() => this.setupSectionObserver());
}
/* private async loadEmbeddedMpvSupport(): Promise<void> {
private async loadEmbeddedMpvSupport(): Promise<void> {
if (!this.isDesktop) {
this.embeddedMpvSupport.set({
supported: false,
@@ -475,13 +475,10 @@ export class SettingsComponent implements OnInit, OnDestroy {
this.embeddedMpvSupport.set({
supported: false,
platform: window.electron.platform,
reason:
error instanceof Error
? error.message
: String(error),
reason: error instanceof Error ? error.message : String(error),
});
}
} */
}
ngOnDestroy(): void {
this.cancelPendingScrollTargetClear();
+41
View File
@@ -5,6 +5,9 @@ interface NodeModule {
}
import {
EmbeddedMpvBounds,
EmbeddedMpvSession,
EmbeddedMpvSupport,
EpgChannel,
EpgChannelMetadata,
EpgProgram,
@@ -14,6 +17,7 @@ import {
PlaylistRefreshEvent,
PlaylistRefreshPayload,
PortalDebugEvent,
ResolvedPortalPlayback,
XtreamBackupFavoriteItem,
XtreamBackupHiddenCategory,
XtreamBackupRecentlyViewedItem,
@@ -321,9 +325,46 @@ declare global {
originalError: string;
}) => void
) => void;
onEmbeddedMpvSessionUpdate?: (
callback: (data: EmbeddedMpvSession) => void
) => () => void;
onExternalPlayerSessionUpdate?: (
callback: (data: ExternalPlayerSession) => void
) => () => void;
getEmbeddedMpvSupport: () => Promise<EmbeddedMpvSupport>;
prepareEmbeddedMpv?: () => Promise<EmbeddedMpvSupport>;
createEmbeddedMpvSession: (
bounds: EmbeddedMpvBounds,
title?: string,
initialVolume?: number
) => Promise<EmbeddedMpvSession>;
loadEmbeddedMpvPlayback: (
sessionId: string,
playback: ResolvedPortalPlayback
) => Promise<void>;
setEmbeddedMpvBounds: (
sessionId: string,
bounds: EmbeddedMpvBounds
) => Promise<void>;
setEmbeddedMpvPaused: (
sessionId: string,
paused: boolean
) => Promise<EmbeddedMpvSession | null>;
seekEmbeddedMpv: (
sessionId: string,
seconds: number
) => Promise<EmbeddedMpvSession | null>;
setEmbeddedMpvVolume: (
sessionId: string,
volume: number
) => Promise<EmbeddedMpvSession | null>;
setEmbeddedMpvAudioTrack: (
sessionId: string,
trackId: number
) => Promise<EmbeddedMpvSession | null>;
disposeEmbeddedMpvSession: (
sessionId: string
) => Promise<EmbeddedMpvSession | null>;
getLocalIpAddresses: () => Promise<string[]>;
// EPG progress listener
onEpgProgress?: (