14 KiB
Embedded MPV Native Integration
This document explains how IPTVnator embeds MPV inside the Electron app, which files are source versus generated build output, and what must be true before the feature is safe to expose to users.
What To Commit
Source files for the embedded MPV integration:
apps/electron-backend/build-embedded-mpv.jsbuilds the native addon for the current Electron runtime.apps/electron-backend/native/binding.gypdefines the native addon build.apps/electron-backend/native/src/embedded_mpv.mmowns the macOSlibmpvrender integration.apps/electron-backend/src/app/services/embedded-mpv-native.service.tsowns Electron main-process session lifecycle and support detection.apps/electron-backend/src/app/events/embedded-mpv.events.tsregisters the IPC contract.apps/electron-backend/src/app/api/main.preload.tsexposes the preload bridge to the renderer.libs/shared/interfaces/src/lib/embedded-mpv-session.interface.tsdefines the shared session and audio-track contract.libs/ui/playback/src/lib/embedded-mpv-player/owns the Angular UI and controls.
Generated native-addon build output:
apps/electron-backend/native/build/
The build directory contains files such as Makefile, binding.Makefile, config.gypi, embedded_mpv.target.mk, gyp-mac-tool, embedded_mpv.node, .o, and .d files. These are generated by node-gyp and must not be committed. The repo .gitignore ignores this directory.
How It Is Embedded
The embedded player does not spawn the normal mpv application and does not use --wid window reparenting. IPTVnator loads libmpv through a native Node addon and renders MPV frames into an app-owned native macOS view.
The flow is:
- Angular receives a
ResolvedPortalPlaybackpayload and rendersEmbeddedMpvPlayerComponent. - The component paints a loading state before requesting native startup work.
- If available, the preload API asks the main process to prepare the embedded MPV addon. This loads
embedded_mpv.nodeand its dylibs, but does not create a native view or MPV playback session. - The component asks the preload API to create an embedded MPV session with the current viewport bounds and initial volume.
- The Electron preload forwards calls through IPC to the main process.
EmbeddedMpvNativeServiceowns sessions, polls snapshots, and emits session updates to the renderer.- The native addon creates an
mpv_handle, configuresvo=libmpv, disables MPV's own OSC/input handling, and creates an app-ownedNSOpenGLViewinside the Electron window content view. - The addon creates a
mpv_render_contextand uses MPV's render API to draw frames into the OpenGL surface. - Resize, scroll, and fullscreen changes are measured in Angular and sent back to the addon as native bounds so the
NSViewstays aligned with the Angular layout. - Playback controls remain IPTVnator-owned Angular UI. MPV receives commands only through the controlled IPC surface.
The renderer never gets direct native-module access. It can only call the preload contract:
- prepare native addon
- create session
- load playback
- set bounds
- play/pause
- seek
- set volume
- set audio track
- dispose session
- subscribe to session updates
Settings uses the preload support API only as a lightweight availability check. That check verifies platform, experiment gating, addon presence, and bundled libmpv.2.dylib presence without require()-loading embedded_mpv.node. This avoids blocking Settings navigation on macOS dlopen and code-signing work.
When embedded-mpv is the saved player, the settings store schedules an idle prepareEmbeddedMpv() call. This intentionally moves the first native addon load away from the click-to-play path. It can still block the Electron main process briefly because Node native addon loading is synchronous, but doing it during idle is less visible than doing it when the user clicks a video. Actual MPV session creation still happens on playback because it needs the current Electron window handle and viewport bounds.
The MPV video surface is a native AppKit/OpenGL view, not a normal DOM element. Do not place critical Angular overlays on top of the video viewport and expect CSS z-index to win. The embedded MPV controls use a compositor-safe control dock below the native viewport instead of a true overlay on top of the OpenGL surface.
The dock has a stable reserved height while embedded controls are enabled. Controls fade in and out inside that fixed dock, so normal show/hide behavior does not resize the native MPV viewport or make the video jump. Volume and audio-track panels replace the default transport controls inside the same dock and provide a back button to return to the default controls. Popovers and menus must stay inside that dock unless the native layering strategy changes. The native MPV view deliberately ignores hit testing so mouse movement passes through to Chromium and can reveal Angular controls even when the pointer moves quickly across the video area.
Resume And Track Handling
ResolvedPortalPlayback.startTime is treated as a media offset in seconds for VOD and episodes. The native addon passes it as the start option in one MPV loadfile options map together with title, user agent, referrer, and HTTP headers.
Live catchup is different: the catchup URL already encodes the archive window, so live catchup playback must not pass an absolute Unix timestamp as startTime.
Audio tracks are discovered from MPV's track-list property. The selected track is controlled through MPV's aid property. Switching tracks must not reload the stream.
Packaging State
Current development behavior:
- The addon build is macOS-only.
- The build script first looks for a staged runtime at
vendor/embedded-mpv/darwin-<arch>/. - The staged runtime must contain
include/mpv/client.h,lib/*.dylib, andruntime-manifest.json. - The compiled
.nodeaddon is copied intodist/apps/electron-backend/native/embedded_mpv.node. - Bundled runtime files are copied into
dist/apps/electron-backend/native/lib/. Most are.dylibfiles, but some Homebrew-linked runtimes expose non-.dylibMach-O files such as a frameworkPythonbinary. - macOS
afterPackcopiesdist/apps/electron-backend/native/intoapp.asar.unpacked/electron-backend/native/so the addon, manifest, dylibs, and non-.dylibMach-O runtime files are filesystem-addressable. - Linux and Windows packaging do not include the Embedded MPV native directory.
Current release caveat:
- Release packaging requires a
vendored-lgplruntime manifest. - Release packaging rejects embedded MPV binaries linked to
/opt/homebrewor/usr/local. - Local development can opt into Homebrew
libmpvonly by settingIPTVNATOR_EMBEDDED_MPV_ALLOW_HOMEBREW=1; packaged release validation rejects that runtime origin.
Before public release, packaging must:
- stage an LGPL-compatible
libmpvand required dylibs fordarwin-arm64anddarwin-x64 - collect indirect dependencies expressed as absolute paths,
@loader_path, or@rpath - rewrite install names and dependency paths to app-relative paths such as
@loader_path - code-sign and notarize the full dependency set
- publish the corresponding FFmpeg/libmpv source and build metadata
Users do not need the MPV GUI application for this architecture. IPTVnator bundles libmpv for release builds. If the bundled runtime is missing or fails to load, embedded MPV is hidden/unsupported and the existing inline/external players remain available.
Runtime Staging
Runtime staging tooling lives in:
/Users/4gray/Code/iptvnator/tools/embedded-mpv//Users/4gray/Code/iptvnator/vendor/embedded-mpv/
Release runtime policy:
- FFmpeg must be built without
--enable-gpland without--enable-nonfree. - mpv must be built with
-Dlibmpv=trueand-Dgpl=false. - The runtime must be dynamically linked and shipped with license/source-distribution notices.
After building an LGPL-compatible prefix for an architecture:
node tools/embedded-mpv/stage-macos-runtime.mjs arm64 /path/to/lgpl-prefix
node tools/embedded-mpv/stage-macos-runtime.mjs x64 /path/to/lgpl-prefix
Tagged macOS release CI builds that prefix from pinned source archives first. The workflow can temporarily run the same path for macOS PR artifacts while the bundled runtime is being tested:
pnpm embedded-mpv:build-runtime -- arm64 /tmp/embedded-mpv-prefix
pnpm embedded-mpv:stage-runtime -- arm64 /tmp/embedded-mpv-prefix
The CI builder pins FFmpeg 8.1, mpv 0.41.0, libplacebo 7.360.1, libass 0.17.3, FreeType 2.13.3, FriBidi 1.0.16, and HarfBuzz 8.5.0. FFmpeg disables autodetected external libraries so Homebrew libraries cannot silently enter the runtime. Libplacebo is checked out from git with the submodules required by its Meson build because the generated GitHub archive does not include submodule contents. Even with Vulkan disabled, libplacebo still compiles Vulkan stubs and needs 3rdparty/Vulkan-Headers. The generated manifest records source URLs, archive SHA-256 values where applicable, libplacebo git commit/submodule metadata, FFmpeg configure flags, and mpv Meson flags. The staging step normalizes that manifest to origin: vendored-lgpl, which release package validation requires.
The Electron backend build consumes the staged runtime, copies Mach-O runtime files into the native build output, and rewrites Mach-O paths so embedded_mpv.node loads @loader_path/lib/libmpv.2.dylib instead of a machine-local Homebrew path. After install_name_tool rewrites any addon or runtime binary, the build re-signs that binary with an ad-hoc signature for local development. Release packaging still performs the normal app signing and notarization later.
For local development before the vendored runtime exists, Homebrew can be used explicitly:
pnpm run serve:backend:embedded-mpv
That script first runs the local native build with IPTVNATOR_EMBEDDED_MPV_ALLOW_HOMEBREW=1, then starts Electron with IPTVNATOR_ENABLE_EMBEDDED_MPV_EXPERIMENT=1. This path is intentionally development-only. Packaged macOS builds reject homebrew-dev manifests and any /opt/homebrew or /usr/local embedded MPV links.
If the settings page does not show Embedded MPV (Experimental, macOS) after starting with those flags, check the native build output:
ls apps/electron-backend/native/build/Release/embedded_mpv.node
If only embedded-mpv-unavailable.txt exists, the dev app started from a build where no runtime was available. Stop the Electron dev process and rerun pnpm run serve:backend:embedded-mpv so the native target is rebuilt before Electron starts. The native MPV build target is intentionally uncached because it depends on local runtime files and environment variables such as IPTVNATOR_EMBEDDED_MPV_ALLOW_HOMEBREW and IPTVNATOR_EMBEDDED_MPV_ARCH.
If opening Settings hard-crashes Electron on macOS and the crash report says Code Signature Invalid, one of the copied runtime binaries was modified by install_name_tool without being re-signed. Rebuild the native target and verify the copied addon/runtime files:
IPTVNATOR_EMBEDDED_MPV_ALLOW_HOMEBREW=1 node apps/electron-backend/build-embedded-mpv.js
codesign --verify --verbose=2 apps/electron-backend/native/build/Release/embedded_mpv.node
codesign --verify --verbose=2 apps/electron-backend/native/build/Release/lib/libmpv.2.dylib
If support detection reports a missing @rpath/... dependency, the dependency collector missed an indirect runtime file. The packaging helper must copy that file into native/lib/, rewrite the dependency to @loader_path/<name>, and include non-.dylib Mach-O files in the asset copy glob.
Same-Version macOS Release Gate
The normal release tag can produce Linux, Windows, and macOS artifacts from the same source version while only macOS carries the bundled Embedded MPV runtime.
For tagged macOS builds, CI must:
- build the pinned LGPL-compatible runtime for the matrix architecture
- stage it into
vendor/embedded-mpv/darwin-${arch}beforepnpm run build:backend - set
IPTVNATOR_EMBEDDED_MPV_ARCH=${arch}for backend build and packaging - set
IPTVNATOR_REQUIRE_EMBEDDED_MPV=1for packaging and package-layout verification
During the temporary macOS artifact tests, CI also sets IPTVNATOR_REQUIRE_EMBEDDED_MPV=1 for macOS PR and master push backend build, packaging, and package-layout verification. After the artifacts are manually validated, remove the workflow's temporary pull_request and refs/heads/master conditions so PR, development, Linux, and Windows packaging leave IPTVNATOR_REQUIRE_EMBEDDED_MPV unset or 0. In that normal mode the package validators still reject a present but invalid Embedded MPV runtime, but they do not require the addon to exist. This keeps the native feature in-tree without making non-macOS or non-release builds depend on macOS runtime artifacts.
Release Safety
The feature is still experimental. The largest risks are native-process risks, not normal Angular UI risks:
- a bad native addon or
libmpvcrash can crash the Electron main process - packaging can fail if
libmpvor one of its dylib dependencies is missing, unsigned, or linked to the wrong runtime path - macOS graphics behavior can vary across Intel, Apple Silicon, external displays, fullscreen transitions, and hardware decoding paths
- Homebrew
libmpvbuilds can target a newer macOS version than IPTVnator's declared deployment target
It is reasonable to ship the code in-tree behind the current experiment flag. It is not yet safe to make it the default player. It can be exposed as macOS-only experimental if support detection is strict, the UI clearly labels it experimental, and fallback to Video.js or external MPV/VLC stays available.
If an embedded session fails to initialize, the app should keep the user in control by preserving the normal player setting choices. If a native crash occurs, normal settings fallback cannot intercept that crash, so broader macOS smoke testing and packaged-app testing are required before broad release.
Suggested Release Gate
Do not expose embedded MPV broadly until these pass on both Apple Silicon and Intel macOS:
- packaged
.appstarts without Homebrew installed - bundled
libmpvand dependent dylibs pass code signing and notarization - VOD resume starts near the saved offset
- live HLS, MPEG-TS, MP4/VOD, headers, referrer, volume, seek, fullscreen, route changes, and cleanup work
- audio-track switching works on a stream with multiple audio tracks
- fallback behavior is clear when the addon or native dependencies are unavailable