fix: harden linux embedded mpv packaging

This commit is contained in:
4gray committed 2026-06-10 21:16:12 +02:00
1 parent 2be42257ab
commit 959b40bce7
22 files changed
+1893 -267

No files matched your search

+53 -6
View File
@@ -68,7 +68,7 @@ jobs:
if: matrix.os == 'linux'
run: |
sudo apt-get update
sudo apt-get install --no-install-recommends -y rpm libarchive-tools flatpak flatpak-builder appstream libx11-dev libxext-dev
sudo apt-get install --no-install-recommends -y rpm libarchive-tools flatpak flatpak-builder appstream libx11-dev libxext-dev libmpv-dev mpv pkg-config
# Configure Flatpak
# 1. Add the Flathub repository (source of runtimes)
@@ -204,17 +204,54 @@ jobs:
pnpm embedded-mpv:build-runtime -- "${{ matrix.embedded_mpv_arch }}" "${RUNTIME_PREFIX}"
pnpm embedded-mpv:stage-runtime -- "${{ matrix.embedded_mpv_platform }}" "${{ matrix.embedded_mpv_arch }}" "${RUNTIME_PREFIX}"
- name: Stage Linux embedded MPV build inputs
if: matrix.os == 'linux'
shell: bash
run: |
set -euo pipefail
RUNTIME_PREFIX="${RUNNER_TEMP}/embedded-mpv-runtime/linux-x64/prefix"
rm -rf "${RUNTIME_PREFIX}"
mkdir -p "${RUNTIME_PREFIX}/include"
cp -a /usr/include/mpv "${RUNTIME_PREFIX}/include/"
LIBMPV_DEV_VERSION="$(dpkg-query -W -f='${Version}' libmpv-dev)"
MPV_VERSION="$(dpkg-query -W -f='${Version}' mpv)"
export RUNTIME_PREFIX LIBMPV_DEV_VERSION MPV_VERSION
node <<'NODE'
const fs = require('fs');
const path = require('path');
const manifest = {
linuxBackend: 'process-isolated mpv --wid',
buildInputs: {
libmpvDevPackage: process.env.LIBMPV_DEV_VERSION,
mpvPackage: process.env.MPV_VERSION,
},
sourceDistribution:
'Linux CI build inputs come from Ubuntu runner packages. Runtime playback uses the system mpv executable; IPTVnator does not bundle or load libmpv in the Electron process on Linux.',
};
fs.writeFileSync(
path.join(process.env.RUNTIME_PREFIX, 'runtime-manifest.json'),
`${JSON.stringify(manifest, null, 2)}\n`
);
NODE
pnpm embedded-mpv:stage-runtime -- linux x64 "${RUNTIME_PREFIX}"
- name: Build backend
env:
IPTVNATOR_EMBEDDED_MPV_PLATFORM: ${{ matrix.embedded_mpv_platform || '' }}
IPTVNATOR_EMBEDDED_MPV_ARCH: ${{ matrix.embedded_mpv_arch || matrix.arch || '' }}
IPTVNATOR_REQUIRE_EMBEDDED_MPV: ${{ ((matrix.os == 'macos' && (startsWith(github.ref, 'refs/tags/v') || github.event_name == 'pull_request' || github.ref == 'refs/heads/master')) || (steps.embedded-mpv-runtime-cache.outputs.cache-hit == 'true')) && '1' || '0' }}
IPTVNATOR_REQUIRE_EMBEDDED_MPV: ${{ (matrix.os == 'linux' || (matrix.os == 'macos' && (startsWith(github.ref, 'refs/tags/v') || github.event_name == 'pull_request' || github.ref == 'refs/heads/master')) || (steps.embedded-mpv-runtime-cache.outputs.cache-hit == 'true')) && '1' || '0' }}
run: pnpm run build:backend
- name: Verify embedded MPV build output
# TEMPORARY ARTIFACT TEST: remove `|| github.event_name == 'pull_request' || github.ref == 'refs/heads/master'`
# after the macOS Embedded MPV artifacts are built and manually tested.
if: (matrix.os == 'macos' && (startsWith(github.ref, 'refs/tags/v') || github.event_name == 'pull_request' || github.ref == 'refs/heads/master')) || steps.embedded-mpv-runtime-cache.outputs.cache-hit == 'true'
if: matrix.os == 'linux' || (matrix.os == 'macos' && (startsWith(github.ref, 'refs/tags/v') || github.event_name == 'pull_request' || github.ref == 'refs/heads/master')) || steps.embedded-mpv-runtime-cache.outputs.cache-hit == 'true'
shell: bash
run: |
set -euo pipefail
@@ -238,7 +275,17 @@ jobs:
test -f dist/apps/electron-backend/native/lib/mpv-2.dll || test -f dist/apps/electron-backend/native/lib/mpv.dll
;;
linux)
test -f dist/apps/electron-backend/native/lib/libmpv.so.2 || test -f dist/apps/electron-backend/native/lib/libmpv.so.1 || test -f dist/apps/electron-backend/native/lib/libmpv.so
node -e "const manifest = require('./dist/apps/electron-backend/native/embedded-mpv-runtime.json'); if (manifest.origin !== 'external-mpv-process') { throw new Error('Linux embedded MPV manifest must use external-mpv-process origin.'); }"
if find dist/apps/electron-backend/native/lib -name 'libmpv.so*' -print -quit 2>/dev/null | grep -q .; then
echo "::error::Linux embedded MPV packages must not bundle libmpv"
find dist/apps/electron-backend/native/lib -name 'libmpv.so*' -print
exit 1
fi
if ldd dist/apps/electron-backend/native/embedded_mpv.node | grep -q 'libmpv'; then
echo "::error::Linux embedded MPV addon must not link directly to libmpv"
ldd dist/apps/electron-backend/native/embedded_mpv.node
exit 1
fi
;;
esac
@@ -424,7 +471,7 @@ jobs:
IPTVNATOR_EMBEDDED_MPV_ARCH: ${{ matrix.embedded_mpv_arch || matrix.arch || '' }}
# TEMPORARY PR TEST: change this back to '0' after manually
# testing the macOS PR artifact with Embedded MPV included.
IPTVNATOR_REQUIRE_EMBEDDED_MPV: ${{ ((matrix.os == 'macos' && github.event_name == 'pull_request') || (steps.embedded-mpv-runtime-cache.outputs.cache-hit == 'true')) && '1' || '0' }}
IPTVNATOR_REQUIRE_EMBEDDED_MPV: ${{ (matrix.os == 'linux' || (matrix.os == 'macos' && github.event_name == 'pull_request') || (steps.embedded-mpv-runtime-cache.outputs.cache-hit == 'true')) && '1' || '0' }}
run: pnpm run make:app
- name: Verify packaged worker layout
@@ -434,7 +481,7 @@ jobs:
PACKAGE_ARCH: ${{ matrix.arch || '' }}
# TEMPORARY ARTIFACT TEST: remove `|| github.event_name == 'pull_request' || github.ref == 'refs/heads/master'`
# after the macOS Embedded MPV artifacts are built and manually tested.
IPTVNATOR_REQUIRE_EMBEDDED_MPV: ${{ ((matrix.os == 'macos' && (startsWith(github.ref, 'refs/tags/v') || github.event_name == 'pull_request' || github.ref == 'refs/heads/master')) || (steps.embedded-mpv-runtime-cache.outputs.cache-hit == 'true')) && '1' || '0' }}
IPTVNATOR_REQUIRE_EMBEDDED_MPV: ${{ (matrix.os == 'linux' || (matrix.os == 'macos' && (startsWith(github.ref, 'refs/tags/v') || github.event_name == 'pull_request' || github.ref == 'refs/heads/master')) || (steps.embedded-mpv-runtime-cache.outputs.cache-hit == 'true')) && '1' || '0' }}
run: pnpm run verify:package-layout -- "$PACKAGE_OS" "$PACKAGE_ARCH"
- name: Save embedded MPV runtime cache
+4 -3
View File
@@ -81,6 +81,7 @@ Thumbs.db
# Native Electron addons
apps/electron-backend/native/build/
apps/electron-backend/src/app/options/electron-builder.metadata.generated.json
vendor/embedded-mpv/darwin-*/include/
vendor/embedded-mpv/darwin-*/lib/
vendor/embedded-mpv/darwin-*/runtime-manifest.json
vendor/embedded-mpv/*/bin/
vendor/embedded-mpv/*/include/
vendor/embedded-mpv/*/lib/
vendor/embedded-mpv/*/runtime-manifest.json
+49 -33
View File
@@ -61,42 +61,42 @@ The application is a cross-platform, open-source project built with Electron and
Press `?` or `Shift+/` in the workspace to open the in-app shortcuts list.
| Area | Shortcut | Action |
| --- | --- | --- |
| Global | `Ctrl/Cmd+K` | Open command palette |
| Global | `Ctrl/Cmd+F` | Open global search in the desktop app |
| Global | `Ctrl/Cmd+R` | Open recently viewed in the desktop app |
| Global | `Enter` in workspace search | Submit the current search |
| Navigation | `Ctrl/Cmd+B` | Toggle the live sidebar |
| Navigation | `0-9` | Select an M3U channel by number |
| Playback | `Space` / `K` | Play or pause embedded MPV playback in the desktop app |
| Playback | `F` | Toggle embedded MPV fullscreen in the desktop app |
| Playback | `ArrowLeft` / `ArrowRight` | Seek embedded MPV playback by 5 seconds in the desktop app |
| Playback | `ArrowUp` / `ArrowDown` | Adjust volume by 5% |
| Playback | `M` | Mute audio |
| Dialogs and lists | `ArrowUp` / `ArrowDown` | Move command palette selection |
| Dialogs and lists | `Enter` | Run the selected command or open a focused item |
| Dialogs and lists | `Escape` | Close dialogs and dismiss overlays |
| Area | Shortcut | Action |
| ----------------- | --------------------------- | ---------------------------------------------------------- |
| Global | `Ctrl/Cmd+K` | Open command palette |
| Global | `Ctrl/Cmd+F` | Open global search in the desktop app |
| Global | `Ctrl/Cmd+R` | Open recently viewed in the desktop app |
| Global | `Enter` in workspace search | Submit the current search |
| Navigation | `Ctrl/Cmd+B` | Toggle the live sidebar |
| Navigation | `0-9` | Select an M3U channel by number |
| Playback | `Space` / `K` | Play or pause embedded MPV playback in the desktop app |
| Playback | `F` | Toggle embedded MPV fullscreen in the desktop app |
| Playback | `ArrowLeft` / `ArrowRight` | Seek embedded MPV playback by 5 seconds in the desktop app |
| Playback | `ArrowUp` / `ArrowDown` | Adjust volume by 5% |
| Playback | `M` | Mute audio |
| Dialogs and lists | `ArrowUp` / `ArrowDown` | Move command palette selection |
| Dialogs and lists | `Enter` | Run the selected command or open a focused item |
| Dialogs and lists | `Escape` | Close dialogs and dismiss overlays |
## Screenshots:
| Dashboard with recently watched content | Live channels with inline player and EPG |
| :-------------------------------------: | :--------------------------------------: |
| ![Dashboard with recently watched content](./apps/website/public/screenshots/dashboard-with-content.webp) | ![Live channels with inline player and EPG](./apps/website/public/screenshots/screenshot-player.webp) |
| Add playlist dialog for M3U, Xtream, and Stalker | Live category channel list |
| ![Add playlist dialog for M3U, Xtream, and Stalker](./apps/website/public/screenshots/add-playlist.webp) | ![Live category channel list](./apps/website/public/screenshots/channels-view.webp) |
| Global search across live TV, movies, and series | Manage visible live categories |
| ![Global search across live TV, movies, and series](./apps/website/public/screenshots/global-search.webp) | ![Manage visible live categories](./apps/website/public/screenshots/manage-categories.webp) |
| Movie category grid with sorting and pagination | Recently added movies and series |
| ![Movie category grid with sorting and pagination](./apps/website/public/screenshots/xtream-category-view.webp) | ![Recently added movies and series](./apps/website/public/screenshots/xtream-recently-added.webp) |
| VOD details with playback and download actions | Download manager |
| ![VOD details with playback and download actions](./apps/website/public/screenshots/vod-details.webp) | ![Download manager](./apps/website/public/screenshots/download-manager.webp) |
| Multi-channel EPG grid | External MPV player support |
| ![Multi-channel EPG grid](./apps/website/public/screenshots/multi-epg-view.webp) | ![External MPV player support](./apps/website/public/screenshots/external-player-support-mpv.webp) |
| Radio playback with dedicated audio player | Light theme |
| ![Radio playback with dedicated audio player](./apps/website/public/screenshots/radio-feature.webp) | ![Light theme](./apps/website/public/screenshots/light-theme.webp) |
| Application settings | |
| ![Application settings](./apps/website/public/screenshots/settings.webp) | |
| Dashboard with recently watched content | Live channels with inline player and EPG |
| :-------------------------------------------------------------------------------------------------------------: | :---------------------------------------------------------------------------------------------------: |
| ![Dashboard with recently watched content](./apps/website/public/screenshots/dashboard-with-content.webp) | ![Live channels with inline player and EPG](./apps/website/public/screenshots/screenshot-player.webp) |
| Add playlist dialog for M3U, Xtream, and Stalker | Live category channel list |
| ![Add playlist dialog for M3U, Xtream, and Stalker](./apps/website/public/screenshots/add-playlist.webp) | ![Live category channel list](./apps/website/public/screenshots/channels-view.webp) |
| Global search across live TV, movies, and series | Manage visible live categories |
| ![Global search across live TV, movies, and series](./apps/website/public/screenshots/global-search.webp) | ![Manage visible live categories](./apps/website/public/screenshots/manage-categories.webp) |
| Movie category grid with sorting and pagination | Recently added movies and series |
| ![Movie category grid with sorting and pagination](./apps/website/public/screenshots/xtream-category-view.webp) | ![Recently added movies and series](./apps/website/public/screenshots/xtream-recently-added.webp) |
| VOD details with playback and download actions | Download manager |
| ![VOD details with playback and download actions](./apps/website/public/screenshots/vod-details.webp) | ![Download manager](./apps/website/public/screenshots/download-manager.webp) |
| Multi-channel EPG grid | External MPV player support |
| ![Multi-channel EPG grid](./apps/website/public/screenshots/multi-epg-view.webp) | ![External MPV player support](./apps/website/public/screenshots/external-player-support-mpv.webp) |
| Radio playback with dedicated audio player | Light theme |
| ![Radio playback with dedicated audio player](./apps/website/public/screenshots/radio-feature.webp) | ![Light theme](./apps/website/public/screenshots/light-theme.webp) |
| Application settings | |
| ![Application settings](./apps/website/public/screenshots/settings.webp) | |
_Note: First version of the application which was developed as a PWA is available in an extra git branch._
@@ -158,6 +158,22 @@ sudo emerge --sync gentoo-zh
sudo emerge iptvnator-bin
```
### Linux Embedded MPV Support
Embedded MPV on Linux is experimental and currently supports x64 desktop
sessions where IPTVnator runs under X11 or Xwayland. Native Wayland embedding
is not supported yet. Linux package launchers request X11 with
`--ozone-platform=x11`, so Wayland desktops still need Xwayland available.
The Linux backend starts a system `mpv` executable with `--wid`, so `mpv` must
be installed and available on `PATH`. CI validates the Linux native addon and
standard packages on Ubuntu 22.04, with Flatpak packaging built on Ubuntu 24.04.
Expected user targets are Ubuntu/Debian `.deb`, Arch/Manjaro `pacman`, RPM
distributions, and AppImage on x64 systems with X11/Xwayland plus `mpv`
installed. Flatpak and Snap builds remain available, but embedded MPV is not
announced as supported there yet because those sandboxed formats do not expose
the host `mpv` executable to the embedded backend by default.
[![Get it from the Snap Store](https://snapcraft.io/static/images/badges/en/snap-store-black.svg)](https://snapcraft.io/iptvnator)
<a href="https://github.com/sponsors/4gray" target="_blank"><img src="https://cdn.buymeacoffee.com/buttons/default-green.png" alt="Buy Me A Coffee" width="185"></a>
+61 -17
View File
@@ -9,7 +9,12 @@ const {
} = require('../../tools/packaging/embedded-mpv-packaging.cjs');
const workspaceRoot = process.cwd();
const addonRoot = path.join(workspaceRoot, 'apps', 'electron-backend', 'native');
const addonRoot = path.join(
workspaceRoot,
'apps',
'electron-backend',
'native'
);
const outputDir = path.join(addonRoot, 'build', 'Release');
const outputFile = path.join(outputDir, 'embedded_mpv.node');
const outputLibDir = path.join(outputDir, 'lib');
@@ -20,7 +25,10 @@ const distNativeDir = path.join(
'electron-backend',
'native'
);
const unavailableMarkerFile = path.join(outputDir, 'embedded-mpv-unavailable.txt');
const unavailableMarkerFile = path.join(
outputDir,
'embedded-mpv-unavailable.txt'
);
const homebrewIncludeDir = '/opt/homebrew/include';
const homebrewLibDir = '/opt/homebrew/lib';
const targetPlatform =
@@ -164,7 +172,7 @@ function resolveRuntime() {
: targetPlatform === 'win32'
? findWindowsLibMpv(vendoredRuntimeRoot)
: targetPlatform === 'linux'
? findLinuxLibMpv(vendoredLibDir)
? true
: null;
const vendoredHeader = path.join(vendoredIncludeDir, 'mpv', 'client.h');
@@ -211,6 +219,29 @@ function resolveRuntime() {
return null;
}
function writeLinuxProcessRuntimeManifest(runtime) {
fs.rmSync(outputLibDir, { recursive: true, force: true });
const manifest = {
...runtime.manifest,
origin: 'external-mpv-process',
generatedAt: new Date().toISOString(),
runtimeFiles: [],
linuxBackend:
runtime.manifest.linuxBackend ?? 'process-isolated mpv --wid',
mpvExecutable: 'mpv',
platform: targetPlatform,
targetArch,
};
fs.writeFileSync(
path.join(outputDir, 'embedded-mpv-runtime.json'),
`${JSON.stringify(manifest, null, 2)}\n`
);
return manifest;
}
function copyFile(sourcePath, destinationPath) {
fs.mkdirSync(path.dirname(destinationPath), { recursive: true });
fs.copyFileSync(sourcePath, destinationPath);
@@ -322,7 +353,9 @@ function runNodeGyp(command, env) {
);
if (result.status !== 0) {
throw new Error(`node-gyp ${command} failed with status ${result.status ?? 1}.`);
throw new Error(
`node-gyp ${command} failed with status ${result.status ?? 1}.`
);
}
}
@@ -349,9 +382,11 @@ function main() {
const message = [
`Skipping build because no embedded MPV runtime was found for ${targetPlatform}-${targetArch}.`,
`Expected vendored runtime at ${vendoredRuntimeRoot}.`,
targetPlatform === 'darwin'
? 'For local development only, set IPTVNATOR_EMBEDDED_MPV_ALLOW_HOMEBREW=1 to use Homebrew libmpv.'
: 'Stage a vendored LGPL-compatible runtime before requiring Embedded MPV on this platform.',
targetPlatform === 'linux'
? 'Stage Linux MPV build inputs before requiring Embedded MPV on this platform.'
: targetPlatform === 'darwin'
? 'For local development only, set IPTVNATOR_EMBEDDED_MPV_ALLOW_HOMEBREW=1 to use Homebrew libmpv.'
: 'Stage a vendored LGPL-compatible runtime before requiring Embedded MPV on this platform.',
].join('\n');
if (embeddedMpvRequired) {
throw new Error(message);
@@ -372,15 +407,14 @@ function main() {
...runtime.manifest,
},
})
: copyGenericRuntimeToNativeBuild(runtime);
: targetPlatform === 'linux'
? writeLinuxProcessRuntimeManifest(runtime)
: copyGenericRuntimeToNativeBuild(runtime);
fs.rmSync(unavailableMarkerFile, { force: true });
const electronPackageJson = require(path.join(
workspaceRoot,
'node_modules',
'electron',
'package.json'
));
const electronPackageJson = require(
path.join(workspaceRoot, 'node_modules', 'electron', 'package.json')
);
const electronVersion = electronPackageJson.version;
const env = {
...process.env,
@@ -391,9 +425,16 @@ function main() {
npm_config_build_from_source: 'true',
npm_config_update_binary: 'false',
LIBMPV_INCLUDE_DIR: runtime.includeDir,
LIBMPV_LIBRARY_DIR: outputLibDir,
...(targetPlatform === 'linux'
? { LINUX_NATIVE_LIBRARY_DIR: runtime.libDir }
: { LIBMPV_LIBRARY_DIR: outputLibDir }),
...(runtime.windowsImportLib
? { LIBMPV_IMPORT_LIB: path.join(outputLibDir, path.basename(runtime.windowsImportLib)) }
? {
LIBMPV_IMPORT_LIB: path.join(
outputLibDir,
path.basename(runtime.windowsImportLib)
),
}
: {}),
};
@@ -415,7 +456,10 @@ function main() {
path.join(outputLibDir, dylib)
),
]);
if (runtime.origin === 'vendored-lgpl' && forbiddenLinkErrors.length > 0) {
if (
runtime.origin === 'vendored-lgpl' &&
forbiddenLinkErrors.length > 0
) {
throw new Error(forbiddenLinkErrors.join('\n'));
}
}
+7 -7
View File
@@ -63,14 +63,14 @@
"sources": [
"src/embedded_mpv_linux.cc"
],
"libraries": [
"-L<!(node -p \"process.env.LIBMPV_LIBRARY_DIR || '/usr/lib'\")",
"-lmpv",
"-lX11",
"-lXext"
"defines": [
"IPTVNATOR_DYNAMIC_LIBMPV"
],
"ldflags": [
"-Wl,-rpath,\\$$ORIGIN/lib"
"libraries": [
"-L<!(node -p \"process.env.LINUX_NATIVE_LIBRARY_DIR || '/usr/lib'\")",
"-lX11",
"-lXext",
"-ldl"
]
}
]
@@ -1,3 +1,7 @@
#ifndef _GNU_SOURCE
#define _GNU_SOURCE
#endif
#include <X11/Xlib.h>
#include <X11/extensions/shape.h>
@@ -7,11 +11,73 @@
#include <cmath>
#include <cstdlib>
#include <cstdint>
#include <iostream>
#include <mutex>
#include <sstream>
#include <string>
namespace {
std::mutex gX11ErrorMutex;
bool gX11ErrorTrapped = false;
XErrorEvent gLastX11Error{};
int trapX11Error(Display*, XErrorEvent* event)
{
gX11ErrorTrapped = true;
gLastX11Error = *event;
return 0;
}
bool isTraceEnabled()
{
return std::getenv("IPTVNATOR_TRACE_EMBEDDED_MPV") != nullptr;
}
void trace(const std::string& message)
{
if (!isTraceEnabled()) {
return;
}
std::cerr << "[Embedded MPV Linux] " << message << std::endl;
}
class ScopedX11ErrorTrap {
public:
explicit ScopedX11ErrorTrap(Display* display)
: lock_(gX11ErrorMutex)
, display_(display)
, previousHandler_(XSetErrorHandler(trapX11Error))
{
gX11ErrorTrapped = false;
gLastX11Error = {};
}
~ScopedX11ErrorTrap()
{
if (display_) {
XSync(display_, False);
}
XSetErrorHandler(previousHandler_);
}
bool failed() const
{
return gX11ErrorTrapped;
}
int errorCode() const
{
return gLastX11Error.error_code;
}
private:
std::unique_lock<std::mutex> lock_;
Display* display_ = nullptr;
XErrorHandler previousHandler_ = nullptr;
};
class NativeVideoHost {
public:
static bool isAvailable()
@@ -32,6 +98,13 @@ public:
return false;
}
if (parentHandle <= 1) {
lastError_ =
"Electron did not provide a valid X11 parent window; native Wayland embedding is not supported yet.";
return false;
}
trace("opening X11 display");
display_ = XOpenDisplay(nullptr);
if (!display_) {
lastError_ = "Unable to open the X11 display for embedded MPV.";
@@ -39,6 +112,7 @@ public:
}
parentWindow_ = static_cast<Window>(parentHandle);
trace("parent window " + std::to_string(static_cast<unsigned long>(parentWindow_)));
if (!parentWindow_) {
lastError_ = "Unable to resolve Electron X11 window handle.";
destroy();
@@ -49,29 +123,54 @@ public:
attributes.background_pixel = BlackPixel(display_, DefaultScreen(display_));
attributes.event_mask = ExposureMask | StructureNotifyMask;
window_ = XCreateWindow(
display_,
parentWindow_,
0,
0,
1,
1,
0,
CopyFromParent,
InputOutput,
CopyFromParent,
CWBackPixel | CWEventMask,
&attributes
);
{
ScopedX11ErrorTrap x11Errors(display_);
trace("creating child window");
window_ = XCreateWindow(
display_,
parentWindow_,
0,
0,
1,
1,
0,
CopyFromParent,
InputOutput,
CopyFromParent,
CWBackPixel | CWEventMask,
&attributes
);
XSync(display_, False);
if (!window_ || x11Errors.failed()) {
std::ostringstream message;
message
<< "Failed to create embedded MPV X11 child window.";
if (x11Errors.failed()) {
message << " X11 error code: " << x11Errors.errorCode()
<< ".";
}
message
<< " Electron did not provide a valid X11 parent window; "
"native Wayland embedding is not supported yet.";
lastError_ = message.str();
window_ = 0;
destroy();
return false;
}
}
if (!window_) {
lastError_ = "Failed to create embedded MPV X11 child window.";
destroy();
return false;
}
trace("clearing input shape");
clearInputShape();
trace("mapping child window");
XMapWindow(display_, window_);
trace("setting child bounds");
setBounds(bounds);
trace("flushing X11 display");
XFlush(display_);
return true;
}
@@ -99,9 +198,7 @@ public:
std::string wid() const
{
std::ostringstream stream;
stream << static_cast<unsigned long>(window_);
return stream.str();
return std::to_string(static_cast<unsigned long>(window_));
}
void destroy()
File diff suppressed because it is too large. Load diff
@@ -26,7 +26,10 @@ describe('Embedded MPV native source recording invariants', () => {
'utf8'
);
const buildAndMakeWorkflowSource = readFileSync(
path.resolve(__dirname, '../../../../../.github/workflows/build-and-make.yaml'),
path.resolve(
__dirname,
'../../../../../.github/workflows/build-and-make.yaml'
),
'utf8'
);
@@ -151,7 +154,9 @@ describe('Embedded MPV native source recording invariants', () => {
});
it('checks Win32 window class registration failures explicitly', () => {
expect(win32Source).toContain('const ATOM classAtom = RegisterClassExW');
expect(win32Source).toContain(
'const ATOM classAtom = RegisterClassExW'
);
expect(win32Source).toContain('ERROR_CLASS_ALREADY_EXISTS');
expect(win32Source).toContain(
'Failed to register embedded MPV child window class.'
@@ -165,6 +170,43 @@ describe('Embedded MPV native source recording invariants', () => {
expect(linuxSource).toContain('drainEvents();');
});
it('keeps Linux mpv child processes isolated from Wayland and inherited Electron descriptors', () => {
expect(widCommonSource).toContain(
'if (hasEnvPrefix(entry, "WAYLAND_DISPLAY="))'
);
expect(widCommonSource).toContain(
'environment.push_back("XDG_SESSION_TYPE=x11");'
);
expect(widCommonSource).toContain(
'arguments.push_back("--vo=gpu,x11");'
);
expect(widCommonSource).toContain(
'arguments.push_back("--gpu-context=x11egl");'
);
expect(widCommonSource).toContain('closeInheritedFileDescriptors();');
expect(widCommonSource).toContain('flags | FD_CLOEXEC');
});
it('drives Linux out-of-process MPV state and controls over JSON IPC', () => {
expect(widCommonSource).toContain('mpvIpcSocketPath');
expect(widCommonSource).toContain(
'arguments.push_back("--input-ipc-server=" + ipcSocketPath);'
);
expect(widCommonSource).toContain('refreshLinuxMpvSnapshot(session);');
expect(widCommonSource).toContain(
'queryLinuxMpvNumber(socketPath, "time-pos")'
);
expect(widCommonSource).toContain(
'queryLinuxMpvNumber(socketPath, "duration")'
);
expect(widCommonSource).toContain(
'std::string("{\\"command\\":[\\"set_property\\",\\"pause\\",")'
);
expect(widCommonSource).toContain(
'"{\\"command\\":[\\"seek\\"," + seconds + ",\\"absolute\\"]}\\n"'
);
});
it('uses platform-specific embedded MPV runtime cache key inputs in CI', () => {
expect(buildAndMakeWorkflowSource).toContain(
"const targetPlatform = '${{ matrix.embedded_mpv_platform }}';"
@@ -183,6 +225,25 @@ describe('Embedded MPV native source recording invariants', () => {
' `xcode${xcodeHash}`,'
);
});
it('requires Linux embedded MPV build inputs and validates process isolation in CI', () => {
expect(buildAndMakeWorkflowSource).toContain(
'libmpv-dev mpv pkg-config'
);
expect(buildAndMakeWorkflowSource).toContain(
'Stage Linux embedded MPV build inputs'
);
expect(buildAndMakeWorkflowSource).toContain(
"linuxBackend: 'process-isolated mpv --wid'"
);
expect(buildAndMakeWorkflowSource).toContain("matrix.os == 'linux'");
expect(buildAndMakeWorkflowSource).toContain(
'Linux embedded MPV addon must not link directly to libmpv'
);
expect(buildScriptSource).toContain("origin: 'external-mpv-process'");
expect(buildScriptSource).toContain('writeLinuxProcessRuntimeManifest');
expect(buildScriptSource).toContain('runtimeFiles: []');
});
});
describe('Embedded MPV native build configuration', () => {
@@ -8,24 +8,38 @@ import { tmpdir } from 'os';
import path from 'path';
import type { EmbeddedMpvNativeService as EmbeddedMpvNativeServiceType } from './embedded-mpv-native.service';
const mockSpawnSync = jest.fn();
jest.mock('child_process', () => ({
spawnSync: mockSpawnSync,
}));
const powerSaveBlockerMock = {
start: jest.fn<number, [string]>(),
stop: jest.fn<void, [number]>(),
isStarted: jest.fn<boolean, [number]>(),
};
const commandLineMock = {
getSwitchValue: jest.fn<string, [string]>(),
};
const appMock = {
isPackaged: true,
getAppPath: () => '/mock/app.asar',
commandLine: commandLineMock,
};
jest.mock('electron', () => ({
app: {
isPackaged: true,
getAppPath: () => '/mock/app.asar',
},
app: appMock,
powerSaveBlocker: powerSaveBlockerMock,
}));
const mainWindowSendMock = jest.fn();
const mainWindowGetNativeWindowHandleMock = jest.fn<Buffer, []>(() =>
Buffer.alloc(8)
);
const mainWindowMock = {
isDestroyed: () => false,
getNativeWindowHandle: () => Buffer.alloc(8),
getNativeWindowHandle: mainWindowGetNativeWindowHandleMock,
webContents: { send: mainWindowSendMock },
};
@@ -98,6 +112,9 @@ describe('EmbeddedMpvNativeService power blocker', () => {
let addon: MockAddon;
let nextBlockerId: number;
let originalPlatform: NodeJS.Platform;
let originalDisplay: string | undefined;
let originalOzonePlatformHint: string | undefined;
let originalWaylandDisplay: string | undefined;
let tempDirs: string[];
beforeEach(async () => {
@@ -105,6 +122,14 @@ describe('EmbeddedMpvNativeService power blocker', () => {
powerSaveBlockerMock.start.mockReset();
powerSaveBlockerMock.stop.mockReset();
powerSaveBlockerMock.isStarted.mockReset();
commandLineMock.getSwitchValue.mockReset();
commandLineMock.getSwitchValue.mockReturnValue('');
mockSpawnSync.mockReset();
mockSpawnSync.mockReturnValue({
status: 0,
});
mainWindowGetNativeWindowHandleMock.mockReset();
mainWindowGetNativeWindowHandleMock.mockReturnValue(Buffer.alloc(8));
mainWindowSendMock.mockReset();
tempDirs = [];
@@ -115,6 +140,9 @@ describe('EmbeddedMpvNativeService power blocker', () => {
Object.defineProperty(process, 'platform', {
value: 'darwin',
});
originalDisplay = process.env.DISPLAY;
originalOzonePlatformHint = process.env.ELECTRON_OZONE_PLATFORM_HINT;
originalWaylandDisplay = process.env.WAYLAND_DISPLAY;
({ EmbeddedMpvNativeService } =
await import('./embedded-mpv-native.service'));
@@ -134,8 +162,20 @@ describe('EmbeddedMpvNativeService power blocker', () => {
Object.defineProperty(process, 'platform', {
value: originalPlatform,
});
restoreEnv('DISPLAY', originalDisplay);
restoreEnv('ELECTRON_OZONE_PLATFORM_HINT', originalOzonePlatformHint);
restoreEnv('WAYLAND_DISPLAY', originalWaylandDisplay);
});
function restoreEnv(key: string, value: string | undefined): void {
if (value === undefined) {
delete process.env[key];
return;
}
process.env[key] = value;
}
function createTempDir(): string {
const tempDir = mkdtempSync(
path.join(tmpdir(), 'iptvnator-recording-')
@@ -181,14 +221,12 @@ describe('EmbeddedMpvNativeService power blocker', () => {
// s1 keeps failing while s2 stays healthy: the healthy session
// must not reset the log suppression for the failing one.
addon.getSessionSnapshot.mockImplementation(
(sessionId: string) => {
if (sessionId === 's1') {
throw new Error('addon crashed');
}
return snapshot('playing');
addon.getSessionSnapshot.mockImplementation((sessionId: string) => {
if (sessionId === 's1') {
throw new Error('addon crashed');
}
);
return snapshot('playing');
});
// Three poll ticks: nothing may escape the interval callback,
// and the failure is logged once instead of at poll rate.
@@ -308,6 +346,7 @@ describe('EmbeddedMpvNativeService power blocker', () => {
it.each<NodeJS.Platform>(['darwin', 'win32', 'linux'])(
'reports embedded MPV support on %s when the addon is already loaded',
(platform) => {
delete process.env.WAYLAND_DISPLAY;
Object.defineProperty(process, 'platform', {
value: platform,
});
@@ -321,6 +360,78 @@ describe('EmbeddedMpvNativeService power blocker', () => {
}
);
it('reports Linux Wayland as unsupported unless Electron is using X11/Xwayland', () => {
Object.defineProperty(process, 'platform', {
value: 'linux',
});
process.env.DISPLAY = ':0';
process.env.WAYLAND_DISPLAY = 'wayland-0';
const support = service.getSupport();
expect(support.supported).toBe(false);
expect(support.reason).toContain('Native Wayland embedding');
});
it('does not treat the ozone platform hint env as proof that Electron is using X11', () => {
Object.defineProperty(process, 'platform', {
value: 'linux',
});
process.env.DISPLAY = ':0';
process.env.WAYLAND_DISPLAY = 'wayland-0';
process.env.ELECTRON_OZONE_PLATFORM_HINT = 'x11';
expect(service.getSupport().supported).toBe(false);
});
it('reports Linux Wayland as supported when X11 ozone is requested', () => {
Object.defineProperty(process, 'platform', {
value: 'linux',
});
process.env.DISPLAY = ':0';
process.env.WAYLAND_DISPLAY = 'wayland-0';
commandLineMock.getSwitchValue.mockReturnValue('x11');
expect(service.getSupport()).toEqual(
expect.objectContaining({
supported: true,
platform: 'linux',
})
);
});
it('reports Linux as unsupported when the mpv executable is missing', () => {
Object.defineProperty(process, 'platform', {
value: 'linux',
});
delete process.env.WAYLAND_DISPLAY;
mockSpawnSync.mockReturnValueOnce({
status: null,
error: Object.assign(new Error('not found'), { code: 'ENOENT' }),
});
const support = service.getSupport();
expect(support.supported).toBe(false);
expect(support.reason).toContain('requires the mpv executable on PATH');
});
it('rejects Electron native Wayland placeholder handles before calling the addon', () => {
Object.defineProperty(process, 'platform', {
value: 'linux',
});
process.env.DISPLAY = ':0';
process.env.WAYLAND_DISPLAY = 'wayland-0';
mainWindowGetNativeWindowHandleMock.mockReturnValueOnce(
Buffer.from([1, 0, 0, 0])
);
expect(() => service.createSession(BOUNDS, '', 1)).toThrow(
'Embedded MPV on Linux requires Electron to run under X11 or Xwayland.'
);
expect(addon.createSession).not.toHaveBeenCalled();
});
it.each([
{
platform: 'darwin' as NodeJS.Platform,
@@ -337,6 +448,7 @@ describe('EmbeddedMpvNativeService power blocker', () => {
])(
'loads the addon after validating the $platform runtime file exists',
({ platform, runtimeFile }) => {
delete process.env.WAYLAND_DISPLAY;
Object.defineProperty(process, 'platform', {
value: platform,
});
@@ -1,4 +1,5 @@
import { app, dialog, powerSaveBlocker } from 'electron';
import { spawnSync } from 'child_process';
import {
closeSync,
existsSync,
@@ -115,8 +116,7 @@ export class EmbeddedMpvNativeService {
return {
supported: false,
platform: process.platform,
reason:
'Embedded MPV is currently available on macOS, Windows, and Linux only.',
reason: 'Embedded MPV is currently available on macOS, Windows, and Linux only.',
};
}
@@ -124,8 +124,7 @@ export class EmbeddedMpvNativeService {
return {
supported: false,
platform: process.platform,
reason:
'Embedded MPV on Linux currently requires X11 or Xwayland. Native Wayland embedding is not supported yet.',
reason: 'Embedded MPV on Linux currently requires X11 or Xwayland. Native Wayland embedding is not supported yet.',
};
}
@@ -137,6 +136,16 @@ export class EmbeddedMpvNativeService {
};
}
const missingLinuxMpvExecutableReason =
this.getMissingLinuxMpvExecutableReason();
if (missingLinuxMpvExecutableReason) {
return {
supported: false,
platform: process.platform,
reason: missingLinuxMpvExecutableReason,
};
}
if (this.addon) {
try {
if (!this.addon.isSupported()) {
@@ -664,7 +673,14 @@ export class EmbeddedMpvNativeService {
throw new Error('The Electron main window is not available.');
}
return App.mainWindow.getNativeWindowHandle();
const windowHandle = App.mainWindow.getNativeWindowHandle();
if (this.isInvalidLinuxWaylandWindowHandle(windowHandle)) {
throw new Error(
'Embedded MPV on Linux requires Electron to run under X11 or Xwayland. Native Wayland embedding is not supported yet. Start IPTVnator with --ozone-platform=x11.'
);
}
return windowHandle;
}
private reserveRecordingTargetPath(
@@ -758,13 +774,49 @@ export class EmbeddedMpvNativeService {
}
private isUnsupportedLinuxDisplayServer(): boolean {
if (process.platform !== 'linux' || !process.env.WAYLAND_DISPLAY) {
return false;
}
return !process.env.DISPLAY || !this.isLinuxX11OzoneRequested();
}
private isLinuxX11OzoneRequested(): boolean {
return (
process.platform === 'linux' &&
Boolean(process.env.WAYLAND_DISPLAY) &&
!process.env.DISPLAY
app.commandLine.getSwitchValue('ozone-platform').toLowerCase() ===
'x11'
);
}
private getMissingLinuxMpvExecutableReason(): string | null {
if (process.platform !== 'linux') {
return null;
}
const result = spawnSync('mpv', ['--version'], { stdio: 'ignore' });
if (result.status === 0) {
return null;
}
return 'Embedded MPV on Linux requires the mpv executable on PATH. Install the mpv package for your distribution and restart IPTVnator.';
}
private isInvalidLinuxWaylandWindowHandle(windowHandle: Buffer): boolean {
if (
process.platform !== 'linux' ||
!process.env.WAYLAND_DISPLAY ||
!process.env.DISPLAY
) {
return false;
}
if (windowHandle.length === 0 || windowHandle.length > 4) {
return false;
}
return windowHandle.readUIntLE(0, windowHandle.length) <= 1;
}
private getAddon(): NativeEmbeddedMpvAddon {
if (this.addon) {
return this.addon;
+12 -8
View File
@@ -5,7 +5,7 @@ This document records the current contract for embedded playback in portal detai
## Summary
- Embedded web players are `videojs`, `html5`, and `artplayer`.
- `embedded-mpv` exists as a hidden desktop experimental harness backed by a native `libmpv` addon.
- `embedded-mpv` exists as a hidden desktop experimental harness backed by a native MPV addon. macOS and Windows use in-process `libmpv`; Linux uses an X11/Xwayland child window with an out-of-process `mpv --wid` backend.
- Controlled external players are `mpv` and `vlc`.
- macOS `.app` bundle paths are resolved only for real MPV/VLC apps. IINA may
launch through the MPV path field when the user supplies an executable path
@@ -53,26 +53,30 @@ Current contract:
- desktop only: macOS, Windows x64, and Linux x64 under X11/Xwayland
- experimental opt-in
- enabled in local development only when `IPTVNATOR_ENABLE_EMBEDDED_MPV_EXPERIMENT=1`
- enabled in packaged desktop builds only when the bundled native addon and `vendored-lgpl` libmpv runtime load successfully
- Linux Wayland sessions must start Electron through Xwayland, for example with `pnpm nx run electron-backend:serve-electron --args=--ozone-platform=x11` during local development or `iptvnator --ozone-platform=x11` for a packaged app
- enabled in packaged desktop builds only when the bundled native addon/runtime prerequisites are present; Linux additionally requires an `mpv` executable on `PATH`
- uses IPTVnator-owned controls and `ResolvedPortalPlayback` payloads
- uses the libmpv render API on macOS and renders through an IPTVnator-owned native `NSView`
- uses mpv `wid` embedding on Windows and Linux through IPTVnator-owned native child windows
- defaults to libmpv's OpenGL render backend with `hwdec=auto-safe`
- Linux starts `mpv --wid=<x11-window>` out of process so MPV does not share Electron's FFmpeg or graphics symbols
- Linux controls that out-of-process MPV instance through a private JSON IPC socket so duration, position, pause, volume, and seek state come from MPV instead of renderer guesses
- Linux starts that MPV process with `WAYLAND_DISPLAY` removed, `XDG_SESSION_TYPE=x11`, and X11 video output options; otherwise MPV can pick Wayland inside a Wayland desktop session, ignore `--wid`, and open a separate window instead of embedding
- macOS/Windows default to libmpv's OpenGL render backend with `hwdec=auto-safe`
- keeps the previous software renderer as a debug fallback via `IPTVNATOR_EMBEDDED_MPV_RENDERER=sw`
- emits lightweight render diagnostics when `IPTVNATOR_TRACE_EMBEDDED_MPV=1` is set
- emits lightweight native diagnostics when `IPTVNATOR_TRACE_EMBEDDED_MPV=1` is set; Linux also writes MPV's own trace log to `/tmp/iptvnator-embedded-mpv.log`
- exposes an IPTVnator-owned fullscreen button that uses the renderer fullscreen API and resyncs the native MPV view bounds after fullscreen transitions
- auto-hides IPTVnator-owned controls while playback is active and restores them on pointer/focus interaction
- exposes audio-track metadata from MPV and switches tracks through the `aid` property without reloading the stream
- passes VOD/episode resume offsets to MPV through the `loadfile` options map; live catchup URLs are treated as already-positioned streams
- applies the initial volume during session creation and uses async libmpv control calls after startup
- applies the initial volume during session creation and uses async libmpv control calls after startup where the in-process libmpv backend is active
- VLC remains external-only
Current limitation:
- the current feasibility harness is still experimental and platform-specific
- the original macOS `wid` embedding path produced audio with a black video surface inside Electron, so the harness now avoids foreign-window embedding on macOS
- Windows and Linux use the mpv `wid` path, require staged LGPL runtime files, and still need OS-native smoke coverage before public exposure
- Linux native Wayland is not supported in this implementation; the Electron process must have `DISPLAY` through X11 or Xwayland
- Windows and Linux use the mpv `wid` path and still need OS-native packaged smoke coverage before public exposure
- Linux native Wayland is not supported in this implementation; the Electron process must have `DISPLAY` through X11/Xwayland and a real X11 window handle
- the OpenGL render path avoids the old per-frame `CGImage` copy path, but it still needs broader interaction, resize, and packaging coverage
- startup deadlocks seen during early macOS playback bring-up are mitigated, but the feature is still kept behind the explicit experiment flag until more interaction and packaging coverage is proven
- because of that, the setting is auto-sanitized back to the default inline player unless support detection reports that the experimental runtime is available
@@ -168,7 +172,7 @@ The diagnostic surface covers the inline player viewport when playback fails, wi
URL extension metadata is filtered before diagnostics and player selection use it. Web script extensions such as `.php` are not shown as stream containers; explicit media query metadata such as `extension=ts` or `format=m3u8` is preferred when present.
Portal VOD and episode payloads with `contentInfo` are treated as non-live by the Video.js MPEG-TS path unless `isLive` is explicitly set. If Chromium leaves the underlying MediaSource duration at `Infinity` for a finite TS VOD, the Video.js wrapper normalizes its UI duration from the finite `seekable` or `buffered` range. This removes the misleading `LIVE` control state without changing stream decoding, diagnostics, or external fallback behavior.
Portal VOD and episode payloads with `contentInfo` are treated as non-live by the inline players unless `isLive` is explicitly set. If Chromium leaves the underlying MediaSource duration at `Infinity` for a finite TS VOD, the Video.js wrapper normalizes its UI duration from the finite `seekable` or `buffered` range. Embedded MPV uses the same live decision rule and shows an unknown duration placeholder for VOD/episode snapshots until MPV reports a finite duration. This removes the misleading `LIVE` control state without changing stream decoding, diagnostics, or external fallback behavior.
When a diagnostic is actionable in Electron, the diagnostic surface may offer `Open in MPV`, `Open in VLC`, `Copy URL`, technical details, and `Retry`. Web builds only expose copy/help text and retry. MPV/VLC fallback requests carry the original `ResolvedPortalPlayback` payload so headers, referer, origin, user-agent, content metadata, and resume offset stay intact. Retry clears the current diagnostic and rebuilds the active inline player inputs; it does not change the saved player setting.
+39 -19
View File
@@ -11,7 +11,7 @@ Source files for the embedded MPV integration:
- `apps/electron-backend/native/src/embedded_mpv.mm` owns the macOS `libmpv` render integration.
- `apps/electron-backend/native/src/embedded_mpv_win32.cc` owns the Windows `HWND` + mpv `wid` backend.
- `apps/electron-backend/native/src/embedded_mpv_linux.cc` owns the Linux X11/Xwayland `Window` + mpv `wid` backend.
- `apps/electron-backend/native/src/embedded_mpv_wid_common.h` owns the shared Windows/Linux libmpv session surface.
- `apps/electron-backend/native/src/embedded_mpv_wid_common.h` owns the shared Windows/Linux session surface, including Linux `mpv --wid` process control and JSON IPC.
- `apps/electron-backend/src/app/services/embedded-mpv-native.service.ts` owns Electron main-process session lifecycle and support detection.
- `apps/electron-backend/src/app/events/embedded-mpv.events.ts` registers the IPC contract.
- `apps/electron-backend/src/app/api/main.preload.ts` exposes the preload bridge to the renderer.
@@ -26,7 +26,24 @@ The build directory contains files such as `Makefile`, `binding.Makefile`, `conf
## How It Is Embedded
The embedded player does not spawn the normal `mpv` application. IPTVnator loads `libmpv` through a native Node addon and renders MPV frames into an app-owned native video surface. macOS uses the libmpv render API in an `NSOpenGLView` because the mpv `wid` path produced a black video surface inside Electron. Windows and Linux use mpv's `wid` option against IPTVnator-owned child windows (`HWND` on Windows, X11 `Window` on Linux/Xwayland).
The embedded player renders MPV frames into an app-owned native video surface. macOS uses the libmpv render API in an `NSOpenGLView` because the mpv `wid` path produced a black video surface inside Electron. Windows loads `libmpv` through the native Node addon and uses mpv's `wid` option against an IPTVnator-owned child `HWND`. Linux creates an IPTVnator-owned X11/Xwayland child `Window` and starts an out-of-process `mpv --wid=<window>` instance for that child window.
On Linux, `embedded_mpv.node` must not link directly to `libmpv` or load libmpv in-process. Electron loads its own `libffmpeg` and Chromium graphics stack; in-process libmpv can resolve FFmpeg/GL symbols against incompatible Electron symbols, while isolated dynamic-loader namespaces introduce thread/runtime ownership problems. The Linux addon therefore owns only the X11 child-window embedding, process lifecycle, and a private MPV JSON IPC socket. It starts `mpv --wid=<window> --input-ipc-server=<socket>`, polls `time-pos`, `duration`, `volume`, and `pause`, and forwards pause/seek/volume/audio-track commands through that socket. A healthy Linux build lists X11/Xext as addon dependencies, but `ldd apps/electron-backend/native/build/Release/embedded_mpv.node` must not list `libmpv`. Runtime support also requires an `mpv` executable on `PATH`.
Linux native Wayland embedding is not implemented. When Electron is started on Xwayland, the Linux backend also starts the child MPV process with `WAYLAND_DISPLAY` removed, `XDG_SESSION_TYPE=x11`, `--vo=gpu,x11`, and `--gpu-context=x11egl`. This prevents MPV from choosing a Wayland VO in a Wayland desktop session, which would ignore the X11 `--wid` target and open a separate top-level MPV window.
## Linux Support Matrix
Embedded MPV on Linux is supported only for x64 desktop builds where Electron runs under X11 or Xwayland and an `mpv` executable is available on `PATH`. Native Wayland embedding is not supported in this implementation. Packaged Linux launchers pass `--ozone-platform=x11` so Wayland desktops use Xwayland when it is available.
Current release-announcement wording should stay close to this:
- Supported display path: X11 or Xwayland.
- Not supported: native Wayland embedding.
- Validated locally: Ubuntu 24.04 GNOME Wayland session with Electron forced to X11/Xwayland and system `mpv`.
- Validated in CI: Ubuntu 22.04 standard Linux package build and Ubuntu 24.04 Flatpak package build.
- Expected standard packages: `.deb` on Ubuntu/Debian, `pacman` on Arch/Manjaro, `.rpm` on RPM-based distributions, and AppImage on x64 glibc systems, all with system `mpv` installed.
- Sandbox caveat: Flatpak and Snap packages build and continue to support the normal inline/external-player flows, but embedded MPV is not announced as supported there yet because the Linux backend launches `mpv --wid` and those sandboxed formats do not expose the host `mpv` executable to the app by default.
The flow is:
@@ -36,8 +53,8 @@ The flow is:
4. The component asks the preload API to create an embedded MPV session with the current viewport bounds and initial volume.
5. The Electron preload forwards calls through IPC to the main process.
6. `EmbeddedMpvNativeService` owns sessions, polls snapshots, and emits session updates to the renderer.
7. The native addon creates an `mpv_handle`, disables MPV's own OSC/input handling, and creates an app-owned platform video host inside the Electron window.
8. On macOS the addon configures `vo=libmpv`, creates a `mpv_render_context`, and draws into the OpenGL surface. On Windows/Linux it passes the child-window id to mpv through `wid` and uses `vo=gpu`.
7. The native addon creates an app-owned platform video host inside the Electron window.
8. On macOS the addon configures `vo=libmpv`, creates a `mpv_render_context`, and draws into the OpenGL surface. On Windows it creates an `mpv_handle`, disables MPV's own OSC/input handling, and passes the child-window id through `wid`. On Linux it starts `mpv --wid=<x11-window>` in a separate process with a private JSON IPC socket and tracks that process until playback replacement or dispose.
9. Resize, scroll, and fullscreen changes are measured in Angular and sent back to the addon as native bounds so the platform video host stays aligned with the Angular layout.
10. Playback controls remain IPTVnator-owned Angular UI. MPV receives commands only through the controlled IPC surface.
@@ -68,13 +85,15 @@ The dock has a stable reserved height while embedded controls are enabled. Contr
`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.
VOD and episode payloads carry `contentInfo` and are treated as non-live unless `isLive` is explicitly set. The embedded MPV UI must not infer "live" from a missing duration alone: on Linux the first snapshot can arrive before the out-of-process MPV IPC socket has reported `duration`, so the UI shows an unknown duration placeholder until MPV reports a finite duration. Live playback is classified from `ResolvedPortalPlayback.isLive` when present, otherwise from the absence of `contentInfo`.
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.
Subtitle tracks mirror the audio-track contract: same `track-list` source, same parsing pipeline, but selected through MPV's `sid` property. A `trackId` of `-1` from the renderer is interpreted as "disable subtitles" and translated to `sid=no` at the addon boundary. Playback speed is observed and set through MPV's `speed` property, clamped at the addon to `[0.25, 4.0]`. Aspect override uses MPV's `video-aspect-override` property as a passthrough string ("no", "16:9", "4:3", "21:9", "2.35:1"). All four properties (`sid`, `speed`, `video-aspect-override`, plus `aid`) are observed at session init so renderer state stays in sync with the native side without needing extra round-trips.
The renderer learns which features the loaded addon binary supports through the `EmbeddedMpvSupport.capabilities` field returned from `getEmbeddedMpvSupport()`. The service probes `typeof addon.<method> === 'function'` for each optional native export. Older addon binaries with the original audio-only surface return `capabilities: { subtitles: false, playbackSpeed: false, aspectOverride: false, screenshot: false, recording: false }`, and the renderer hides the corresponding controls instead of throwing at runtime. After a native rebuild, the new buttons light up automatically without renderer changes.
The renderer learns which features the loaded addon binary supports through the `EmbeddedMpvSupport.capabilities` field returned from `getEmbeddedMpvSupport()`. The service probes `typeof addon.<method> === 'function'` for each optional native export. Older addon binaries with the original audio-only surface return `capabilities: { subtitles: false, playbackSpeed: false, aspectOverride: false, screenshot: false, recording: false }`, and the renderer hides the corresponding controls instead of throwing at runtime. Linux intentionally does not export libmpv-only optional controls while it uses the process-isolated `mpv --wid` backend.
## Session End And Series Navigation
@@ -87,13 +106,13 @@ The renderer learns which features the loaded addon binary supports through the
Renderer autoplay must use `ended` only. It must not treat `closed`, `idle`, or `error` as a request to continue to the next episode.
Series episode navigation is owned by the portal feature components and passed through the shared inline player to `EmbeddedMpvPlayerComponent`. The embedded MPV controls show `skip_previous` and `skip_next` buttons only as part of the embedded player control surface. The shared navigation payload contains `canPrevious`, `canNext`, and `autoplayEnabled`; the component disables previous/next at the current-season boundaries and guards the output handlers as well as the button disabled state.
Series episode navigation is owned by the portal feature components and passed through the shared inline player to `EmbeddedMpvPlayerComponent`. The embedded MPV controls show `skip_previous` and `skip_next` buttons only for non-live series playback. The shared navigation payload contains `canPrevious`, `canNext`, and `autoplayEnabled`; the component disables previous/next at the current-season boundaries and guards the output handlers as well as the button disabled state.
Autoplay is enabled by default for series playback in embedded MPV. On `ended`, Xtream and Stalker series detail views start the next episode only when the current episode has a next item in the same season. Playback stops on the last episode of the current season. Previous always switches to the previous episode in the current season; it does not implement a restart-threshold behavior.
## Live Stream Recording
Embedded MPV can record live streams through mpv's `stream-record` option. IPTVnator exposes this only for `ResolvedPortalPlayback.isLive === true`; VOD, episodes, catchup playback, radio audio playback, and non-embedded players do not show the recording control.
Embedded MPV can record live streams through mpv's `stream-record` option. IPTVnator exposes this only for playback classified as live (`ResolvedPortalPlayback.isLive` when present, otherwise no `contentInfo`); VOD, episodes, catchup playback, radio audio playback, and non-embedded players do not show the recording control.
Recording is session-scoped:
@@ -161,29 +180,30 @@ Current development behavior:
- The addon build supports `darwin`, `win32`, and `linux`; Windows and Linux builds require running on that target OS.
- The build script first looks for a staged runtime at `vendor/embedded-mpv/<platform>-<arch>/`.
- The staged runtime must contain `include/mpv/client.h`, platform runtime files, and `runtime-manifest.json`.
- The staged runtime/build inputs must contain `include/mpv/client.h` and `runtime-manifest.json`. macOS and Windows staging also contains the platform runtime files that are bundled into the app.
- The compiled `.node` addon is copied into `dist/apps/electron-backend/native/embedded_mpv.node`.
- Bundled runtime files are copied into `dist/apps/electron-backend/native/lib/`. macOS copies `.dylib` and non-`.dylib` Mach-O dependencies, Windows copies `mpv-2.dll`/`mpv.dll` and import libraries, and Linux copies `libmpv.so*`.
- Bundled runtime files are copied into `dist/apps/electron-backend/native/lib/` for macOS and Windows. macOS copies `.dylib` and non-`.dylib` Mach-O dependencies; Windows copies `mpv-2.dll`/`mpv.dll` and import libraries. Linux writes an `external-mpv-process` manifest and intentionally leaves `libmpv.so` out of the package.
- Linux does not bundle or load `libmpv` in the Electron process. Its native addon still requires staged MPV headers, but runtime support depends on the X11/Xwayland window handle plus an `mpv` executable on `PATH`.
- `afterPack` copies `dist/apps/electron-backend/native/` into `app.asar.unpacked/electron-backend/native/` on macOS, Windows, and Linux so the addon, manifest, and runtime libraries are filesystem-addressable.
Current release caveat:
- Release packaging requires a `vendored-lgpl` runtime manifest.
- Release packaging requires a `vendored-lgpl` runtime manifest on macOS and Windows, and an `external-mpv-process` manifest on Linux.
- macOS release packaging rejects embedded MPV binaries linked to `/opt/homebrew` or `/usr/local`.
- Windows and Linux release packaging verifies that the platform runtime file is present when Embedded MPV is required.
- Windows release packaging verifies that the platform runtime file is present when Embedded MPV is required. Linux release packaging verifies that the addon and manifest are present and that no bundled `libmpv.so` files slipped into the package.
- Local development can opt into Homebrew `libmpv` only by setting `IPTVNATOR_EMBEDDED_MPV_ALLOW_HOMEBREW=1`; packaged release validation rejects that runtime origin.
Before public release, packaging must:
- stage an LGPL-compatible `libmpv` runtime for each release platform/architecture
- stage an LGPL-compatible `libmpv` runtime for each macOS/Windows release platform/architecture, and stage Linux MPV headers/build metadata for Linux
- collect indirect macOS dependencies expressed as absolute paths, `@loader_path`, or `@rpath`
- rewrite macOS install names and dependency paths to app-relative paths such as `@loader_path`
- code-sign and notarize the full macOS dependency set
- ensure Windows runtime staging includes both the DLL and the import library used by `node-gyp`
- ensure Linux runtime staging includes a runtime SONAME (`libmpv.so.2`, `libmpv.so.1`, or `libmpv.so`) and an unversioned `libmpv.so` linker name for local native builds
- publish the corresponding FFmpeg/libmpv source and build metadata
- ensure Linux native builds do not gain a direct `libmpv` dependency; the runtime playback path is `mpv --wid` in a separate process
- publish the corresponding FFmpeg/libmpv source and build metadata for bundled macOS/Windows runtimes; Linux should document the distribution package versions used as build inputs
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.
Users on macOS and Windows do not need the MPV GUI application for this architecture. Linux currently requires an `mpv` executable because the supported backend is process-isolated. If the native addon/runtime prerequisites or Linux `mpv` executable are missing, embedded MPV is hidden/unsupported and the existing inline/external players remain available.
## Runtime Staging
@@ -216,9 +236,9 @@ pnpm embedded-mpv:stage-runtime -- darwin arm64 /tmp/embedded-mpv-prefix
During temporary PR and `master` artifact testing, CI can restore an exact-keyed GitHub Actions cache for the staged `vendor/embedded-mpv/<platform>-<arch>/` runtime and skip the expensive source build where a source builder exists. The cache only contains `include/`, `lib/`, and `runtime-manifest.json`; it never contains the compiled `embedded_mpv.node` addon because that target depends on Electron headers, ABI, architecture, and build environment. Runtime cache entries are saved only from trusted repository refs, and tagged public macOS release builds continue to rebuild from pinned sources until a dedicated signed and attested runtime artifact flow exists. Windows and Linux currently use staged runtime cache inputs only; adding pinned source builders for those platforms is a separate release-hardening task.
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 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 macOS/Windows manifests to `origin: vendored-lgpl`, which release package validation requires on those platforms.
The Electron backend build consumes the staged runtime and copies platform runtime files into the native build output. macOS additionally 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.
The Electron backend build consumes the staged runtime/build inputs and copies macOS/Windows runtime files into the native build output. Linux consumes the staged MPV headers, writes an `external-mpv-process` manifest, and does not copy `libmpv.so` into the package. macOS additionally 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:
@@ -281,8 +301,8 @@ If an embedded session fails to initialize, the app should keep the user in cont
Do not expose embedded MPV broadly until these pass on every supported target:
- packaged app starts without system `mpv` installed
- bundled `libmpv` and dependent runtime files pass platform package validation
- macOS/Windows packaged app starts without system `mpv` installed; Linux reports Embedded MPV unsupported with a clear message when system `mpv` is missing
- bundled `libmpv` and dependent runtime files pass macOS/Windows package validation; Linux package validation confirms the external-process manifest and absence of bundled `libmpv.so`
- macOS bundled `libmpv` and dependent dylibs pass code signing and notarization
- VOD resume starts near the saved offset
- series EOF emits `ended` and embedded MPV auto-continues only inside the current season
+1
View File
@@ -50,6 +50,7 @@
"linux": {
"category": "Video",
"executableName": "iptvnator",
"executableArgs": ["--ozone-platform=x11"],
"desktop": {
"entry": {
"StartupWMClass": "iptvnator"
@@ -100,33 +100,35 @@
}}</mat-icon>
</button>
<button
mat-icon-button
type="button"
class="embedded-mpv-player__control-button"
data-test-id="embedded-mpv-previous-episode"
[disabled]="isLoading() || !canPreviousEpisode()"
(click)="requestPreviousEpisode()"
aria-label="Previous episode"
matTooltip="Previous episode"
matTooltipPosition="above"
>
<mat-icon>skip_previous</mat-icon>
</button>
@if (showSeriesNavigation()) {
<button
mat-icon-button
type="button"
class="embedded-mpv-player__control-button"
data-test-id="embedded-mpv-previous-episode"
[disabled]="isLoading() || !canPreviousEpisode()"
(click)="requestPreviousEpisode()"
aria-label="Previous episode"
matTooltip="Previous episode"
matTooltipPosition="above"
>
<mat-icon>skip_previous</mat-icon>
</button>
<button
mat-icon-button
type="button"
class="embedded-mpv-player__control-button"
data-test-id="embedded-mpv-next-episode"
[disabled]="isLoading() || !canNextEpisode()"
(click)="requestNextEpisode()"
aria-label="Next episode"
matTooltip="Next episode"
matTooltipPosition="above"
>
<mat-icon>skip_next</mat-icon>
</button>
<button
mat-icon-button
type="button"
class="embedded-mpv-player__control-button"
data-test-id="embedded-mpv-next-episode"
[disabled]="isLoading() || !canNextEpisode()"
(click)="requestNextEpisode()"
aria-label="Next episode"
matTooltip="Next episode"
matTooltipPosition="above"
>
<mat-icon>skip_next</mat-icon>
</button>
}
<button
mat-icon-button
@@ -179,7 +181,7 @@
<span>{{
formatTime(session()?.durationSeconds)
}}</span>
} @else {
} @else if (isLivePlayback()) {
<span
class="embedded-mpv-player__live-badge"
aria-label="Live stream"
@@ -190,6 +192,8 @@
></span>
LIVE
</span>
} @else {
<span>--:--</span>
}
</div>
@if (recordingStatusText(); as recordingStatus) {
@@ -1,11 +1,13 @@
import { Component } from '@angular/core';
import { Component, signal } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { ResolvedPortalPlayback } from '@iptvnator/shared/interfaces';
import {
EmbeddedMpvSession,
ResolvedPortalPlayback,
} from '@iptvnator/shared/interfaces';
import { EmbeddedMpvOverlayVisibilityService } from './embedded-mpv-overlay-visibility.service';
import { EmbeddedMpvPlayerComponent } from './embedded-mpv-player.component';
import { EmbeddedMpvSessionController } from './embedded-mpv-session-controller';
import { signal } from '@angular/core';
@Component({
imports: [EmbeddedMpvPlayerComponent],
@@ -20,9 +22,14 @@ import { signal } from '@angular/core';
`,
})
class EmbeddedMpvPlayerHostComponent {
readonly playback: ResolvedPortalPlayback = {
playback: ResolvedPortalPlayback = {
streamUrl: 'https://example.test/series/1002.mp4',
title: 'Episode 2',
contentInfo: {
playlistId: 'playlist-1',
contentXtreamId: 1002,
contentType: 'episode',
},
};
seriesNavigation = {
@@ -41,27 +48,9 @@ describe('EmbeddedMpvPlayerComponent series navigation', () => {
let player: EmbeddedMpvPlayerComponent;
let controller: EmbeddedMpvSessionController;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [EmbeddedMpvPlayerHostComponent],
providers: [
{
provide: EmbeddedMpvOverlayVisibilityService,
useValue: { overlayActive: signal(false) },
},
],
}).compileComponents();
fixture = TestBed.createComponent(EmbeddedMpvPlayerHostComponent);
fixture.detectChanges();
const playerDebugElement = fixture.debugElement.query(
By.directive(EmbeddedMpvPlayerComponent)
);
player = playerDebugElement.componentInstance;
controller = playerDebugElement.injector.get(
EmbeddedMpvSessionController
);
const configureReadyController = (
sessionOverrides: Partial<EmbeddedMpvSession> = {}
) => {
controller.support.set({
supported: true,
platform: 'darwin',
@@ -90,8 +79,36 @@ describe('EmbeddedMpvPlayerComponent series navigation', () => {
recording: { active: false },
startedAt: '2026-06-06T12:00:00Z',
updatedAt: '2026-06-06T12:00:00Z',
...sessionOverrides,
});
fixture.detectChanges();
};
const bindPlayer = () => {
const playerDebugElement = fixture.debugElement.query(
By.directive(EmbeddedMpvPlayerComponent)
);
player = playerDebugElement.componentInstance;
controller = playerDebugElement.injector.get(
EmbeddedMpvSessionController
);
};
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [EmbeddedMpvPlayerHostComponent],
providers: [
{
provide: EmbeddedMpvOverlayVisibilityService,
useValue: { overlayActive: signal(false) },
},
],
}).compileComponents();
fixture = TestBed.createComponent(EmbeddedMpvPlayerHostComponent);
fixture.detectChanges();
bindPlayer();
configureReadyController();
});
afterEach(() => {
@@ -143,4 +160,70 @@ describe('EmbeddedMpvPlayerComponent series navigation', () => {
expect(fixture.componentInstance.endedCount).toBe(1);
expect(player.isPlaying()).toBe(false);
});
it('hides episode navigation for live playback', () => {
fixture.destroy();
fixture = TestBed.createComponent(EmbeddedMpvPlayerHostComponent);
fixture.componentInstance.playback = {
streamUrl: 'https://example.test/live/zdf-hd.ts',
title: 'ZDF HD',
isLive: true,
};
fixture.detectChanges();
bindPlayer();
configureReadyController({
streamUrl: 'https://example.test/live/zdf-hd.ts',
title: 'ZDF HD',
durationSeconds: 120,
});
expect(
fixture.debugElement.query(
By.css('[data-test-id="embedded-mpv-previous-episode"]')
)
).toBeNull();
expect(
fixture.debugElement.query(
By.css('[data-test-id="embedded-mpv-next-episode"]')
)
).toBeNull();
expect(
fixture.debugElement.query(
By.css('.embedded-mpv-player__live-badge')
)
).not.toBeNull();
expect(
fixture.debugElement.query(
By.css('button[aria-label="Back 10 seconds"]')
).nativeElement.disabled
).toBe(true);
expect(
fixture.debugElement.query(
By.css('button[aria-label="Forward 10 seconds"]')
).nativeElement.disabled
).toBe(true);
expect(
fixture.debugElement.query(By.css('.embedded-mpv-player__slider'))
.nativeElement.disabled
).toBe(true);
});
it('does not label VOD or episode playback as live while duration is loading', () => {
controller.session.update((session) =>
session
? {
...session,
durationSeconds: null,
}
: session
);
fixture.detectChanges();
expect(
fixture.debugElement.query(
By.css('.embedded-mpv-player__live-badge')
)
).toBeNull();
expect(fixture.nativeElement.textContent).toContain('--:--');
});
});
@@ -128,8 +128,17 @@ export class EmbeddedMpvPlayerComponent implements OnDestroy {
);
readonly isPlaying = computed(() => this.session()?.status === 'playing');
readonly isErrored = computed(() => this.session()?.status === 'error');
readonly isLivePlayback = computed(() => {
const playback = this.playback();
if (typeof playback.isLive === 'boolean') {
return playback.isLive;
}
return !playback.contentInfo;
});
readonly canSeek = computed(
() => (this.session()?.durationSeconds ?? 0) > 0
() =>
!this.isLivePlayback() && (this.session()?.durationSeconds ?? 0) > 0
);
readonly canFullscreen = computed(
() =>
@@ -198,18 +207,25 @@ export class EmbeddedMpvPlayerComponent implements OnDestroy {
readonly canRecord = computed(
() =>
this.capabilities().recording &&
this.playback().isLive === true &&
this.isLivePlayback() &&
this.isSupported() &&
!this.isErrored()
);
readonly isRecording = computed(
() => this.session()?.recording?.active === true
);
readonly showSeriesNavigation = computed(
() => !this.isLivePlayback() && this.seriesNavigation() !== null
);
readonly canPreviousEpisode = computed(
() => this.seriesNavigation()?.canPrevious === true
() =>
this.showSeriesNavigation() &&
this.seriesNavigation()?.canPrevious === true
);
readonly canNextEpisode = computed(
() => this.seriesNavigation()?.canNext === true
() =>
this.showSeriesNavigation() &&
this.seriesNavigation()?.canNext === true
);
readonly recordingElapsed = computed(() => {
const startedAt = this.session()?.recording?.startedAt;
+16 -11
View File
@@ -1,6 +1,6 @@
# Embedded MPV Runtime
This folder contains tooling for preparing the `libmpv` runtime that is bundled with IPTVnator's experimental embedded MPV player.
This folder contains tooling for preparing MPV runtime/build inputs for IPTVnator's experimental embedded MPV player. macOS and Windows bundle `libmpv`; Linux uses staged MPV headers for compilation and launches the system `mpv` executable at runtime.
## Runtime Policy
@@ -34,8 +34,6 @@ vendor/embedded-mpv/
runtime-manifest.json
linux-x64/
include/mpv/client.h
lib/libmpv.so.2
lib/libmpv.so
runtime-manifest.json
```
@@ -59,11 +57,11 @@ pnpm embedded-mpv:stage-runtime:macos -- arm64 /path/to/lgpl-prefix
pnpm embedded-mpv:stage-runtime:macos -- x64 /path/to/lgpl-prefix
```
The prefix must contain `include/mpv/client.h` and the platform runtime files:
The prefix must contain `include/mpv/client.h` and the platform runtime/build files:
- macOS: `lib/libmpv.2.dylib` or `lib/libmpv.dylib` plus all non-system dylib dependencies
- Windows: `lib/mpv.lib` or `lib/mpv-2.lib`, and `bin/mpv-2.dll` or `lib/mpv-2.dll`
- Linux: `lib/libmpv.so.2`, `lib/libmpv.so.1`, or `lib/libmpv.so`; include a `libmpv.so` linker name when building locally
- Linux: `include/mpv/client.h`; CI also records the `libmpv-dev` and `mpv` package versions used as build inputs. Linux runtime playback uses the system `mpv` executable and does not bundle `libmpv.so`.
If the prefix contains `runtime-manifest.json`, the staging script copies its build metadata into the vendored manifest. At minimum, record:
@@ -80,6 +78,13 @@ pnpm embedded-mpv:build-runtime -- arm64 /tmp/embedded-mpv-prefix
pnpm embedded-mpv:stage-runtime -- darwin arm64 /tmp/embedded-mpv-prefix
```
Linux CI does not build libmpv from source. It installs Ubuntu runner packages
(`libmpv-dev` and `mpv`), stages their headers and build metadata under
`vendor/embedded-mpv/linux-x64/`, and requires the native addon/package layout
to be present. Linux playback does not load or bundle `libmpv` in the Electron
process; the addon creates an X11 child window and starts a system `mpv --wid`
process at runtime.
During temporary PR and `master` artifact testing, CI restores an exact-keyed GitHub Actions cache for the staged `vendor/embedded-mpv/<platform>-<arch>/` runtime before falling back to the macOS source build where available. The cache key includes the target platform, architecture, macOS deployment target, Xcode version when available, and hashes of the runtime build/staging scripts. Cache entries are saved only from trusted repository refs and are treated strictly as a speed optimization; tagged macOS release builds continue to rebuild from pinned sources unless a future signed and attested runtime artifact flow is introduced.
The builder currently pins:
@@ -89,11 +94,11 @@ The builder currently pins:
- libplacebo `7.360.1`, checked out from git with the `glad`, Python template, `fast_float`, and `Vulkan-Headers` submodules required by its Meson build
- libass `0.17.3` plus FreeType, FriBidi, and HarfBuzz
The build manifest records source URLs, downloaded archive SHA-256 values where applicable, libplacebo git commit/submodule metadata, and the exact FFmpeg/mpv flags. The staged manifest is normalized to `origin: vendored-lgpl`, which is the only embedded MPV runtime origin allowed in required macOS release packaging.
The build manifest records source URLs, downloaded archive SHA-256 values where applicable, libplacebo git commit/submodule metadata, and the exact FFmpeg/mpv flags. The staged macOS/Windows manifest is normalized to `origin: vendored-lgpl`, which is the only embedded MPV runtime origin allowed in required macOS/Windows release packaging.
## Build Integration
`apps/electron-backend/build-embedded-mpv.js` links the native addon against the staged runtime, copies runtime libraries into `apps/electron-backend/native/build/Release/lib/`, rewrites macOS Mach-O paths to `@loader_path`, and writes `embedded-mpv-runtime.json`.
`apps/electron-backend/build-embedded-mpv.js` builds the native addon against the staged runtime/build inputs, copies macOS/Windows runtime libraries into `apps/electron-backend/native/build/Release/lib/`, rewrites macOS Mach-O paths to `@loader_path`, and writes `embedded-mpv-runtime.json`. Linux builds use the staged MPV headers and system X11 development libraries, write an `external-mpv-process` manifest, and must not copy or link directly to `libmpv`; CI validates this with package checks and `ldd`.
For local macOS development with Homebrew `mpv`, use:
@@ -103,14 +108,14 @@ pnpm run serve:backend:embedded-mpv
The script rebuilds the native addon with `IPTVNATOR_EMBEDDED_MPV_ALLOW_HOMEBREW=1` before starting Electron with the experimental player enabled. Use this only for local testing; release packaging rejects the resulting `homebrew-dev` runtime manifest.
The `afterPack` hook copies `dist/apps/electron-backend/native/` into `app.asar.unpacked/electron-backend/native/` so the addon, runtime manifest, and runtime libraries are available as real files on macOS, Windows, and Linux.
The `afterPack` hook copies `dist/apps/electron-backend/native/` into `app.asar.unpacked/electron-backend/native/` so the addon, runtime manifest, and runtime libraries are available as real files where needed. Linux packages include the addon and manifest, but no bundled `libmpv.so`.
During release packaging, `tools/packaging/electron-after-pack.cjs` verifies that the packaged app uses a `vendored-lgpl` runtime. macOS artifacts additionally verify that Mach-O dependencies have no `/opt/homebrew` or `/usr/local` dynamic links for embedded MPV.
During release packaging, `tools/packaging/electron-after-pack.cjs` verifies that macOS/Windows packages use a `vendored-lgpl` runtime/build input set. macOS artifacts additionally verify that Mach-O dependencies have no `/opt/homebrew` or `/usr/local` dynamic links for embedded MPV. Linux artifacts verify that the addon and `external-mpv-process` manifest are present, that no bundled `libmpv.so` files are present, and the runtime support check verifies that `mpv` is available on `PATH`.
Set `IPTVNATOR_REQUIRE_EMBEDDED_MPV=1` when packaging a release artifact that must include Embedded MPV. The same variable is temporarily enabled for macOS PR and `master` push artifacts while the bundled runtime is being tested. Windows and Linux CI packaging requires Embedded MPV when an exact-keyed staged runtime cache is restored; otherwise those jobs build without the native addon and Settings keeps Embedded MPV hidden.
Set `IPTVNATOR_REQUIRE_EMBEDDED_MPV=1` when packaging a release artifact that must include Embedded MPV. The same variable is temporarily enabled for macOS PR and `master` push artifacts while the bundled runtime is being tested. Linux CI packaging requires Embedded MPV after staging the Ubuntu package build inputs. Windows CI packaging requires Embedded MPV when an exact-keyed staged runtime cache is restored; otherwise the Windows job builds without the native addon and Settings keeps Embedded MPV hidden.
## Platform Notes
- macOS keeps the existing libmpv render-context backend because mpv `wid` stays black inside Electron on macOS.
- Windows uses an embedded child `HWND` and passes it to mpv through `wid`.
- Linux uses an X11 child window and passes it to mpv through `wid`. Native Wayland is not supported in v1; run under X11/Xwayland so `DISPLAY` is set.
- Linux uses an X11 child window and starts a system `mpv --wid` process for that window. Native Wayland is not supported in v1; run under X11/Xwayland so `DISPLAY` is set and `mpv` can honor the X11 window id.
+15 -8
View File
@@ -23,7 +23,7 @@ if (!validTargets.has(`${platform}-${arch}`) || !sourcePrefix) {
'- win32 x64',
'- linux x64',
'',
'The prefix must contain include/mpv/client.h and dynamic libmpv runtime files.',
'The prefix must contain include/mpv/client.h. macOS and Windows prefixes must also contain dynamic libmpv runtime files.',
].join('\n')
);
process.exit(1);
@@ -162,11 +162,15 @@ try {
path.join(sourceIncludeDir, 'mpv', 'client.h'),
'Missing libmpv header'
);
if (!findRuntimeFile(sourceLibDir)) {
throw new Error(`Missing libmpv runtime for ${platform} in ${sourceLibDir}`);
if (platform !== 'linux' && !findRuntimeFile(sourceLibDir)) {
throw new Error(
`Missing libmpv runtime for ${platform} in ${sourceLibDir}`
);
}
if (platform === 'win32' && !hasWindowsImportLibrary(sourceLibDir)) {
throw new Error(`Missing Windows libmpv import library in ${sourceLibDir}`);
throw new Error(
`Missing Windows libmpv import library in ${sourceLibDir}`
);
}
fs.rmSync(destinationIncludeDir, { recursive: true, force: true });
@@ -183,8 +187,9 @@ try {
}
const externalManifest =
readJsonIfExists(path.join(normalizedPrefix, 'runtime-manifest.json')) ??
{};
readJsonIfExists(
path.join(normalizedPrefix, 'runtime-manifest.json')
) ?? {};
const manifest = {
...externalManifest,
origin: 'vendored-lgpl',
@@ -193,14 +198,16 @@ try {
stagedAt: new Date().toISOString(),
runtimeFiles: listRuntimeFiles(destinationLibDir),
ffmpeg: {
licensePolicy: 'LGPL, built without --enable-gpl and --enable-nonfree',
licensePolicy:
'LGPL, built without --enable-gpl and --enable-nonfree',
...externalManifest.ffmpeg,
configureFlags:
externalManifest.ffmpeg?.configureFlags ??
'Record the exact FFmpeg configure flags used to build this runtime.',
},
mpv: {
licensePolicy: 'LGPL-compatible libmpv, built with -Dlibmpv=true -Dgpl=false',
licensePolicy:
'LGPL-compatible libmpv, built with -Dlibmpv=true -Dgpl=false',
...externalManifest.mpv,
mesonFlags:
externalManifest.mpv?.mesonFlags ??
@@ -19,7 +19,14 @@ const electronBuilderConfig = JSON.parse(
);
const electronProjectConfig = JSON.parse(
fs.readFileSync(
join(currentDir, '..', '..', 'apps', 'electron-backend', 'project.json'),
join(
currentDir,
'..',
'..',
'apps',
'electron-backend',
'project.json'
),
'utf8'
)
);
@@ -49,9 +56,7 @@ const electronAfterPackSource = fs.readFileSync(
join(currentDir, 'electron-after-pack.cjs'),
'utf8'
);
const {
validatePackagedEmbeddedMpv,
} = require('./embedded-mpv-packaging.cjs');
const { validatePackagedEmbeddedMpv } = require('./embedded-mpv-packaging.cjs');
test('Linux package identity does not expose the internal Electron backend project name', () => {
assert.equal(electronBuilderConfig.productName, 'IPTVnator');
@@ -62,15 +67,16 @@ test('Linux package identity does not expose the internal Electron backend proje
electronBuilderConfig.linux?.desktop?.entry?.StartupWMClass,
'iptvnator'
);
assert.ok(
electronBuilderConfig.linux?.executableArgs?.includes(
'--ozone-platform=x11'
)
);
});
test('generated Electron package metadata mirrors the root package identity', async () => {
const {
buildElectronBuilderMetadata,
buildElectronPackageMetadata,
} = await import(
'./generate-electron-builder-metadata.mjs'
);
const { buildElectronBuilderMetadata, buildElectronPackageMetadata } =
await import('./generate-electron-builder-metadata.mjs');
const generatedElectronPackage = buildElectronPackageMetadata(
packageMetadata,
electronBuilderConfig,
@@ -156,13 +162,9 @@ test('package layout verifier uses canonical helpers and direct dependencies', (
});
test('nx-electron packaging does not copy duplicate root package metadata', () => {
const nxElectronExecutorPath = require.resolve(
'nx-electron/src/executors/package/executor.js'
);
const nxElectronExecutor = fs.readFileSync(
nxElectronExecutorPath,
'utf8'
);
const nxElectronExecutorPath =
require.resolve('nx-electron/src/executors/package/executor.js');
const nxElectronExecutor = fs.readFileSync(nxElectronExecutorPath, 'utf8');
assert.doesNotMatch(nxElectronExecutor, /['"]\.\/package\.json['"]/);
assert.match(
@@ -189,14 +191,13 @@ test('embedded MPV runtime binaries are unpacked on every supported desktop plat
}
});
test('embedded MPV package validation accepts Windows and Linux runtime files', () => {
test('embedded MPV package validation accepts Windows runtime files and Linux process isolation', () => {
const tempDir = fs.mkdtempSync(join(os.tmpdir(), 'iptvnator-mpv-package-'));
try {
for (const [platform, runtimeFile] of [
['windows', 'mpv-2.dll'],
['windows', join('lib', 'mpv.dll')],
['linux', join('lib', 'libmpv.so.2')],
]) {
const resourceDir = join(tempDir, platform);
const nativeDir = join(
@@ -221,6 +222,28 @@ test('embedded MPV package validation accepts Windows and Linux runtime files',
[]
);
}
const linuxResourceDir = join(tempDir, 'linux');
const linuxNativeDir = join(
linuxResourceDir,
'app.asar.unpacked',
'electron-backend',
'native'
);
fs.mkdirSync(linuxNativeDir, { recursive: true });
fs.writeFileSync(join(linuxNativeDir, 'embedded_mpv.node'), '');
fs.writeFileSync(
join(linuxNativeDir, 'embedded-mpv-runtime.json'),
JSON.stringify({ origin: 'external-mpv-process' })
);
assert.deepEqual(
validatePackagedEmbeddedMpv(linuxResourceDir, {
platform: 'linux',
required: true,
}),
[]
);
} finally {
fs.rmSync(tempDir, { recursive: true, force: true });
}
@@ -264,3 +287,32 @@ test('embedded MPV package validation rejects missing required Windows runtime',
fs.rmSync(tempDir, { recursive: true, force: true });
}
});
test('embedded MPV package validation rejects bundled Linux libmpv', () => {
const tempDir = fs.mkdtempSync(join(os.tmpdir(), 'iptvnator-mpv-package-'));
try {
const nativeDir = join(
tempDir,
'app.asar.unpacked',
'electron-backend',
'native'
);
fs.mkdirSync(join(nativeDir, 'lib'), { recursive: true });
fs.writeFileSync(join(nativeDir, 'embedded_mpv.node'), '');
fs.writeFileSync(
join(nativeDir, 'embedded-mpv-runtime.json'),
JSON.stringify({ origin: 'external-mpv-process' })
);
fs.writeFileSync(join(nativeDir, 'lib', 'libmpv.so'), '');
const errors = validatePackagedEmbeddedMpv(tempDir, {
platform: 'linux',
required: true,
});
assert.match(errors.join('\n'), /must not bundle libmpv/);
} finally {
fs.rmSync(tempDir, { recursive: true, force: true });
}
});
+48 -13
View File
@@ -193,7 +193,11 @@ function collectExternalDylibs(entryPaths) {
while (queue.length > 0) {
const currentPath = queue.shift();
if (!currentPath || visited.has(currentPath) || !fs.existsSync(currentPath)) {
if (
!currentPath ||
visited.has(currentPath) ||
!fs.existsSync(currentPath)
) {
continue;
}
@@ -203,7 +207,10 @@ function collectExternalDylibs(entryPaths) {
continue;
}
const resolvedPath = resolveDependencyPath(dependencyPath, currentPath);
const resolvedPath = resolveDependencyPath(
dependencyPath,
currentPath
);
if (!resolvedPath || !fs.existsSync(resolvedPath)) {
continue;
}
@@ -244,7 +251,11 @@ function patchDylibIds(libDir) {
}
}
function patchBinaryDependencies(binaryPath, dependencyBaseDir, replacementPrefix) {
function patchBinaryDependencies(
binaryPath,
dependencyBaseDir,
replacementPrefix
) {
const availableRuntimeFiles = new Set(
listRuntimeFiles(dependencyBaseDir).map((runtimePath) =>
path.basename(runtimePath)
@@ -299,7 +310,9 @@ function copyRuntimeToNativeBuild({
removeDir(outputLibDir);
ensureDir(outputLibDir);
const runtimeFilesByName = new Map([[path.basename(libMpvPath), libMpvPath]]);
const runtimeFilesByName = new Map([
[path.basename(libMpvPath), libMpvPath],
]);
for (const dylibPath of collectExternalDylibs([libMpvPath])) {
runtimeFilesByName.set(path.basename(dylibPath), dylibPath);
}
@@ -315,9 +328,15 @@ function copyRuntimeToNativeBuild({
}
if (!fs.existsSync(path.join(outputLibDir, 'libmpv.2.dylib'))) {
const copiedLibMpvPath = path.join(outputLibDir, path.basename(libMpvPath));
const copiedLibMpvPath = path.join(
outputLibDir,
path.basename(libMpvPath)
);
if (path.basename(copiedLibMpvPath) !== 'libmpv.2.dylib') {
copyFile(copiedLibMpvPath, path.join(outputLibDir, 'libmpv.2.dylib'));
copyFile(
copiedLibMpvPath,
path.join(outputLibDir, 'libmpv.2.dylib')
);
}
}
@@ -414,11 +433,7 @@ function getPackagedRuntimeCandidates(libDir, platform, nativeDir) {
path.join(libDir, 'mpv.dll'),
].filter(Boolean);
case 'linux':
return [
path.join(libDir, 'libmpv.so.2'),
path.join(libDir, 'libmpv.so.1'),
path.join(libDir, 'libmpv.so'),
];
return [];
default:
return [];
}
@@ -461,9 +476,29 @@ function validatePackagedEmbeddedMpv(resourceDir, options = {}) {
errors.push(`Missing embedded MPV runtime manifest: ${manifestPath}`);
} else {
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
if (manifest.origin !== 'vendored-lgpl') {
const expectedOrigin =
platform === 'linux' ? 'external-mpv-process' : 'vendored-lgpl';
if (manifest.origin !== expectedOrigin) {
errors.push(
`Embedded MPV packaged runtime must be vendored-lgpl, received: ${manifest.origin}`
`Embedded MPV packaged runtime must be ${expectedOrigin}, received: ${manifest.origin}`
);
}
}
if (platform === 'linux') {
const bundledLinuxRuntime = [
path.join(libDir, 'libmpv.so.2'),
path.join(libDir, 'libmpv.so.1'),
path.join(libDir, 'libmpv.so'),
].filter((candidate) => fs.existsSync(candidate));
if (bundledLinuxRuntime.length > 0) {
errors.push(
[
'Linux embedded MPV must use the external mpv process backend and must not bundle libmpv.',
'Remove:',
...bundledLinuxRuntime.map((candidate) => `- ${candidate}`),
].join('\n')
);
}
}
@@ -43,8 +43,11 @@ const electronBuilderConfig = JSON.parse(
fs.readFileSync(electronBuilderConfigPath, 'utf8')
);
const flatpakFinishArgs = electronBuilderConfig.flatpak?.finishArgs ?? [];
const linuxExecutableArgs = electronBuilderConfig.linux?.executableArgs ?? [];
const snapConfigInspection = loadSnapConfigInspection();
const embeddedMpvRequired = isTruthy(process.env.IPTVNATOR_REQUIRE_EMBEDDED_MPV);
const embeddedMpvRequired = isTruthy(
process.env.IPTVNATOR_REQUIRE_EMBEDDED_MPV
);
const workerRelativeDir = path.join(
'dist',
'apps',
@@ -118,20 +121,18 @@ function getMacResourceDirs() {
}
function getUnpackedResourceDirs(prefix) {
return packageOutputRoots
.filter(directoryExists)
.flatMap((outputRoot) =>
fs
.readdirSync(outputRoot, { withFileTypes: true })
.filter(
(entry) =>
entry.isDirectory() &&
entry.name.startsWith(prefix) &&
entry.name.endsWith('-unpacked')
)
.map((entry) => path.join(outputRoot, entry.name, 'resources'))
.filter(directoryExists)
);
return packageOutputRoots.filter(directoryExists).flatMap((outputRoot) =>
fs
.readdirSync(outputRoot, { withFileTypes: true })
.filter(
(entry) =>
entry.isDirectory() &&
entry.name.startsWith(prefix) &&
entry.name.endsWith('-unpacked')
)
.map((entry) => path.join(outputRoot, entry.name, 'resources'))
.filter(directoryExists)
);
}
function getResourceDirs() {
@@ -149,7 +150,17 @@ function getResourceDirs() {
}
function sanitizeExecutableName(value) {
const invalidCharacters = new Set(['<', '>', ':', '"', '/', '\\', '|', '?', '*']);
const invalidCharacters = new Set([
'<',
'>',
':',
'"',
'/',
'\\',
'|',
'?',
'*',
]);
return [...value]
.filter(
@@ -362,7 +373,8 @@ function parseEffectiveSnapConfig(yamlContent) {
}
function loadSnapConfigInspection() {
const builderEffectiveConfigPath = builderEffectiveConfigPaths.find(fileExists);
const builderEffectiveConfigPath =
builderEffectiveConfigPaths.find(fileExists);
if (builderEffectiveConfigPath) {
const effectiveConfigContent = fs.readFileSync(
@@ -496,6 +508,19 @@ function verifyFlatpakPermissions(errors) {
}
}
function verifyLinuxExecutableArgs(errors) {
if (!Array.isArray(linuxExecutableArgs)) {
errors.push('linux.executableArgs must be configured as an array.');
return;
}
if (!linuxExecutableArgs.includes('--ozone-platform=x11')) {
errors.push(
'linux.executableArgs must include --ozone-platform=x11 while embedded MPV requires X11/Xwayland on Linux.'
);
}
}
function verifySnapPackagingConfig(errors) {
if (snapConfigInspection.config?.base !== 'core22') {
errors.push(
@@ -561,6 +586,7 @@ function verifyResourceDir(resourceDir) {
`Missing Flatpak metainfo file: ${flatpakMetainfoPath}`
);
}
verifyLinuxExecutableArgs(errors);
verifyFlatpakPermissions(errors);
verifySnapPackagingConfig(errors);
verifyLinuxLauncher(resourceDir, errors);
+12 -2
View File
@@ -1,10 +1,20 @@
# Embedded MPV Runtime Artifacts
This directory is the staging location for macOS embedded MPV runtime artifacts.
This directory is the staging location for generated embedded MPV runtime/build
artifacts.
Generated architecture folders are expected at:
- `vendor/embedded-mpv/darwin-arm64/`
- `vendor/embedded-mpv/darwin-x64/`
- `vendor/embedded-mpv/win32-x64/`
- `vendor/embedded-mpv/linux-x64/`
Each generated folder must contain `include/mpv/client.h`, `lib/*.dylib`, and `runtime-manifest.json`. The binary runtime directories are ignored by git by default; generate or restore them in release packaging jobs before building the Electron backend.
Each generated folder must contain `include/mpv/client.h` and
`runtime-manifest.json`. macOS and Windows folders also contain platform
runtime/build inputs under `lib/` or `bin/`. The binary runtime directories are
ignored by git by default; generate, stage, or restore them in release packaging
jobs before building the Electron backend.
Linux uses this directory for MPV headers and build metadata only. Linux
packages launch the system `mpv` executable and must not bundle `libmpv.so`.