diff --git a/.github/workflows/build-and-make.yaml b/.github/workflows/build-and-make.yaml
index e9e0c173a..5df334f90 100644
--- a/.github/workflows/build-and-make.yaml
+++ b/.github/workflows/build-and-make.yaml
@@ -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
diff --git a/.gitignore b/.gitignore
index befffd62e..ea63467e3 100644
--- a/.gitignore
+++ b/.gitignore
@@ -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
diff --git a/README.md b/README.md
index 500f194b2..71840976d 100644
--- a/README.md
+++ b/README.md
@@ -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 |
-| :-------------------------------------: | :--------------------------------------: |
-|  |  |
-| Add playlist dialog for M3U, Xtream, and Stalker | Live category channel list |
-|  |  |
-| Global search across live TV, movies, and series | Manage visible live categories |
-|  |  |
-| Movie category grid with sorting and pagination | Recently added movies and series |
-|  |  |
-| VOD details with playback and download actions | Download manager |
-|  |  |
-| Multi-channel EPG grid | External MPV player support |
-|  |  |
-| Radio playback with dedicated audio player | Light theme |
-|  |  |
-| Application settings | |
-|  | |
+| Dashboard with recently watched content | Live channels with inline player and EPG |
+| :-------------------------------------------------------------------------------------------------------------: | :---------------------------------------------------------------------------------------------------: |
+|  |  |
+| Add playlist dialog for M3U, Xtream, and Stalker | Live category channel list |
+|  |  |
+| Global search across live TV, movies, and series | Manage visible live categories |
+|  |  |
+| Movie category grid with sorting and pagination | Recently added movies and series |
+|  |  |
+| VOD details with playback and download actions | Download manager |
+|  |  |
+| Multi-channel EPG grid | External MPV player support |
+|  |  |
+| Radio playback with dedicated audio player | Light theme |
+|  |  |
+| Application settings | |
+|  | |
_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.
+
[](https://snapcraft.io/iptvnator)
diff --git a/apps/electron-backend/build-embedded-mpv.js b/apps/electron-backend/build-embedded-mpv.js
index c12ad9b5f..5a6e81f19 100644
--- a/apps/electron-backend/build-embedded-mpv.js
+++ b/apps/electron-backend/build-embedded-mpv.js
@@ -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 =
@@ -68,6 +76,15 @@ function cleanOutput() {
);
}
+function cleanNativeBuildIntermediates() {
+ fs.rmSync(outputFile, { force: true });
+ fs.rmSync(path.join(outputDir, '.deps'), { recursive: true, force: true });
+ fs.rmSync(path.join(outputDir, 'obj.target'), {
+ recursive: true,
+ force: true,
+ });
+}
+
function cleanDistNativeOutput() {
fs.rmSync(distNativeDir, { recursive: true, force: true });
}
@@ -164,7 +181,7 @@ function resolveRuntime() {
: targetPlatform === 'win32'
? findWindowsLibMpv(vendoredRuntimeRoot)
: targetPlatform === 'linux'
- ? findLinuxLibMpv(vendoredLibDir)
+ ? true
: null;
const vendoredHeader = path.join(vendoredIncludeDir, 'mpv', 'client.h');
@@ -211,6 +228,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 +362,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 +391,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 +416,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,15 +434,23 @@ 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)
+ ),
+ }
: {}),
};
log(
`Building native addon against Electron ${electronVersion} using ${runtime.origin} runtime for ${targetPlatform}-${targetArch}...`
);
+ cleanNativeBuildIntermediates();
runNodeGyp('configure', env);
runNodeGyp('build', env);
@@ -415,7 +466,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'));
}
}
diff --git a/apps/electron-backend/native/binding.gyp b/apps/electron-backend/native/binding.gyp
index 4a637da2e..7e18d2fca 100644
--- a/apps/electron-backend/native/binding.gyp
+++ b/apps/electron-backend/native/binding.gyp
@@ -63,14 +63,14 @@
"sources": [
"src/embedded_mpv_linux.cc"
],
- "libraries": [
- "-L
#include
@@ -7,11 +11,73 @@
#include
#include
#include
+#include
+#include
#include
#include
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 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(parentHandle);
+ trace("parent window " + std::to_string(static_cast(parentWindow_)));
if (!parentWindow_) {
lastError_ = "Unable to resolve Electron X11 window handle.";
destroy();
@@ -49,29 +123,58 @@ 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
- );
+ bool createWindowFailed = false;
+ {
+ 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;
+ createWindowFailed = true;
+ }
+ }
+ if (createWindowFailed) {
+ 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 +202,7 @@ public:
std::string wid() const
{
- std::ostringstream stream;
- stream << static_cast(window_);
- return stream.str();
+ return std::to_string(static_cast(window_));
}
void destroy()
diff --git a/apps/electron-backend/native/src/embedded_mpv_wid_common.h b/apps/electron-backend/native/src/embedded_mpv_wid_common.h
index 4e76478b5..cfe8a873f 100644
--- a/apps/electron-backend/native/src/embedded_mpv_wid_common.h
+++ b/apps/electron-backend/native/src/embedded_mpv_wid_common.h
@@ -6,23 +6,87 @@
#include
+#ifdef IPTVNATOR_DYNAMIC_LIBMPV
+#ifndef IPTVNATOR_MPV_SELECTANY
+#ifdef MPV_SELECTANY
+#define IPTVNATOR_MPV_SELECTANY MPV_SELECTANY
+#elif defined(_WIN32)
+#define IPTVNATOR_MPV_SELECTANY __declspec(selectany)
+#else
+#define IPTVNATOR_MPV_SELECTANY
+#endif
+#endif
+
+#define IPTVNATOR_DECLARE_MPV_DYNAMIC_SYMBOL(name) \
+ IPTVNATOR_MPV_SELECTANY decltype(&name) pfn_##name = nullptr;
+
+IPTVNATOR_DECLARE_MPV_DYNAMIC_SYMBOL(mpv_command_async)
+IPTVNATOR_DECLARE_MPV_DYNAMIC_SYMBOL(mpv_command_node_async)
+IPTVNATOR_DECLARE_MPV_DYNAMIC_SYMBOL(mpv_create)
+IPTVNATOR_DECLARE_MPV_DYNAMIC_SYMBOL(mpv_error_string)
+IPTVNATOR_DECLARE_MPV_DYNAMIC_SYMBOL(mpv_initialize)
+IPTVNATOR_DECLARE_MPV_DYNAMIC_SYMBOL(mpv_observe_property)
+IPTVNATOR_DECLARE_MPV_DYNAMIC_SYMBOL(mpv_request_log_messages)
+IPTVNATOR_DECLARE_MPV_DYNAMIC_SYMBOL(mpv_set_option_string)
+IPTVNATOR_DECLARE_MPV_DYNAMIC_SYMBOL(mpv_set_property)
+IPTVNATOR_DECLARE_MPV_DYNAMIC_SYMBOL(mpv_set_property_async)
+IPTVNATOR_DECLARE_MPV_DYNAMIC_SYMBOL(mpv_terminate_destroy)
+IPTVNATOR_DECLARE_MPV_DYNAMIC_SYMBOL(mpv_wait_event)
+IPTVNATOR_DECLARE_MPV_DYNAMIC_SYMBOL(mpv_wakeup)
+
+#undef IPTVNATOR_DECLARE_MPV_DYNAMIC_SYMBOL
+
+#define mpv_command_async pfn_mpv_command_async
+#define mpv_command_node_async pfn_mpv_command_node_async
+#define mpv_create pfn_mpv_create
+#define mpv_error_string pfn_mpv_error_string
+#define mpv_initialize pfn_mpv_initialize
+#define mpv_observe_property pfn_mpv_observe_property
+#define mpv_request_log_messages pfn_mpv_request_log_messages
+#define mpv_set_option_string pfn_mpv_set_option_string
+#define mpv_set_property pfn_mpv_set_property
+#define mpv_set_property_async pfn_mpv_set_property_async
+#define mpv_terminate_destroy pfn_mpv_terminate_destroy
+#define mpv_wait_event pfn_mpv_wait_event
+#define mpv_wakeup pfn_mpv_wakeup
+#endif
+
#include
#include
+#include
#include
#include
+#include
#include
#include
#include
#include
#include
+#include
#include
#include
+#include
#include
#include
+#include
#include
#include
#include
#include
+#ifdef __linux__
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#endif
+
+#ifdef __linux__
+extern char **environ;
+#endif
namespace {
@@ -78,13 +142,29 @@ struct Session {
std::string pendingRecordingStartedAt;
std::string pendingRecordingStopStartedAt;
uint64_t pendingPlaybackLoadRequestId = 0;
+#ifdef __linux__
+ pid_t mpvProcessId = -1;
+ std::string mpvIpcSocketPath;
+#endif
};
std::atomic gNextSessionId{1};
std::atomic gNextAsyncRequestId{1};
+#ifdef __linux__
+std::atomic gNextLinuxIpcSocketId{1};
+#endif
std::mutex gSessionsMutex;
std::unordered_map> gSessions;
+void traceMpvCommon(const std::string& message)
+{
+ if (!std::getenv("IPTVNATOR_TRACE_EMBEDDED_MPV")) {
+ return;
+ }
+
+ std::cerr << "[Embedded MPV] " << message << std::endl;
+}
+
std::string toStatusString(SessionStatus status)
{
switch (status) {
@@ -120,6 +200,27 @@ double clampVolumePercent(double value)
return std::max(0.0, std::min(100.0, value * 100.0));
}
+std::string formatInvariantDouble(double value)
+{
+ if (!std::isfinite(value)) {
+ return "0";
+ }
+
+ char buffer[64]{};
+ const auto result = std::to_chars(
+ buffer,
+ buffer + sizeof(buffer),
+ value,
+ std::chars_format::general,
+ 17
+ );
+ if (result.ec != std::errc()) {
+ return "0";
+ }
+
+ return std::string(buffer, result.ptr);
+}
+
std::string nowIsoString()
{
const auto now = std::chrono::system_clock::now();
@@ -265,6 +366,696 @@ Bounds readBounds(const Napi::Object& object)
};
}
+#ifdef __linux__
+struct LinuxMpvProcess {
+ pid_t processId = -1;
+ std::string ipcSocketPath;
+};
+
+bool isExecutableAvailable(const char* executableName)
+{
+ const char* pathValue = std::getenv("PATH");
+ if (!pathValue || !executableName || executableName[0] == '\0') {
+ return false;
+ }
+
+ std::string paths(pathValue);
+ size_t start = 0;
+ while (start <= paths.size()) {
+ const size_t end = paths.find(':', start);
+ const std::string directory = paths.substr(
+ start,
+ end == std::string::npos ? std::string::npos : end - start
+ );
+ const std::string candidate =
+ (directory.empty() ? "." : directory) + "/" + executableName;
+ if (access(candidate.c_str(), X_OK) == 0) {
+ return true;
+ }
+ if (end == std::string::npos) {
+ break;
+ }
+ start = end + 1;
+ }
+
+ return false;
+}
+
+bool hasEnvPrefix(const std::string& entry, const char* prefix)
+{
+ return entry.rfind(prefix, 0) == 0;
+}
+
+std::vector buildLinuxMpvEnvironment()
+{
+ std::vector environment;
+ bool hasXdgSessionType = false;
+
+ for (char** current = environ; current && *current; current += 1) {
+ const std::string entry(*current);
+
+ if (hasEnvPrefix(entry, "WAYLAND_DISPLAY=")) {
+ continue;
+ }
+
+ if (hasEnvPrefix(entry, "XDG_SESSION_TYPE=")) {
+ environment.push_back("XDG_SESSION_TYPE=x11");
+ hasXdgSessionType = true;
+ continue;
+ }
+
+ environment.push_back(entry);
+ }
+
+ if (!hasXdgSessionType) {
+ environment.push_back("XDG_SESSION_TYPE=x11");
+ }
+
+ return environment;
+}
+
+void updateLinuxProcessState(const std::shared_ptr& session)
+{
+ if (!session || !session->running.load()) {
+ return;
+ }
+
+ pid_t processId = -1;
+ {
+ std::lock_guard lock(session->mutex);
+ processId = session->mpvProcessId;
+ }
+ if (processId <= 0) {
+ return;
+ }
+
+ int status = 0;
+ const pid_t result = waitpid(processId, &status, WNOHANG);
+ if (result != processId) {
+ return;
+ }
+ if (!session->running.load()) {
+ return;
+ }
+
+ std::lock_guard lock(session->mutex);
+ if (session->mpvProcessId != processId) {
+ return;
+ }
+ session->mpvProcessId = -1;
+ session->snapshot.status =
+ WIFEXITED(status) && WEXITSTATUS(status) == 0
+ ? SessionStatus::Closed
+ : SessionStatus::Error;
+ if (session->snapshot.status == SessionStatus::Error) {
+ session->snapshot.error = "Embedded MPV process exited unexpectedly.";
+ }
+}
+
+void unlinkLinuxIpcSocket(const std::string& ipcSocketPath)
+{
+ if (!ipcSocketPath.empty()) {
+ unlink(ipcSocketPath.c_str());
+ }
+}
+
+LinuxMpvProcess takeLinuxMpvProcess(const std::shared_ptr& session)
+{
+ LinuxMpvProcess process;
+ if (!session) {
+ return process;
+ }
+
+ {
+ std::lock_guard lock(session->mutex);
+ process.processId = session->mpvProcessId;
+ session->mpvProcessId = -1;
+ process.ipcSocketPath = session->mpvIpcSocketPath;
+ session->mpvIpcSocketPath.clear();
+ }
+
+ return process;
+}
+
+void waitForLinuxMpvProcessExit(LinuxMpvProcess process)
+{
+ if (process.processId <= 0) {
+ unlinkLinuxIpcSocket(process.ipcSocketPath);
+ return;
+ }
+
+ for (int attempt = 0; attempt < 10; attempt += 1) {
+ int status = 0;
+ const pid_t result = waitpid(process.processId, &status, WNOHANG);
+ if (result == process.processId || result == -1) {
+ unlinkLinuxIpcSocket(process.ipcSocketPath);
+ return;
+ }
+ std::this_thread::sleep_for(std::chrono::milliseconds(50));
+ }
+
+ kill(process.processId, SIGKILL);
+ waitpid(process.processId, nullptr, 0);
+ unlinkLinuxIpcSocket(process.ipcSocketPath);
+}
+
+void terminateLinuxMpvProcessAsync(const std::shared_ptr& session)
+{
+ auto process = takeLinuxMpvProcess(session);
+ if (process.processId <= 0) {
+ unlinkLinuxIpcSocket(process.ipcSocketPath);
+ return;
+ }
+
+ kill(process.processId, SIGTERM);
+ std::thread([process = std::move(process)]() mutable {
+ waitForLinuxMpvProcessExit(std::move(process));
+ }).detach();
+}
+
+void appendLinuxMpvOption(
+ std::vector& arguments,
+ const std::string& name,
+ const std::string& value)
+{
+ if (!value.empty()) {
+ arguments.push_back("--" + name + "=" + value);
+ }
+}
+
+std::vector buildLinuxMpvArguments(
+ const std::shared_ptr& session,
+ const Napi::Object& playback,
+ const std::string& streamUrl,
+ const std::string& title,
+ const std::string& userAgent,
+ const std::string& referer,
+ double startTime,
+ const std::string& ipcSocketPath)
+{
+ std::vector arguments;
+ arguments.reserve(24);
+ arguments.push_back("mpv");
+ arguments.push_back("--no-config");
+ arguments.push_back("--no-terminal");
+ arguments.push_back("--force-window=immediate");
+ arguments.push_back("--osc=no");
+ arguments.push_back("--input-default-bindings=no");
+ arguments.push_back("--input-vo-keyboard=no");
+ arguments.push_back("--ytdl=no");
+ arguments.push_back("--keep-open=yes");
+ arguments.push_back("--wid=" + session->host.wid());
+ arguments.push_back("--input-ipc-server=" + ipcSocketPath);
+ arguments.push_back("--vo=gpu,x11");
+ arguments.push_back("--gpu-context=x11egl");
+
+ {
+ std::lock_guard lock(session->mutex);
+ arguments.push_back(
+ "--volume=" +
+ formatInvariantDouble(session->snapshot.volumePercent)
+ );
+ }
+
+ if (std::getenv("IPTVNATOR_TRACE_EMBEDDED_MPV")) {
+ arguments.push_back("--msg-level=all=trace");
+ arguments.push_back("--log-file=/tmp/iptvnator-embedded-mpv.log");
+ } else {
+ arguments.push_back("--really-quiet");
+ }
+
+ appendLinuxMpvOption(arguments, "force-media-title", title);
+ appendLinuxMpvOption(arguments, "user-agent", userAgent);
+ appendLinuxMpvOption(arguments, "referrer", referer);
+ if (std::isfinite(startTime) && startTime >= 0) {
+ appendLinuxMpvOption(
+ arguments,
+ "start",
+ formatInvariantDouble(startTime)
+ );
+ }
+ if (playback.Has("headers") && playback.Get("headers").IsObject()) {
+ appendLinuxMpvOption(
+ arguments,
+ "http-header-fields",
+ joinHeaderFields(playback.Get("headers").As())
+ );
+ }
+ arguments.push_back(streamUrl);
+ return arguments;
+}
+
+void closeInheritedFileDescriptors()
+{
+ DIR* directory = opendir("/proc/self/fd");
+ if (directory) {
+ const int directoryFd = dirfd(directory);
+ while (dirent* entry = readdir(directory)) {
+ char* end = nullptr;
+ const long value = std::strtol(entry->d_name, &end, 10);
+ if (
+ !end ||
+ *end != '\0' ||
+ value <= STDERR_FILENO ||
+ value > INT_MAX
+ ) {
+ continue;
+ }
+
+ const int descriptor = static_cast(value);
+ if (descriptor == directoryFd) {
+ continue;
+ }
+ const int flags = fcntl(descriptor, F_GETFD);
+ if (flags >= 0) {
+ fcntl(descriptor, F_SETFD, flags | FD_CLOEXEC);
+ }
+ }
+ closedir(directory);
+ return;
+ }
+
+ for (int descriptor = STDERR_FILENO + 1; descriptor < 1024; descriptor += 1) {
+ const int flags = fcntl(descriptor, F_GETFD);
+ if (flags >= 0) {
+ fcntl(descriptor, F_SETFD, flags | FD_CLOEXEC);
+ }
+ }
+}
+
+std::string buildLinuxIpcSocketPath(const std::shared_ptr& session)
+{
+ std::string safeSessionId = session ? session->id : "unknown";
+ for (char& character : safeSessionId) {
+ if (!std::isalnum(static_cast(character))) {
+ character = '-';
+ }
+ }
+
+ return "/tmp/iptvnator-embedded-mpv-" +
+ std::to_string(static_cast(getpid())) +
+ "-" + safeSessionId +
+ "-" + std::to_string(gNextLinuxIpcSocketId.fetch_add(1)) + ".sock";
+}
+
+bool writeAll(int fileDescriptor, const std::string& payload)
+{
+ const char* current = payload.c_str();
+ size_t remaining = payload.size();
+
+ while (remaining > 0) {
+ const ssize_t written = write(fileDescriptor, current, remaining);
+ if (written <= 0) {
+ return false;
+ }
+ current += written;
+ remaining -= static_cast(written);
+ }
+
+ return true;
+}
+
+bool readLine(int fileDescriptor, std::string& output)
+{
+ output.clear();
+ char buffer[512];
+
+ while (output.find('\n') == std::string::npos) {
+ const ssize_t received = read(fileDescriptor, buffer, sizeof(buffer));
+ if (received <= 0) {
+ return !output.empty();
+ }
+ output.append(buffer, static_cast(received));
+ }
+
+ const size_t newline = output.find('\n');
+ if (newline != std::string::npos) {
+ output.erase(newline + 1);
+ }
+ return true;
+}
+
+bool transactLinuxMpvIpc(
+ const std::string& socketPath,
+ const std::string& payload,
+ std::string& response)
+{
+ if (socketPath.empty() || socketPath.size() >= sizeof(sockaddr_un::sun_path)) {
+ return false;
+ }
+
+ const int fileDescriptor = socket(AF_UNIX, SOCK_STREAM | SOCK_CLOEXEC, 0);
+ if (fileDescriptor < 0) {
+ return false;
+ }
+
+ timeval timeout{};
+ timeout.tv_sec = 0;
+ timeout.tv_usec = 250000;
+ setsockopt(fileDescriptor, SOL_SOCKET, SO_RCVTIMEO, &timeout, sizeof(timeout));
+ setsockopt(fileDescriptor, SOL_SOCKET, SO_SNDTIMEO, &timeout, sizeof(timeout));
+
+ sockaddr_un address{};
+ address.sun_family = AF_UNIX;
+ std::strncpy(address.sun_path, socketPath.c_str(), sizeof(address.sun_path) - 1);
+
+ bool ok = connect(
+ fileDescriptor,
+ reinterpret_cast(&address),
+ sizeof(address)
+ ) == 0;
+ if (ok) {
+ ok = writeAll(fileDescriptor, payload) && readLine(fileDescriptor, response);
+ }
+
+ close(fileDescriptor);
+ return ok;
+}
+
+std::optional parseJsonDataToken(const std::string& response)
+{
+ const size_t key = response.find("\"data\"");
+ if (key == std::string::npos) {
+ return std::nullopt;
+ }
+ const size_t colon = response.find(':', key);
+ if (colon == std::string::npos) {
+ return std::nullopt;
+ }
+
+ size_t start = colon + 1;
+ while (start < response.size() &&
+ std::isspace(static_cast(response[start]))) {
+ start += 1;
+ }
+ if (start >= response.size()) {
+ return std::nullopt;
+ }
+
+ if (response.compare(start, 4, "null") == 0) {
+ return std::nullopt;
+ }
+
+ if (response[start] == '"') {
+ size_t end = start + 1;
+ while (end < response.size()) {
+ if (response[end] == '"' && response[end - 1] != '\\') {
+ break;
+ }
+ end += 1;
+ }
+ if (end >= response.size()) {
+ return std::nullopt;
+ }
+ return response.substr(start + 1, end - start - 1);
+ }
+
+ size_t end = start;
+ while (end < response.size() &&
+ response[end] != ',' &&
+ response[end] != '}' &&
+ !std::isspace(static_cast(response[end]))) {
+ end += 1;
+ }
+
+ return response.substr(start, end - start);
+}
+
+std::optional parseJsonDataNumber(const std::string& response)
+{
+ const auto token = parseJsonDataToken(response);
+ if (!token) {
+ return std::nullopt;
+ }
+
+ char* end = nullptr;
+ const double value = std::strtod(token->c_str(), &end);
+ if (end == token->c_str() || !std::isfinite(value)) {
+ return std::nullopt;
+ }
+
+ return value;
+}
+
+std::optional parseJsonDataBoolean(const std::string& response)
+{
+ const auto token = parseJsonDataToken(response);
+ if (!token) {
+ return std::nullopt;
+ }
+ if (*token == "true") {
+ return true;
+ }
+ if (*token == "false") {
+ return false;
+ }
+ return std::nullopt;
+}
+
+std::optional parseJsonDataString(const std::string& response)
+{
+ return parseJsonDataToken(response);
+}
+
+std::optional queryLinuxMpvNumber(
+ const std::string& socketPath,
+ const std::string& propertyName)
+{
+ std::string response;
+ if (!transactLinuxMpvIpc(
+ socketPath,
+ "{\"command\":[\"get_property\",\"" + propertyName + "\"]}\n",
+ response
+ )) {
+ return std::nullopt;
+ }
+
+ return parseJsonDataNumber(response);
+}
+
+std::optional queryLinuxMpvBoolean(
+ const std::string& socketPath,
+ const std::string& propertyName)
+{
+ std::string response;
+ if (!transactLinuxMpvIpc(
+ socketPath,
+ "{\"command\":[\"get_property\",\"" + propertyName + "\"]}\n",
+ response
+ )) {
+ return std::nullopt;
+ }
+
+ return parseJsonDataBoolean(response);
+}
+
+std::optional queryLinuxMpvString(
+ const std::string& socketPath,
+ const std::string& propertyName)
+{
+ std::string response;
+ if (!transactLinuxMpvIpc(
+ socketPath,
+ "{\"command\":[\"get_property\",\"" + propertyName + "\"]}\n",
+ response
+ )) {
+ return std::nullopt;
+ }
+
+ return parseJsonDataString(response);
+}
+
+bool sendLinuxMpvCommand(
+ const std::string& socketPath,
+ const std::string& command)
+{
+ std::string response;
+ return transactLinuxMpvIpc(socketPath, command, response);
+}
+
+void refreshLinuxMpvSnapshot(const std::shared_ptr& session)
+{
+ updateLinuxProcessState(session);
+ if (!session || !session->running.load()) {
+ return;
+ }
+
+ std::string socketPath;
+ pid_t processId = -1;
+ {
+ std::lock_guard lock(session->mutex);
+ socketPath = session->mpvIpcSocketPath;
+ processId = session->mpvProcessId;
+ }
+ if (processId <= 0 || socketPath.empty()) {
+ return;
+ }
+
+ const auto position = queryLinuxMpvNumber(socketPath, "time-pos");
+ const auto duration = queryLinuxMpvNumber(socketPath, "duration");
+ const auto volume = queryLinuxMpvNumber(socketPath, "volume");
+ const auto paused = queryLinuxMpvBoolean(socketPath, "pause");
+ const auto path = queryLinuxMpvString(socketPath, "path");
+
+ std::lock_guard lock(session->mutex);
+ if (
+ !session->running.load() ||
+ session->mpvProcessId != processId ||
+ session->mpvIpcSocketPath != socketPath
+ ) {
+ return;
+ }
+ if (position) {
+ session->snapshot.positionSeconds = std::max(0.0, *position);
+ }
+ if (duration) {
+ session->snapshot.durationSeconds = std::max(0.0, *duration);
+ }
+ if (volume) {
+ session->snapshot.volumePercent =
+ std::max(0.0, std::min(100.0, *volume));
+ }
+ if (paused) {
+ session->snapshot.status =
+ *paused ? SessionStatus::Paused : SessionStatus::Playing;
+ session->snapshot.error.clear();
+ }
+ if (path && !path->empty()) {
+ session->snapshot.streamUrl = *path;
+ }
+}
+
+void runLinuxProcessPollLoop(std::shared_ptr session)
+{
+ while (session && session->running.load()) {
+ refreshLinuxMpvSnapshot(session);
+ for (int tick = 0; tick < 10 && session->running.load(); tick += 1) {
+ std::this_thread::sleep_for(std::chrono::milliseconds(50));
+ }
+ }
+}
+
+bool startLinuxProcessPolling(const std::shared_ptr& session)
+{
+ if (!session) {
+ return false;
+ }
+
+ bool expected = false;
+ if (!session->running.compare_exchange_strong(expected, true)) {
+ return true;
+ }
+
+ try {
+ session->eventThread = std::thread(runLinuxProcessPollLoop, session);
+ } catch (...) {
+ session->running.store(false);
+ return false;
+ }
+
+ return true;
+}
+
+pid_t spawnLinuxMpvProcess(
+ const std::vector& arguments,
+ const std::vector& environment)
+{
+ std::vector argv;
+ argv.reserve(arguments.size() + 1);
+ for (const auto& argument : arguments) {
+ argv.push_back(const_cast(argument.c_str()));
+ }
+ argv.push_back(nullptr);
+
+ std::vector envp;
+ envp.reserve(environment.size() + 1);
+ for (const auto& entry : environment) {
+ envp.push_back(const_cast(entry.c_str()));
+ }
+ envp.push_back(nullptr);
+
+ const bool traceEnabled = std::getenv("IPTVNATOR_TRACE_EMBEDDED_MPV");
+ const pid_t processId = fork();
+ if (processId != 0) {
+ return processId;
+ }
+
+ if (!traceEnabled) {
+ const int nullFd = open("/dev/null", O_RDWR);
+ if (nullFd >= 0) {
+ dup2(nullFd, STDOUT_FILENO);
+ dup2(nullFd, STDERR_FILENO);
+ if (nullFd > STDERR_FILENO) {
+ close(nullFd);
+ }
+ }
+ }
+
+ closeInheritedFileDescriptors();
+ execvpe(argv[0], argv.data(), envp.data());
+ _exit(127);
+}
+
+void loadLinuxProcessPlayback(
+ Napi::Env env,
+ const std::shared_ptr& session,
+ const Napi::Object& playback,
+ const std::string& streamUrl,
+ const std::string& title,
+ const std::string& userAgent,
+ const std::string& referer,
+ double startTime)
+{
+ terminateLinuxMpvProcessAsync(session);
+ const auto ipcSocketPath = buildLinuxIpcSocketPath(session);
+ unlink(ipcSocketPath.c_str());
+ const auto arguments = buildLinuxMpvArguments(
+ session,
+ playback,
+ streamUrl,
+ title,
+ userAgent,
+ referer,
+ startTime,
+ ipcSocketPath
+ );
+ const auto environment = buildLinuxMpvEnvironment();
+
+ const pid_t processId = spawnLinuxMpvProcess(arguments, environment);
+ if (processId < 0) {
+ throw Napi::Error::New(env, "Failed to start embedded MPV process.");
+ }
+
+ {
+ std::lock_guard lock(session->mutex);
+ session->mpvProcessId = processId;
+ session->mpvIpcSocketPath = ipcSocketPath;
+ session->snapshot.positionSeconds = std::max(0.0, startTime);
+ session->snapshot.durationSeconds = -1.0;
+ session->snapshot.streamUrl = streamUrl;
+ session->snapshot.error.clear();
+ session->snapshot.status = SessionStatus::Playing;
+ session->snapshot.audioTracks.clear();
+ session->snapshot.selectedAudioTrackId = -1;
+ session->snapshot.subtitleTracks.clear();
+ session->snapshot.selectedSubtitleTrackId = -1;
+ session->snapshot.playbackSpeed = 1.0;
+ session->snapshot.aspectOverride = "no";
+ session->snapshot.recordingActive = false;
+ session->snapshot.recordingTargetPath.clear();
+ session->snapshot.recordingStartedAt.clear();
+ session->snapshot.recordingError.clear();
+ }
+ if (!startLinuxProcessPolling(session)) {
+ terminateLinuxMpvProcessAsync(session);
+ throw Napi::Error::New(
+ env,
+ "Failed to start embedded MPV snapshot polling."
+ );
+ }
+}
+#endif
+
bool parseIntegerString(const std::string& value, int64_t& result)
{
if (value.empty()) {
@@ -648,6 +1439,21 @@ void destroySession(const std::shared_ptr& session)
return;
}
+#ifdef __linux__
+ session->running.store(false);
+ terminateLinuxMpvProcessAsync(session);
+ if (session->eventThread.joinable()) {
+ session->eventThread.detach();
+ }
+ session->host.destroy();
+ {
+ std::lock_guard lock(session->mutex);
+ session->snapshot.status = SessionStatus::Closed;
+ session->snapshot.recordingActive = false;
+ session->snapshot.recordingStartedAt.clear();
+ }
+ return;
+#endif
session->running.store(false);
if (session->handle) {
bool recordingActive = false;
@@ -687,7 +1493,11 @@ void destroySession(const std::shared_ptr& session)
Napi::Value IsSupported(const Napi::CallbackInfo& info)
{
- return Napi::Boolean::New(info.Env(), NativeVideoHost::isAvailable());
+ bool supported = NativeVideoHost::isAvailable();
+#ifdef __linux__
+ supported = supported && isExecutableAvailable("mpv");
+#endif
+ return Napi::Boolean::New(info.Env(), supported);
}
Napi::Value CreateSession(const Napi::CallbackInfo& info)
@@ -701,15 +1511,16 @@ Napi::Value CreateSession(const Napi::CallbackInfo& info)
}
const auto windowHandle = info[0].As>();
- if (windowHandle.Length() < sizeof(uintptr_t)) {
- throw Napi::Error::New(env, "Native window handle buffer is too small.");
+ const auto windowHandleLength = windowHandle.Length();
+ if (windowHandleLength == 0) {
+ throw Napi::Error::New(env, "Native window handle buffer is empty.");
}
uintptr_t windowHandleValue = 0;
std::memcpy(
&windowHandleValue,
windowHandle.Data(),
- std::min(windowHandle.Length(), sizeof(uintptr_t))
+ std::min(windowHandleLength, sizeof(uintptr_t))
);
if (windowHandleValue == 0) {
throw Napi::Error::New(env, "Unable to resolve Electron native window handle.");
@@ -723,17 +1534,33 @@ Napi::Value CreateSession(const Napi::CallbackInfo& info)
? clampVolumePercent(info[3].As().DoubleValue())
: 100.0;
+ traceMpvCommon("creating native video host");
if (!session->host.create(windowHandleValue, bounds)) {
throw Napi::Error::New(env, NativeVideoHost::lastError());
}
+ traceMpvCommon("native video host created");
+#ifdef __linux__
+ session->host.setBounds(bounds);
+ {
+ std::lock_guard sessionsLock(gSessionsMutex);
+ gSessions.emplace(session->id, session);
+ }
+ return Napi::String::New(env, session->id);
+#endif
+
+ traceMpvCommon("creating libmpv handle");
session->handle = mpv_create();
if (!session->handle) {
session->host.destroy();
throw Napi::Error::New(env, "Failed to create libmpv handle.");
}
+ traceMpvCommon("libmpv handle created");
+ traceMpvCommon("resolving native video host id");
const std::string wid = session->host.wid();
+ traceMpvCommon("native video host id resolved");
+ traceMpvCommon("setting libmpv options");
mpv_set_option_string(session->handle, "terminal", "no");
mpv_set_option_string(session->handle, "config", "no");
mpv_set_option_string(session->handle, "osc", "no");
@@ -743,14 +1570,29 @@ Napi::Value CreateSession(const Napi::CallbackInfo& info)
mpv_set_option_string(session->handle, "input-vo-keyboard", "no");
mpv_set_option_string(session->handle, "ytdl", "no");
mpv_set_option_string(session->handle, "wid", wid.c_str());
+#ifdef __linux__
+ mpv_set_option_string(session->handle, "vo", "x11");
+ mpv_set_option_string(session->handle, "hwdec", "no");
+#else
mpv_set_option_string(session->handle, "vo", "gpu");
mpv_set_option_string(session->handle, "hwdec", "auto-safe");
+#endif
+ if (std::getenv("IPTVNATOR_TRACE_EMBEDDED_MPV")) {
+ mpv_set_option_string(session->handle, "msg-level", "all=trace");
+ mpv_set_option_string(
+ session->handle,
+ "log-file",
+ "/tmp/iptvnator-embedded-mpv.log"
+ );
+ }
- const auto initialVolume =
- std::to_string(session->snapshot.volumePercent);
+ const auto initialVolume = formatInvariantDouble(
+ session->snapshot.volumePercent
+ );
mpv_set_option_string(session->handle, "volume", initialVolume.c_str());
mpv_request_log_messages(session->handle, "warn");
+ traceMpvCommon("initializing libmpv");
const int initializeResult = mpv_initialize(session->handle);
if (initializeResult < 0) {
destroySession(session);
@@ -760,7 +1602,9 @@ Napi::Value CreateSession(const Napi::CallbackInfo& info)
mpv_error_string(initializeResult)
);
}
+ traceMpvCommon("libmpv initialized");
+ traceMpvCommon("observing libmpv properties");
mpv_observe_property(session->handle, 1, "time-pos", MPV_FORMAT_DOUBLE);
mpv_observe_property(session->handle, 2, "duration", MPV_FORMAT_DOUBLE);
mpv_observe_property(session->handle, 3, "pause", MPV_FORMAT_FLAG);
@@ -781,6 +1625,7 @@ Napi::Value CreateSession(const Napi::CallbackInfo& info)
session->running.store(true);
session->eventThread = std::thread(runEventLoop, session);
session->host.setBounds(bounds);
+ traceMpvCommon("session event loop started");
{
std::lock_guard sessionsLock(gSessionsMutex);
@@ -809,6 +1654,20 @@ Napi::Value LoadPlayback(const Napi::CallbackInfo& info)
const std::string referer = readOptionalString(playback, "referer");
const double startTime = readOptionalNumber(playback, "startTime", -1);
+#ifdef __linux__
+ loadLinuxProcessPlayback(
+ env,
+ session,
+ playback,
+ streamUrl,
+ title,
+ userAgent,
+ referer,
+ startTime
+ );
+ return env.Undefined();
+#endif
+
bool recordingActive = false;
{
std::lock_guard lock(session->mutex);
@@ -957,6 +1816,23 @@ Napi::Value SetPaused(const Napi::CallbackInfo& info)
const auto session =
getSessionOrThrow(env, info[0].As().Utf8Value());
int paused = info[1].As().Value() ? 1 : 0;
+#ifdef __linux__
+ std::string socketPath;
+ {
+ std::lock_guard lock(session->mutex);
+ socketPath = session->mpvIpcSocketPath;
+ session->snapshot.status =
+ paused ? SessionStatus::Paused : SessionStatus::Playing;
+ }
+ if (!socketPath.empty()) {
+ sendLinuxMpvCommand(
+ socketPath,
+ std::string("{\"command\":[\"set_property\",\"pause\",") +
+ (paused ? "true" : "false") + "]}\n"
+ );
+ }
+ return env.Undefined();
+#endif
const int result = mpv_set_property_async(
session->handle,
nextAsyncRequestId(),
@@ -978,8 +1854,25 @@ Napi::Value Seek(const Napi::CallbackInfo& info)
}
const auto session =
getSessionOrThrow(env, info[0].As().Utf8Value());
- const std::string seconds =
- std::to_string(info[1].As().DoubleValue());
+ const std::string seconds = formatInvariantDouble(
+ info[1].As().DoubleValue()
+ );
+#ifdef __linux__
+ std::string socketPath;
+ {
+ std::lock_guard lock(session->mutex);
+ socketPath = session->mpvIpcSocketPath;
+ session->snapshot.positionSeconds =
+ std::max(0.0, info[1].As().DoubleValue());
+ }
+ if (!socketPath.empty()) {
+ sendLinuxMpvCommand(
+ socketPath,
+ "{\"command\":[\"seek\"," + seconds + ",\"absolute\"]}\n"
+ );
+ }
+ return env.Undefined();
+#endif
const char* command[] = { "seek", seconds.c_str(), "absolute", nullptr };
const int result = mpv_command_async(
session->handle,
@@ -1001,6 +1894,22 @@ Napi::Value SetVolume(const Napi::CallbackInfo& info)
const auto session =
getSessionOrThrow(env, info[0].As().Utf8Value());
double volume = clampVolumePercent(info[1].As().DoubleValue());
+#ifdef __linux__
+ std::string socketPath;
+ {
+ std::lock_guard lock(session->mutex);
+ socketPath = session->mpvIpcSocketPath;
+ session->snapshot.volumePercent = volume;
+ }
+ if (!socketPath.empty()) {
+ sendLinuxMpvCommand(
+ socketPath,
+ "{\"command\":[\"set_property\",\"volume\"," +
+ formatInvariantDouble(volume) + "]}\n"
+ );
+ }
+ return env.Undefined();
+#endif
const int result = mpv_set_property_async(
session->handle,
nextAsyncRequestId(),
@@ -1024,6 +1933,22 @@ Napi::Value SetAudioTrack(const Napi::CallbackInfo& info)
getSessionOrThrow(env, info[0].As().Utf8Value());
int64_t trackId =
static_cast(info[1].As().Int64Value());
+#ifdef __linux__
+ std::string socketPath;
+ {
+ std::lock_guard lock(session->mutex);
+ socketPath = session->mpvIpcSocketPath;
+ session->snapshot.selectedAudioTrackId = trackId;
+ }
+ if (!socketPath.empty()) {
+ sendLinuxMpvCommand(
+ socketPath,
+ "{\"command\":[\"set_property\",\"aid\"," +
+ std::to_string(trackId) + "]}\n"
+ );
+ }
+ return env.Undefined();
+#endif
const int result = mpv_set_property_async(
session->handle,
nextAsyncRequestId(),
@@ -1037,7 +1962,7 @@ Napi::Value SetAudioTrack(const Napi::CallbackInfo& info)
return env.Undefined();
}
-Napi::Value SetSubtitleTrack(const Napi::CallbackInfo& info)
+[[maybe_unused]] Napi::Value SetSubtitleTrack(const Napi::CallbackInfo& info)
{
Napi::Env env = info.Env();
if (info.Length() < 2 || !info[0].IsString() || !info[1].IsNumber()) {
@@ -1072,7 +1997,7 @@ Napi::Value SetSubtitleTrack(const Napi::CallbackInfo& info)
return env.Undefined();
}
-Napi::Value SetSpeed(const Napi::CallbackInfo& info)
+[[maybe_unused]] Napi::Value SetSpeed(const Napi::CallbackInfo& info)
{
Napi::Env env = info.Env();
if (info.Length() < 2 || !info[0].IsString() || !info[1].IsNumber()) {
@@ -1094,7 +2019,7 @@ Napi::Value SetSpeed(const Napi::CallbackInfo& info)
return env.Undefined();
}
-Napi::Value SetAspect(const Napi::CallbackInfo& info)
+[[maybe_unused]] Napi::Value SetAspect(const Napi::CallbackInfo& info)
{
Napi::Env env = info.Env();
if (info.Length() < 2 || !info[0].IsString() || !info[1].IsString()) {
@@ -1117,7 +2042,7 @@ Napi::Value SetAspect(const Napi::CallbackInfo& info)
return env.Undefined();
}
-Napi::Value StartRecording(const Napi::CallbackInfo& info)
+[[maybe_unused]] Napi::Value StartRecording(const Napi::CallbackInfo& info)
{
Napi::Env env = info.Env();
if (info.Length() < 2 || !info[0].IsString() || !info[1].IsString()) {
@@ -1153,7 +2078,7 @@ Napi::Value StartRecording(const Napi::CallbackInfo& info)
return env.Undefined();
}
-Napi::Value StopRecording(const Napi::CallbackInfo& info)
+[[maybe_unused]] Napi::Value StopRecording(const Napi::CallbackInfo& info)
{
Napi::Env env = info.Env();
if (info.Length() < 1 || !info[0].IsString()) {
@@ -1322,11 +2247,13 @@ Napi::Object Init(Napi::Env env, Napi::Object exports)
exports.Set("seek", Napi::Function::New(env, Seek));
exports.Set("setVolume", Napi::Function::New(env, SetVolume));
exports.Set("setAudioTrack", Napi::Function::New(env, SetAudioTrack));
+#ifndef __linux__
exports.Set("setSubtitleTrack", Napi::Function::New(env, SetSubtitleTrack));
exports.Set("setSpeed", Napi::Function::New(env, SetSpeed));
exports.Set("setAspect", Napi::Function::New(env, SetAspect));
exports.Set("startRecording", Napi::Function::New(env, StartRecording));
exports.Set("stopRecording", Napi::Function::New(env, StopRecording));
+#endif
exports.Set("getSessionSnapshot", Napi::Function::New(env, GetSessionSnapshot));
exports.Set("disposeSession", Napi::Function::New(env, DisposeSession));
return exports;
diff --git a/apps/electron-backend/src/app/services/embedded-mpv-native-source.spec.ts b/apps/electron-backend/src/app/services/embedded-mpv-native-source.spec.ts
index fd287922e..68e13e601 100644
--- a/apps/electron-backend/src/app/services/embedded-mpv-native-source.spec.ts
+++ b/apps/electron-backend/src/app/services/embedded-mpv-native-source.spec.ts
@@ -26,27 +26,45 @@ 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'
+ );
+ const stageRuntimeSource = readFileSync(
+ path.resolve(
+ __dirname,
+ '../../../../../tools/embedded-mpv/stage-runtime.mjs'
+ ),
'utf8'
);
function functionBody(name: string): string {
- const start = nativeSource.indexOf(`Napi::Value ${name}(`);
+ return sourceFunctionBody(nativeSource, `Napi::Value ${name}(`, name);
+ }
+
+ function sourceFunctionBody(
+ source: string,
+ signature: string,
+ name: string
+ ): string {
+ const start = source.indexOf(signature);
expect(start).toBeGreaterThanOrEqual(0);
- const bodyStart = nativeSource.indexOf('{', start);
+ const bodyStart = source.indexOf('{', start);
expect(bodyStart).toBeGreaterThanOrEqual(0);
let depth = 0;
- for (let index = bodyStart; index < nativeSource.length; index += 1) {
- if (nativeSource[index] === '{') {
+ for (let index = bodyStart; index < source.length; index += 1) {
+ if (source[index] === '{') {
depth += 1;
}
- if (nativeSource[index] === '}') {
+ if (source[index] === '}') {
depth -= 1;
}
if (depth === 0) {
- return nativeSource.slice(bodyStart, index + 1);
+ return source.slice(bodyStart, index + 1);
}
}
@@ -141,7 +159,9 @@ describe('Embedded MPV native source recording invariants', () => {
idleFallbackStart
);
expect(redirectBranch).toContain('SessionStatus::Loading');
- expect(redirectBranch).toContain('session->snapshot.error.clear();');
+ expect(redirectBranch).toContain(
+ 'session->snapshot.error.clear();'
+ );
}
});
@@ -245,7 +265,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.'
@@ -259,6 +281,248 @@ describe('Embedded MPV native source recording invariants', () => {
expect(linuxSource).toContain('drainEvents();');
});
+ it('defers Linux X11 create failure cleanup until after the error trap scope closes', () => {
+ const trapStart = linuxSource.indexOf(
+ 'ScopedX11ErrorTrap x11Errors(display_);'
+ );
+ expect(trapStart).toBeGreaterThanOrEqual(0);
+ const deferredCleanupStart = linuxSource.indexOf(
+ 'if (createWindowFailed) {',
+ trapStart
+ );
+ expect(deferredCleanupStart).toBeGreaterThan(trapStart);
+
+ const trappedCreateBlock = linuxSource.slice(
+ trapStart,
+ deferredCleanupStart
+ );
+ const nextWindowGuardStart = linuxSource.indexOf(
+ 'if (!window_) {',
+ deferredCleanupStart
+ );
+ expect(nextWindowGuardStart).toBeGreaterThan(deferredCleanupStart);
+ const deferredCleanupBlock = linuxSource.slice(
+ deferredCleanupStart,
+ nextWindowGuardStart
+ );
+
+ expect(trappedCreateBlock).toContain('createWindowFailed = true;');
+ expect(trappedCreateBlock).not.toContain('destroy();');
+ expect(deferredCleanupBlock).toContain('destroy();');
+ });
+
+ 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(
+ 'void closeInheritedFileDescriptors()'
+ );
+ expect(widCommonSource).toContain('opendir("/proc/self/fd")');
+ expect(widCommonSource).toContain('readdir(directory)');
+ expect(widCommonSource).toContain(
+ 'fcntl(descriptor, F_SETFD, flags | FD_CLOEXEC)'
+ );
+ expect(widCommonSource).toContain('closeInheritedFileDescriptors();');
+ expect(widCommonSource).not.toContain('inheritedFileDescriptorLimit()');
+ expect(widCommonSource).not.toContain(
+ 'closeInheritedFileDescriptors(fileDescriptorLimit);'
+ );
+ });
+
+ 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('keeps Linux MPV IPC volume readback in mpv percent units', () => {
+ const refreshBody = sourceFunctionBody(
+ widCommonSource,
+ 'void refreshLinuxMpvSnapshot(',
+ 'refreshLinuxMpvSnapshot'
+ );
+ expect(refreshBody).toContain(
+ 'queryLinuxMpvNumber(socketPath, "volume")'
+ );
+ expect(refreshBody).toContain(
+ 'session->snapshot.volumePercent =\n' +
+ ' std::max(0.0, std::min(100.0, *volume));'
+ );
+ expect(refreshBody).not.toContain('clampVolumePercent(*volume)');
+ });
+
+ it('only marks open file descriptors close-on-exec before Linux MPV exec', () => {
+ const closeDescriptorsBody = sourceFunctionBody(
+ widCommonSource,
+ 'void closeInheritedFileDescriptors(',
+ 'closeInheritedFileDescriptors'
+ );
+ expect(closeDescriptorsBody).toContain('opendir("/proc/self/fd")');
+ expect(closeDescriptorsBody).toContain('readdir(directory)');
+ expect(closeDescriptorsBody).toContain('fcntl(descriptor, F_GETFD)');
+ expect(closeDescriptorsBody).toContain(
+ 'fcntl(descriptor, F_SETFD, flags | FD_CLOEXEC)'
+ );
+ expect(widCommonSource).not.toContain('sysconf(_SC_OPEN_MAX)');
+ expect(widCommonSource).not.toContain(
+ 'fileDescriptor < fileDescriptorLimit'
+ );
+
+ const spawnBody = sourceFunctionBody(
+ widCommonSource,
+ 'pid_t spawnLinuxMpvProcess(',
+ 'spawnLinuxMpvProcess'
+ );
+ expect(spawnBody).toContain('closeInheritedFileDescriptors();');
+ expect(spawnBody).not.toContain('inheritedFileDescriptorLimit()');
+ });
+
+ it('formats MPV floating-point values independently from the user locale', () => {
+ const formatterBody = sourceFunctionBody(
+ widCommonSource,
+ 'std::string formatInvariantDouble(',
+ 'formatInvariantDouble'
+ );
+ expect(formatterBody).toContain('std::to_chars(');
+ expect(formatterBody).toContain('std::chars_format::general');
+ expect(formatterBody).not.toContain('std::locale::classic()');
+ expect(formatterBody).not.toContain('std::ostringstream');
+
+ const linuxArgumentsBody = sourceFunctionBody(
+ widCommonSource,
+ 'std::vector buildLinuxMpvArguments(',
+ 'buildLinuxMpvArguments'
+ );
+ expect(linuxArgumentsBody).toContain(
+ '"--volume=" +\n' +
+ ' formatInvariantDouble(session->snapshot.volumePercent)'
+ );
+ expect(linuxArgumentsBody).toContain(
+ 'appendLinuxMpvOption(\n' +
+ ' arguments,\n' +
+ ' "start",\n' +
+ ' formatInvariantDouble(startTime)\n' +
+ ' );'
+ );
+
+ const seekBody = sourceFunctionBody(
+ widCommonSource,
+ 'Napi::Value Seek(',
+ 'Seek'
+ );
+ expect(seekBody).toContain('formatInvariantDouble(');
+ expect(seekBody).not.toContain('std::to_string(info[1]');
+
+ const setVolumeBody = sourceFunctionBody(
+ widCommonSource,
+ 'Napi::Value SetVolume(',
+ 'SetVolume'
+ );
+ expect(setVolumeBody).toContain('formatInvariantDouble(volume)');
+ expect(setVolumeBody).not.toContain('std::to_string(volume)');
+ });
+
+ it('keeps Linux MPV snapshot IPC off the NAPI snapshot read path', () => {
+ const getSnapshotBody = sourceFunctionBody(
+ widCommonSource,
+ 'Napi::Value GetSessionSnapshot(',
+ 'GetSessionSnapshot'
+ );
+ expect(getSnapshotBody).not.toContain('refreshLinuxMpvSnapshot');
+ expect(widCommonSource).toContain('void runLinuxProcessPollLoop');
+ expect(widCommonSource).toContain('refreshLinuxMpvSnapshot(session);');
+ expect(widCommonSource).toContain('startLinuxProcessPolling(session)');
+ expect(widCommonSource).toContain(
+ 'session->eventThread = std::thread(runLinuxProcessPollLoop, session);'
+ );
+ });
+
+ it('terminates Linux MPV processes away from the NAPI teardown path', () => {
+ const destroyBody = sourceFunctionBody(
+ widCommonSource,
+ 'void destroySession(',
+ 'destroySession'
+ );
+ expect(destroyBody).toContain(
+ 'terminateLinuxMpvProcessAsync(session);'
+ );
+ expect(destroyBody).toContain('session->eventThread.detach();');
+ expect(destroyBody).not.toContain('std::this_thread::sleep_for');
+ expect(destroyBody).not.toContain('SIGKILL');
+
+ const asyncTerminationBody = sourceFunctionBody(
+ widCommonSource,
+ 'void terminateLinuxMpvProcessAsync(',
+ 'terminateLinuxMpvProcessAsync'
+ );
+ expect(asyncTerminationBody).toContain(
+ 'kill(process.processId, SIGTERM);'
+ );
+ expect(asyncTerminationBody).toContain('std::thread(');
+ expect(asyncTerminationBody).toContain('waitForLinuxMpvProcessExit');
+ });
+
+ it('uses generation-unique Linux MPV IPC socket paths', () => {
+ expect(widCommonSource).toContain('gNextLinuxIpcSocketId');
+ expect(widCommonSource).toContain(
+ 'std::to_string(gNextLinuxIpcSocketId.fetch_add(1))'
+ );
+ });
+
+ it('keeps dynamic libmpv symbol declarations compatible with distro headers', () => {
+ expect(widCommonSource).not.toContain('MPV_CPLUGIN_DYNAMIC_SYM');
+ expect(widCommonSource).toContain('#ifdef IPTVNATOR_DYNAMIC_LIBMPV');
+ expect(widCommonSource).toContain('#ifdef MPV_SELECTANY');
+ expect(widCommonSource).toContain(
+ '#define IPTVNATOR_MPV_SELECTANY MPV_SELECTANY'
+ );
+ expect(widCommonSource).toContain(
+ 'IPTVNATOR_MPV_SELECTANY decltype(&name) pfn_##name = nullptr;'
+ );
+ expect(widCommonSource).toContain(
+ 'IPTVNATOR_DECLARE_MPV_DYNAMIC_SYMBOL(mpv_command_async)'
+ );
+ expect(widCommonSource).toContain(
+ '#define mpv_command_async pfn_mpv_command_async'
+ );
+ expect(widCommonSource).not.toContain('mpvLibraryCandidates');
+ expect(widCommonSource).not.toContain('ensureMpvApiLoaded');
+ expect(widCommonSource).not.toContain('"libmpv.so"');
+ expect(buildScriptSource).toContain('cleanNativeBuildIntermediates();');
+ });
+
+ it('does not stage Linux libmpv runtime libraries', () => {
+ expect(stageRuntimeSource).toContain(
+ "if (platform !== 'linux') {\n" +
+ ' copyDirectory(sourceLibDir, destinationLibDir, runtimeFileFilter);\n' +
+ ' }'
+ );
+ });
+
it('uses platform-specific embedded MPV runtime cache key inputs in CI', () => {
expect(buildAndMakeWorkflowSource).toContain(
"const targetPlatform = '${{ matrix.embedded_mpv_platform }}';"
@@ -277,6 +541,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', () => {
diff --git a/apps/electron-backend/src/app/services/embedded-mpv-native.service.spec.ts b/apps/electron-backend/src/app/services/embedded-mpv-native.service.spec.ts
index 8dff12386..8044d9401 100644
--- a/apps/electron-backend/src/app/services/embedded-mpv-native.service.spec.ts
+++ b/apps/electron-backend/src/app/services/embedded-mpv-native.service.spec.ts
@@ -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(),
stop: jest.fn(),
isStarted: jest.fn(),
};
+const commandLineMock = {
+ getSwitchValue: jest.fn(),
+};
+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.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(['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,114 @@ 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');
+ expect(mockSpawnSync).toHaveBeenCalledWith('mpv', ['--version'], {
+ stdio: 'ignore',
+ timeout: 3000,
+ });
+ });
+
+ it('caches the Linux mpv executable probe result', () => {
+ Object.defineProperty(process, 'platform', {
+ value: 'linux',
+ });
+ delete process.env.WAYLAND_DISPLAY;
+
+ service.getSupport();
+ service.getSupport();
+
+ expect(mockSpawnSync).toHaveBeenCalledTimes(1);
+ expect(mockSpawnSync).toHaveBeenCalledWith('mpv', ['--version'], {
+ stdio: 'ignore',
+ timeout: 3000,
+ });
+ });
+
+ 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('rejects 64-bit 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.alloc(8)
+ );
+
+ 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,
@@ -330,13 +477,10 @@ describe('EmbeddedMpvNativeService power blocker', () => {
platform: 'win32' as NodeJS.Platform,
runtimeFile: path.join('lib', 'mpv-2.dll'),
},
- {
- platform: 'linux' as NodeJS.Platform,
- runtimeFile: path.join('lib', 'libmpv.so.2'),
- },
])(
'loads the addon after validating the $platform runtime file exists',
({ platform, runtimeFile }) => {
+ delete process.env.WAYLAND_DISPLAY;
Object.defineProperty(process, 'platform', {
value: platform,
});
@@ -365,6 +509,36 @@ describe('EmbeddedMpvNativeService power blocker', () => {
}
);
+ it('loads the Linux addon without bundled libmpv runtime files', () => {
+ delete process.env.WAYLAND_DISPLAY;
+ Object.defineProperty(process, 'platform', {
+ value: 'linux',
+ });
+ const nativeDir = createTempDir();
+ const addonPath = path.join(nativeDir, 'embedded_mpv.node');
+ writeFileSync(addonPath, '');
+ const loadAddonModule = jest.fn().mockReturnValue(addon);
+
+ Object.assign(service as unknown as Record, {
+ addon: null,
+ addonLoadError: null,
+ loadAddonModule,
+ getAddonCandidatePaths: () => [addonPath],
+ });
+
+ expect(service.getSupport()).toEqual(
+ expect.objectContaining({
+ supported: true,
+ platform: 'linux',
+ })
+ );
+ expect(loadAddonModule).toHaveBeenCalledWith(addonPath);
+ expect(mockSpawnSync).toHaveBeenCalledWith('mpv', ['--version'], {
+ stdio: 'ignore',
+ timeout: 3000,
+ });
+ });
+
it('loads the addon before reporting support capabilities', () => {
const loadAddonModule = jest.fn().mockReturnValue(addon);
Object.assign(service as unknown as Record, {
diff --git a/apps/electron-backend/src/app/services/embedded-mpv-native.service.ts b/apps/electron-backend/src/app/services/embedded-mpv-native.service.ts
index 7b9e9ca08..94a4e5fff 100644
--- a/apps/electron-backend/src/app/services/embedded-mpv-native.service.ts
+++ b/apps/electron-backend/src/app/services/embedded-mpv-native.service.ts
@@ -1,4 +1,5 @@
import { app, dialog, powerSaveBlocker } from 'electron';
+import { spawnSync } from 'child_process';
import {
closeSync,
existsSync,
@@ -96,6 +97,7 @@ export class EmbeddedMpvNativeService {
private readonly pollFailuresLogged = new Set();
private powerBlockerId: number | null = null;
private readonly loadAddonModule = createRequire(__filename);
+ private cachedLinuxMpvExecutableReason: string | null | undefined;
private detectCapabilities(): EmbeddedMpvCapabilities {
const addon = this.addon;
@@ -115,8 +117,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 +125,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 +137,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 +674,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 +775,60 @@ 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;
+ }
+
+ if (this.cachedLinuxMpvExecutableReason !== undefined) {
+ return this.cachedLinuxMpvExecutableReason;
+ }
+
+ const result = spawnSync('mpv', ['--version'], {
+ stdio: 'ignore',
+ timeout: 3000,
+ });
+ this.cachedLinuxMpvExecutableReason =
+ result.status === 0
+ ? null
+ : 'Embedded MPV on Linux requires the mpv executable on PATH. Install the mpv package for your distribution and restart IPTVnator.';
+ return this.cachedLinuxMpvExecutableReason;
+ }
+
+ private isInvalidLinuxWaylandWindowHandle(windowHandle: Buffer): boolean {
+ if (
+ process.platform !== 'linux' ||
+ !process.env.WAYLAND_DISPLAY ||
+ !process.env.DISPLAY
+ ) {
+ return false;
+ }
+
+ if (windowHandle.length === 0) {
+ return false;
+ }
+
+ const windowHandleValue =
+ windowHandle.length >= 4
+ ? windowHandle.readUInt32LE(0)
+ : windowHandle.readUIntLE(0, windowHandle.length);
+ return windowHandleValue <= 1;
+ }
+
private getAddon(): NativeEmbeddedMpvAddon {
if (this.addon) {
return this.addon;
@@ -880,6 +944,10 @@ export class EmbeddedMpvNativeService {
const nativeDir = path.dirname(addonPath);
const runtimeCandidates = this.getRuntimeLibraryCandidates(nativeDir);
+ if (runtimeCandidates.length === 0) {
+ return null;
+ }
+
if (!runtimeCandidates.some((candidate) => existsSync(candidate))) {
return [
'Embedded MPV runtime is incomplete. Missing one of:',
@@ -905,11 +973,7 @@ export class EmbeddedMpvNativeService {
path.join(nativeDir, 'mpv.dll'),
];
case 'linux':
- return [
- path.join(nativeDir, 'lib', 'libmpv.so.2'),
- path.join(nativeDir, 'lib', 'libmpv.so.1'),
- path.join(nativeDir, 'lib', 'libmpv.so'),
- ];
+ return [];
default:
return [];
}
diff --git a/docs/architecture/embedded-inline-playback.md b/docs/architecture/embedded-inline-playback.md
index 514b4e70f..8d53a7051 100644
--- a/docs/architecture/embedded-inline-playback.md
+++ b/docs/architecture/embedded-inline-playback.md
@@ -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=` 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
@@ -195,7 +199,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.
diff --git a/docs/architecture/embedded-mpv-native.md b/docs/architecture/embedded-mpv-native.md
index bf6d38147..b34c10f24 100644
--- a/docs/architecture/embedded-mpv-native.md
+++ b/docs/architecture/embedded-mpv-native.md
@@ -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=` 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= --input-ipc-server=`, polls `time-pos`, `duration`, `volume`, and `pause`, and forwards pause/seek/volume/audio-track commands through that socket. The Linux MPV JSON IPC polling runs on an addon-owned background thread; `getSessionSnapshot()` returns the last cached snapshot and must not perform socket round trips on Electron's main thread. Linux MPV process teardown sends `SIGTERM` on the caller path, then waits and escalates to `SIGKILL` on a detached cleanup thread. 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=` 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. === '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. === '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
@@ -90,13 +109,13 @@ Renderer autoplay must use `ended` only. It must not treat `closed`, `idle`, or
The native addon also observes mpv's `eof-reached` property and maps a true value to `ended`. This is required because embedded sessions run with `keep-open=yes`; MPV can pause at EOF while keeping the file loaded, so relying only on `MPV_EVENT_END_FILE` can leave the renderer in a paused-at-end state and block series autoplay.
-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:
@@ -164,29 +183,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/-/`.
-- 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
@@ -219,9 +239,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/-/` 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:
@@ -284,8 +304,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
diff --git a/electron-builder.json b/electron-builder.json
index f6bc40041..bcfbb6ccc 100644
--- a/electron-builder.json
+++ b/electron-builder.json
@@ -50,6 +50,7 @@
"linux": {
"category": "Video",
"executableName": "iptvnator",
+ "executableArgs": ["--ozone-platform=x11"],
"desktop": {
"entry": {
"StartupWMClass": "iptvnator"
diff --git a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.html b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.html
index 4bc119bec..75945feda 100644
--- a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.html
+++ b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.html
@@ -100,33 +100,35 @@
}}
-
+ @if (showSeriesNavigation()) {
+
-
+
+ }