Files
iptvnator/docs/architecture/embedded-mpv-native.md
T
4gray 8eda5370eb fix(playback): apply themes to player and EPG panels (#1541)
* fix(playback): apply themes to player and EPG panels

* test(playback): verify active recording icon theme

* fix(epg): keep loading shimmer visible in both themes
2026-09-05 15:32:22 +02:00

1289 lines
95 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Embedded MPV Native Integration
This document explains how IPTVnator embeds MPV inside the Electron app, which files are source versus generated build output, and what must be true before the feature is safe to expose to users.
## What To Commit
Source files for the embedded MPV integration:
- `apps/electron-backend/build-embedded-mpv.js` builds the native addon for the target Electron runtime.
- `apps/electron-backend/native/binding.gyp` defines the native addon build.
- `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 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.
- `libs/shared/interfaces/src/lib/embedded-mpv-session.interface.ts` defines the shared session and audio-track contract.
- `libs/ui/playback/src/lib/embedded-mpv-player/` owns the Angular UI and controls.
Frame-copy engine sources (experimental, macOS Apple Silicon, Linux x64, and
Windows — see the "Frame-Copy Engine" section below):
- `apps/electron-backend/native/helper/` — `iptvnator_mpv_helper` process (`mpv_frame_helper.cpp`, `frame_helper_render.h`, `frame_helper_gl.h`, `frame_helper_io.h`, `frame_shm.h`).
- `apps/electron-backend/native/src/embedded_mpv_frame_reader.c` — N-API shm frame reader used by the preload frame pump.
- `apps/electron-backend/src/app/services/embedded-mpv-frame-copy.adapter.ts` — helper-process adapter behind the `NativeEmbeddedMpvAddon` surface.
- `apps/electron-backend/src/app/api/embedded-mpv-frame-pump.ts` — preload frame pump (shm → WebGL canvas).
- `spikes/mpv-frame-copy/` — standalone spike, measurement log (RESULTS.md), integration design (DESIGN.md), and the original analysis (ANALYSIS.md).
Generated native-addon build output:
- `apps/electron-backend/native/build/`
The build directory contains files such as `Makefile`, `binding.Makefile`, `config.gypi`, `embedded_mpv.target.mk`, `gyp-mac-tool`, `embedded_mpv.node`, `.o`, and `.d` files. These are generated by `node-gyp` and must not be committed. The repo `.gitignore` ignores this directory.
## How It Is Embedded
Embedded MPV has two rendering paths. The native-view engine renders into an
app-owned platform video surface: macOS uses the libmpv render API in an
`NSOpenGLView` because the mpv `wid` path produced a black video surface inside
Electron; Windows loads `libmpv` through the native Node addon and uses mpv's
`wid` option against an IPTVnator-owned child `HWND`; Linux creates an
IPTVnator-owned X11/Xwayland child `Window` and starts an out-of-process
`mpv --wid=<window>` instance for that child window. The frame-copy engine
instead uploads helper-produced frames to a Chromium-owned DOM canvas.
Windows packaged runtimes must preserve the MPV DLL basename referenced by the
import library used at native-addon link time. For example, an archive that
ships `libmpv.dll.a` and `libmpv-2.dll` must package `libmpv-2.dll`; renaming it
to `mpv-2.dll` leaves `embedded_mpv.node` with an unresolved DLL dependency at
startup, so the Settings support probe hides the Embedded MPV option. The
frame-copy helper is a separate executable and resolves that same import from
its own directory. Package validation therefore reads the helper's PE import
table and requires the exact referenced MPV DLL beside
`iptvnator_mpv_helper.exe`, not only under `native/lib/`.
On Linux, `embedded_mpv.node` must not link directly to `libmpv` or load libmpv in-process. Electron loads its own `libffmpeg` and Chromium graphics stack; in-process libmpv can resolve FFmpeg/GL symbols against incompatible Electron symbols, while isolated dynamic-loader namespaces introduce thread/runtime ownership problems. The Linux addon therefore owns only the X11 child-window embedding, process lifecycle, and a private MPV JSON IPC socket. It starts `mpv --wid=<window> --input-ipc-server=<socket>`, polls `time-pos`, `duration`, `volume`, and `pause`, and forwards pause/seek/volume/audio-track commands through that socket. 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
Official Linux frame-copy packaging is x64-only. The native-view and frame-copy
engines have different runtime requirements:
| Engine | Display path | MPV runtime |
| ----------- | ----------------------------- | -------------------------------------------------------------------------- |
| native-view | X11 or Xwayland | An `mpv` executable on `PATH`; playback is an isolated `mpv --wid` process |
| frame-copy | Headless EGL; no window embed | A separately linked and capability-probed `iptvnator_mpv_helper` |
Native Wayland embedding is not implemented for native-view. Frame-copy itself
does not embed a window and can render through EGL on a native Wayland desktop,
although packaged launchers still default Electron to X11/Xwayland unless the
user explicitly supplies an Ozone choice. A user-provided `--ozone-platform`
or `ELECTRON_OZONE_PLATFORM_HINT` is never overridden.
Linux packages are built in separate passes because Electron Builder reuses
one unpacked application layout per pass:
| Profile | Formats | Frame-copy runtime strategy |
| ---------- | ---------------- | -------------------------------------------------------------------------------------------------------- |
| `system` | DEB, RPM, Pacman | System `libmpv.so.2` plus the helper's direct EGL/GL/GBM interfaces; exact dependencies are listed below |
| `portable` | AppImage, Snap | Bundled pinned LGPL-compatible runtime under `native/lib` |
| `flatpak` | Flatpak | The same bundled pinned LGPL-compatible runtime under `native/lib` |
System package dependencies are fail-closed and format-specific:
- DEB: `libmpv2`, `libegl1`, `libgl1`, `libgbm1`
- RPM: `mpv-libs`, `libglvnd-egl`, `libglvnd-glx`, `mesa-libgbm`
- Pacman: `mpv`, `libglvnd`, `mesa`
The DEB contract deliberately names `libmpv2`, not a loose `libmpv`
alternative, and explicitly names the GLVND `libGL.so.1` interface because
`libmpv2` does not pull it in for the helper. The helper uses `-lGL`, not
`-lOpenGL`; this matches the graphics interface supplied by distributions and
Snap's `mesa-core22`. Release CI verifies that contract on Ubuntu 24.04
(Noble). Ubuntu 22.04 (Jammy) provides `libmpv1`, so its DEB cannot enable this
system-runtime frame-copy path; use the x64 AppImage there instead.
Every x64 layout contains the addon, frame reader, helper, and a normalized
`embedded-mpv-runtime.json`. The Electron executable, Electron libraries,
`embedded_mpv.node`, and the frame reader must not link libmpv; only the helper
may do so. AppImage, Snap, and Flatpak retain dynamically linked, replaceable
runtime libraries and ship the corresponding source/build metadata. Their
native directory also contains hash-validated `embedded-mpv-notices.json`,
`THIRD_PARTY_NOTICES.txt`, and `licenses/<package>/**`. DEB, RPM, Pacman, and
marker-only packages intentionally contain neither a private `native/lib`
directory nor the bundled-runtime legal payload.
The build-time `electron-backend/native` tree is excluded from `app.asar`.
`afterPack` is the only owner of
`resources/app.asar.unpacked/electron-backend/native`, so each profile receives
exactly its normalized payload and ARM packages cannot retain a hidden x64
helper, runtime, manifest, or notice copy in the archive. Both unpacked-layout
and final Linux artifact verification enumerate `app.asar` and fail if any
entry remains below `/electron-backend/native/`.
The pristine `afterPack` and unpacked-layout checks recursively inspect
Electron-owned shared libraries. An extracted Snap has already overlaid its
package-manager `lib/**` and `usr/lib/**` runtime trees onto that same payload
root, so the post-target verifier excludes exactly those two target-provided
trees while continuing to scan every other directory recursively. Electron
libraries are still required to be regular files and free of libmpv linkage;
Snap runtime symlinks are outside that ownership boundary.
ARM Linux packages remain marker-only. They never borrow x64 native artifacts,
even when build environment variables claim a matching staged architecture.
Consequently frame-copy is not advertised there, and the normal inline/external
players remain available. On x64, any missing or unusable frame-copy dependency
falls back to native-view without crashing; if native-view also lacks X11 or a
system `mpv` executable, Embedded MPV is reported unavailable with a stable
diagnostic reason.
The flow is:
1. Angular receives a `ResolvedPortalPlayback` payload and renders `EmbeddedMpvPlayerComponent`.
2. The component paints a loading state before requesting native startup work.
3. If available, the preload API asks the main process to prepare the embedded MPV addon. This loads `embedded_mpv.node` and its platform runtime files, but does not create a native view or MPV playback session.
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. For the native-view engine, the addon creates an app-owned platform video
host inside the Electron window. For frame-copy, the preload frame pump
attaches the session's shared-memory reader to
`<canvas data-embedded-mpv-frame>`.
8. On macOS native-view, the addon configures `vo=libmpv`, creates a
`mpv_render_context`, and draws into the OpenGL surface. On Windows
native-view, it creates an `mpv_handle`, disables MPV's own OSC/input
handling, and passes the child-window id through `wid`. On Linux
native-view, it starts `mpv --wid=<x11-window>` in a separate process with a
private JSON IPC socket. Frame-copy instead uses the per-session helper
described below.
9. Resize, scroll, fullscreen, and devicePixelRatio changes are measured in
Angular and sent through bounds sync. Native-view uses them to align the
platform host; frame-copy uses them to resize helper rendering and the
canvas frame source.
10. Playback controls remain IPTVnator-owned Angular UI. Frame-copy uses the
shared `app-player-controls` overlay through
`EmbeddedMpvControlsAdapter`; native-view keeps its compositor-safe fixed
dock. MPV receives commands only through the controlled IPC surface.
The renderer never gets direct native-module access. It can only call the preload contract:
- prepare native addon
- create session
- load playback
- set bounds
- play/pause
- seek (absolute target) and seek by (relative step)
- set volume
- set audio track
- start/stop live stream recording
- resolve/select the live recording folder
- dispose session
- subscribe to session updates
Settings uses the preload support API as an availability and capability check.
Unsupported paths return before loading the addon when platform, experiment
gating, native artifacts, the packaged runtime manifest, the Linux helper
probe, or the native-view `mpv` executable check fails. Support diagnostics
include a stable `frameCopyUnavailableReason`; it is tracing/support data, not
user-facing copy. Supported paths load `embedded_mpv.node` so the renderer can
receive capability flags from the actual addon binary. Avoid calling this
support API from global workspace startup paths; use an explicit user action
or idle preparation path when a renderer surface only needs to reveal optional
Embedded MPV UI.
When `embedded-mpv` is the saved player, the settings store schedules an idle `prepareEmbeddedMpv()` call. This intentionally moves the first native addon load away from the click-to-play path. It can still block the Electron main process briefly because Node native addon loading is synchronous, but doing it during idle is less visible than doing it when the user clicks a video. Actual MPV session creation still happens on playback because it needs the current Electron window handle and viewport bounds.
For the native-view engine, the MPV video surface is a platform view/window,
not a normal DOM element. Do not place critical Angular overlays on top of that
video viewport and expect CSS `z-index` to win. Native-view controls use a
compositor-safe dock below the viewport instead of a true overlay.
The native-view dock has a stable reserved height while embedded controls are
enabled. Controls fade in and out inside that fixed dock, so normal show/hide
behavior does not resize the native MPV viewport or make the video jump. Volume
and audio-track panels replace the default transport controls inside the same
dock and provide a back button to return to the default controls. Popovers and
menus must stay inside that dock unless the native layering strategy changes.
The native MPV view deliberately ignores hit testing so mouse movement passes
through to Chromium and can reveal Angular controls even when the pointer moves
quickly across the video area. None of these compositor restrictions applies to
the frame-copy canvas.
## Frame-Copy Engine (Experimental, Apple Silicon, Linux and Windows)
`IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY=1` (on top of the regular
embedded MPV experiment flag) switches macOS/arm64, Linux and Windows to a
second rendering engine that replaces the native-view compositing entirely
(gate: `isFrameCopyPlatformSupported()` in
`embedded-mpv-frame-copy-platform.util.ts`; the adapter imports it directly,
while `main.ts` and the service call it transitively through the same util
module's `isFrameCopyRuntimeUsable()` / `getFrameCopyRuntimeAvailability()`):
- `apps/electron-backend/native/helper/` — `iptvnator_mpv_helper`, a
one-process-per-session libmpv host. It decodes (hwdec), renders
offscreen at viewport size (async PBO readback ring over a headless GL
context — `frame_helper_gl.h`: CGL on macOS; on Linux EGL, acquiring a
display in order surfaceless-Mesa → default display → GBM render node;
every tier must complete config/context/bind validation, hardware rendering
is preferred, and a software tier is retained only as the final fallback;
on Windows WGL against a hidden window, bootstrapping a 3.2 core context
through `wglCreateContextAttribsARB`), publishes BGRA frames into a shm
seqlock ring (`frame_shm.h`, 3 slots, resize creates a new `-g<N>`
generation — POSIX shm on macOS/Linux, a `Local\` named file mapping on
Windows; the protocol carries POSIX-style `/impv-*` names everywhere and
the native sides derive the mapping name), and plays audio directly.
Control protocol: tab-separated commands on stdin, JSON events on stdout;
the `snapshot` event mirrors `NativeEmbeddedMpvSessionSnapshot`. Status
semantics are ported from `embedded_mpv.mm`.
- `apps/electron-backend/src/app/services/embedded-mpv-frame-copy.adapter.ts` —
implements the same `NativeEmbeddedMpvAddon` surface over the helper
process, so `EmbeddedMpvNativeService` (polling, diffing, power blocker,
recording paths) is reused unchanged. The flag routes `getAddon()` to the
adapter and support reports `engine: 'frame-copy'`.
- `apps/electron-backend/native/src/embedded_mpv_frame_reader.c` — N-API
shm reader loaded by the preload frame pump
(`apps/electron-backend/src/app/api/embedded-mpv-frame-pump.ts`): copy
the newest complete frame into a reused ArrayBuffer once per rAF and
upload it to a WebGL2 texture on the renderer's
`<canvas data-embedded-mpv-frame>` (BGRA swizzle in the shader). Frame
copies whose post-copy seqlock check reports a writer race are discarded
without advancing the consumed sequence, so the next rAF retries instead
of uploading partial pixels. Frame data never crosses the contextBridge;
the bridge only exposes
`attachEmbeddedMpvFrameView`/`detachEmbeddedMpvFrameView`.
- Renderer: `EmbeddedMpvPlayerComponent` renders the canvas when
`support.engine === 'frame-copy'` and skips the compositor workarounds —
no `HIDDEN_BOUNDS` when dialogs open; dialogs
and the shared `app-player-controls` overlay stack above the canvas as
ordinary DOM. The canvas fills the player root; the native dock's reserved
controls height is not applied. Legacy embedded-MPV pointer/click,
double-click, shortcut, cursor, menu, and recording-feedback ownership is
disabled for this engine. Bounds sync still runs: the helper re-renders at
the new viewport size (device pixels via the display scale factor), including
a forced current-frame render when a paused resize creates a fresh
shared-memory generation. Shared fullscreen uses the DOM Fullscreen API on
the host-supplied `fullscreenTarget` (the `app-web-player-view` element,
which outlives the per-application remount; the player root is the
fallback), and the component's fullscreen listener still requests bounds
sync.
Enabling it: the `Settings > Playback > Embedded MPV: frame-copy engine`
checkbox (shown when support reports `frameCopyAvailable` or the option is
already enabled, so it stays visible for turning off) persists to
the main-process config store (`electron-conf`), which `main.ts` reads before
creating the window and translates into the env flag; an explicitly set env
var (including `0`) wins over the stored preference, but cannot bypass the
platform/runtime safety gate. Frame-copy can relax the window sandbox only
when embedded MPV itself is enabled for the current run (packaged app or the
regular development experiment flag) and one process-wide capability decision
has succeeded. On Linux x64 that decision validates the profile manifest,
regular-file/access modes, the complete declared bundled closure and hashes,
then runs `iptvnator_mpv_helper --runtime-probe` with a three-second timeout.
That short budget belongs to the application gate alone, because it blocks the
main process and a timeout there degrades to the native-view fallback;
`tools/packaging/verify-linux-frame-copy-runtime.mjs` runs the same probe under
a deliberately larger bound described under "Same-Version Desktop Release Gate".
The probe loads dependencies through the normal ELF loader, initializes an
idle libmpv client, creates EGL/OpenGL plus mpv render contexts, then
creates, maps, validates, and destroys a minimal `16x16` shared-memory ring
named `/impv-fc-runtime-probe-<pid>`. It never opens media or enters the media
or command loops. Shared-memory creation/mapping and header-initialization
failures emit the stable helper reasons `shared-memory-create-failed` and
`shared-memory-initialize-failed`. The probe must emit exactly one protocol-v1
JSON line and return zero. When the helper exits nonzero with an otherwise
exact failure line, the application availability diagnostic keeps the
fail-closed top-level reason `helper-probe-failed` and may add only the
allowlisted helper reason as `helperReason`. An optional `helperDetail` is
copied only from the same exact line when it contains 1–1024 printable ASCII
characters; an invalid detail rejects both helper fields. Malformed,
multi-line, wrong-protocol, or unknown failure output never reaches either
field. Every probe uses the same explicit 16 MiB aggregate captured-output
ceiling, independent of tracing, so verbose diagnostics do not fall back to
Node's smaller implicit buffer. When `IPTVNATOR_TRACE_PLAYER=1`, the probe also
emits non-empty captured helper stderr separately as one JSON line. JSON
escaping keeps embedded newlines on that single line, the `stderr` field is
limited to the first 16,384 characters, and the `truncated` boolean is always
present. A missing flag, empty capture, or trace-writer failure produces no
trace and never changes the cached availability result or the application
diagnostic's stdout protocol.
The startup probe and every playback helper session use the same sanitized
loader environment selected by the validated manifest's cached `runtimeMode`.
Both remove ambient ELF audit/preload/origin/library overrides, direct
EGL/GBM/GL/VA/Vulkan driver and layer paths, shell startup/options, tracing
hooks, and exported Bash functions. The extracted-artifact verifier applies
the same deny-set before its direct helper smoke, while preserving
feature/debug selectors such as `LIBGL_ALWAYS_SOFTWARE`. The system profile
then uses the default system loader without a private path. Bundled profiles
put the validated packaged `native/lib` first. AppImage and Flatpak resolve the
declared external graphics/audio interfaces through their normal host or
sandbox loader.
For the exact `com.fourgray.iptvnator` Flatpak payload under `/app`, the helper
reconstructs Freedesktop Platform 24.08's immutable
`__EGL_EXTERNAL_PLATFORM_CONFIG_DIRS` value:
`/etc/egl/egl_external_platform.d:/usr/lib/x86_64-linux-gnu/GL/egl/egl_external_platform.d:/usr/share/egl/egl_external_platform.d`.
All ambient EGL/GBM/GL/VA/Vulkan path overrides remain removed. Freedesktop's
GL extension `add-ld-path` is supplied through the sandbox loader cache, so no
ambient `LD_LIBRARY_PATH` is needed. Flatpak CI runs the application-level
`--embedded-mpv-runtime-probe`; direct helper execution is only a package
layout check and cannot substitute for the real gate.
The installed-Snap smoke enables `IPTVNATOR_TRACE_PLAYER=1`,
`EGL_LOG_LEVEL=debug`, and `LIBGL_DEBUG=verbose`, so GLVND/Mesa loader failures
remain observable through the bounded stderr record while the same hostile
ambient-path assertions and fail-closed application gate stay active.
Inside a genuine Snap mount, filtered absolute `SNAP_LIBRARY_PATH` entries below
`/var/lib/snapd/lib/gl` follow `native/lib`. The exact
`$SNAP/graphics/usr/lib/x86_64-linux-gnu` content-provider roots come next,
followed by the core22 base `/usr/lib/x86_64-linux-gnu`, then the GNOME
platform's fixed x64 library, Mesa, DRI, and PulseAudio roots only when
`SNAP_DESKTOP_RUNTIME` resolves exactly to `$SNAP/gnome-platform`; generic
`$SNAP` roots remain last. Core22 must precede that older desktop content
runtime so its compatible `libedit.so.2` wins instead of the GNOME copy that
requires unavailable `libtinfo.so.5`. The helper also rebuilds the GBM, GL/VA
driver, EGL vendor/platform, and Vulkan layer variables from those trusted
roots. Caller-provided triplets, graphics-driver paths, and out-of-root loader
entries are ignored.
Both the bounded probe and playback execute the helper through
`$SNAP/graphics/bin/graphics-core22-provider-wrapper`. Before either launch,
the app requires the mounted graphics root to be a real directory and the
wrapper to be a regular, non-symlinked, readable executable. A missing or
disconnected provider therefore reports the stable
`snap-graphics-provider-unavailable` reason instead of attempting a partial
loader setup. Because that provider wrapper is a non-interactive Bash script,
the child environment also removes shell startup/options, exported
`BASH_FUNC_*` functions, and tracing hooks, and fixes `PATH` to core22 system
directories. This prevents ambient shell configuration from replacing the
probe or its `dirname` lookup before the helper executes.
The Snap is `base: core22` with strict confinement. It keeps Electron Builder's
default plugs and adds an auto-connected private `shared-memory` plug plus the
`graphics-core22` content plug targeting a real empty mode-0755
`$SNAP/graphics`, with `mesa-core22` as default provider. `mesa-core22`
supplies the shared
EGL/GL/GLX/GBM/DRM/VA userspace; the existing GNOME content runtime supplies
ALSA/PulseAudio. These providers are external shared snaps, so their binaries,
source, notices, and installed bytes are not part of the IPTVnator Snap or its
compliance archive. CI installs and explicitly connects both providers for a
locally installed `--dangerous` artifact, then runs the application-level
probe under strict confinement.
The package carries the empty content target itself because core22 does not
create `$SNAP` content targets while packing. Metadata verification rejects a
missing, non-directory, symlinked, non-empty, or incorrectly permissioned
target. It also requires exactly the canonical provider-data layouts:
`/usr/share/libdrm` binds from `$SNAP/graphics/libdrm`, and
`/usr/share/drirc.d` symlinks to `$SNAP/graphics/drirc.d`. No additional or
duplicate layout entry is accepted. Static extraction verification also
requires regular `desktop-init.sh`, `desktop-common.sh`, and
`desktop-gnome-specific.sh` files at the Snap root, with `desktop-init.sh`
executable, because Electron Builder's generated `command.sh` invokes that
runtime before the application binary.
Private shared memory gives the app a confined, snap-specific POSIX shm
namespace rather than global cross-snap access. The packaging-only
`--embedded-mpv-runtime-probe` application switch invokes the same complete
manifest, mode, hash, linkage, environment, and bounded helper probe used at
startup before any BrowserWindow is created. It writes exactly one availability
JSON line and exits zero only when frame-copy is usable. Consequently the
installed-Snap smoke validates the actual confinement and shared-memory
lifecycle required by playback, not only direct helper execution. The smoke
first disconnects `graphics-core22` and requires the application diagnostic to
emit `usable:false` with reason `snap-graphics-provider-unavailable` and
controlled exit code `1`; it then reconnects the provider and requires the
same diagnostic to succeed.
Packaged addon, frame-reader, and helper discovery is limited to package-owned
`app.asar.unpacked` resource locations and never falls through to writable
cwd/dist development paths. Those fallbacks are development-only. A disabled
base experiment or any failed capability check keeps the renderer sandbox
enabled and falls back to the native engine. The result is cached by
helper/manifest identity for the process lifetime, so the startup and service
gates cannot disagree.
Changing the toggle requires an app restart because web preferences are fixed
at window creation.
Rendering size: the helper renders at the **aspect-fit** size of the video
(observed `dwidth`/`dheight`) inside the requested viewport and bumps a shm
generation when it changes — letterbox bars are never baked into frames,
frames stay as small as possible, and the canvas letterboxes with a
transparent canvas over a black video viewport in both windowed and
fullscreen mode. Snapshots carry `videoWidth`/`videoHeight`.
`IPTVNATOR_EMBEDDED_MPV_AUDIO_DELAY=<seconds>` passes through to mpv's
`audio-delay` for lip-sync tuning until a calibration flow exists.
Lifecycle safety: `EmbeddedMpvNativeService` watches the main window for
`render-process-gone` and `did-navigate` (full reloads) and disposes every
session — Angular teardown never runs on a renderer crash/hard reload, and
without the watch helper processes (or native mpv handles) would leak until
app shutdown. Unexpected helper exits surface as a session `error`. macOS
and Windows package validation requires the helper
(`iptvnator_mpv_helper` / `iptvnator_mpv_helper.exe`) and
`embedded_mpv_frame_reader.node` next to the addon whenever the addon
ships; on Windows the bundled mpv DLL is also copied beside the helper so
the executable resolves it from its own directory. The after-pack hook
restores the POSIX helper's executable mode after the asset copy, and
optional/skipped native rebuilds remove stale helper/reader artifacts before
reporting frame-copy availability. This cleanup prevents known leftover build
output; the manifest and runtime probe are the compatibility check for a
complete Linux runtime. Linux x64 packages retain the helper and frame reader:
system packages resolve the declared `libmpv.so.2` through their package
manager, while portable and sandboxed profiles resolve the source-built closure
through `$ORIGIN/lib`. Foreign-architecture packages remove all native
artifacts and contain only the unavailable marker.
Trade-offs and constraints:
- The frame-copy experiment flag can relax the BrowserWindow sandbox only
while the base embedded-MPV feature is enabled (preload must
`require` the reader addon); `contextIsolation` and
`nodeIntegration:false` stay on. The sandbox story must be revisited
before this engine can become a default — candidates: utilityProcess +
MessagePort (costs one extra copy + GC churn since Electron ports clone
ArrayBuffers) or a WebCodecs-based path.
- Scope: on macOS Apple Silicon only by owner decision (2026-07-10);
Intel Macs keep the native-view engine. Official Linux frame-copy is x64 —
headless EGL, works under native Wayland since nothing embeds into a
window; local system builds need `libmpv-dev`, `libegl-dev`, `libgl-dev`,
and `libgbm-dev`. The helper links libmpv, which is legal
out-of-process; the in-process-libmpv ban still binds the addon and frame
reader. The helper logs the chosen EGL display tier and the GL renderer
string to stderr. If an early tier selects Mesa software rendering (for
example, while a proprietary NVIDIA driver is reachable through the default
display or GBM), it probes the remaining tiers and uses software only when
no hardware-backed context works. Windows (any arch with a helper, in
practice x64) is ported: WGL renders offscreen against a hidden window,
the shm ring is a session-local named file mapping, and the reader addon
compiles as C++ there (MSVC has no C11 `<stdatomic.h>`). The helper
links the vendored libmpv import library and loads the DLL from its own
directory. Windows 11 Smart App Control blocks unsigned locally-built
executables — turn it off on dev machines or the helper cannot spawn
(the support probe still reports available; the session errors).
Runtime trait, not frame-copy-specific: the vendored mpv-winbuild
libmpv routes http(s) through mpv's curl stream backend and ships no CA
bundle, so https streams currently fail TLS verification (`mpv/curl`
errors in the helper log); the in-process native engine links the same
DLL and shares the trait. Resolving the CA story belongs to Windows
runtime packaging, not to either engine.
- Measured baseline (M1 Pro, spikes/mpv-frame-copy/RESULTS.md): 4K60 HEVC
sustained end to end, ~1.2 ms shm copy + ~3.5 ms texture upload, ~10 ms
produce-to-upload latency, zero torn frames over a 10-minute run.
Linux (i7-1165G7/Iris Xe, same RESULTS.md): 1080p60 sustained with
~1.2 ms copies; the 4K rows are limited by software decode/source
generation on that hardware, not by the copy path; zero torn frames
everywhere.
- Helper crash isolation: an unexpected helper exit surfaces as a session
`error` (renderer falls back); it can never take down the Electron main
process, unlike in-process libmpv.
## Resume And Track Handling
`ResolvedPortalPlayback.startTime` is treated as a media offset in seconds for VOD and episodes. The native addon passes it as the `start` option in one MPV `loadfile` options map together with title, user agent, referrer, and HTTP headers.
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`.
Seeking has two IPC shapes. The timeline scrub commits one absolute target (`seekEmbeddedMpv` → mpv `seek <t> absolute`). Arrow-key and ±10 s button steps go through `seekEmbeddedMpvBy`, which every backend forwards as a relative mpv seek (`seek <delta> relative+exact`): the macOS and Windows addons via their `seekBy` export, the frame-copy helper via the `seek-by\tseconds=<delta>` stdin command, and Linux over the MPV JSON IPC socket. The renderer must never derive an absolute target for a step from `session.positionSeconds`: that value is floored to whole seconds and refreshed at most every 500 ms (the helper emits snapshots at most every 250 ms), and a seek reply does not carry the new position yet, so every press inside that window landed on the same target and a burst of presses advanced by roughly one second each. mpv resolves relative seeks against its own position and merges the ones still queued, so presses accumulate exactly as they do in mpv itself. `EmbeddedMpvNativeService.seekBy` keeps an absolute fallback computed from the addon's own snapshot only for an addon binary built before `seekBy` existed, and `EmbeddedMpvCommandRunner.seekBy` keeps the same fallback for a preload without `seekEmbeddedMpvBy`. Unlike the absolute seek, a relative step never speculates about the resulting position in the snapshot: only mpv's observed `time-pos` updates it, because an optimistic `position + delta` could land on top of an observer write that already reflects the completed seek and count the step twice, with nothing to correct it while paused (on Linux it would also advertise a position that a failed socket delivery never reached). The packaged Linux frame-copy smoke (`electron-backend-e2e:packaged-frame-copy-smoke`) drives a burst of `seekEmbeddedMpvBy` calls through the built app and asserts the accumulated position.
Audio tracks are discovered from MPV's `track-list` property. The selected track is controlled through MPV's `aid` property. Switching tracks must not reload the stream.
Subtitle tracks mirror the audio-track contract: same `track-list` source, same parsing pipeline, but selected through MPV's `sid` property. A `trackId` of `-1` from the renderer is interpreted as "disable subtitles" and translated to `sid=no` at the addon boundary. Playback speed is observed and set through MPV's `speed` property, clamped at the addon to `[0.25, 4.0]`. Aspect override uses MPV's `video-aspect-override` property as a passthrough string ("no", "16:9", "4:3", "21:9", "2.35:1"). All four properties (`sid`, `speed`, `video-aspect-override`, plus `aid`) are observed at session init so renderer state stays in sync with the native side without needing extra round-trips.
The renderer learns which features the loaded addon binary supports through the `EmbeddedMpvSupport.capabilities` field returned from `getEmbeddedMpvSupport()`. The service probes `typeof addon.<method> === 'function'` for each optional native export. Older addon binaries with the original audio-only surface return `capabilities: { subtitles: false, playbackSpeed: false, aspectOverride: false, screenshot: false, recording: false }`, and the renderer hides the corresponding controls instead of throwing at runtime. Linux intentionally does not export libmpv-only optional controls while it uses the process-isolated `mpv --wid` backend.
Linux audio-track discovery works differently from macOS/Windows because the hand-rolled JSON IPC reply parser only understands scalar `data` values: the poll loop reads `track-list/count` every tick and walks the scalar `track-list/N/{type,id,title,lang,default,forced}` sub-properties only when the count changes. The selected track is reconciled from the scalar `aid` property on every tick (`aid` reads back non-numeric when audio is disabled, which maps to "no selection"). Track switching still goes through `set_property aid` over the same socket.
## Session End And Series Navigation
`EmbeddedMpvSessionStatus` includes `ended` for successful EOF only. The native addon maps `MPV_EVENT_END_FILE` to:
- `ended` when the end-file reason is `MPV_END_FILE_REASON_EOF`
- `error` when MPV reports an end-file error
- `loading` when MPV reports `MPV_END_FILE_REASON_REDIRECT`, because playback continues with the redirected playlist contents
- `idle` for other successful end-file reasons such as replacement/stop
- `closed` only for dispose/manual teardown
Renderer autoplay must use `ended` only. It must not treat `closed`, `idle`, or `error` as a request to continue to the next episode.
Async command/property replies are reconciled against pending request IDs on all platforms: only a failed `loadfile` reply (or a recording start/stop reply) may change the session status. A rejected seek, `aid`, or `speed` reply on a live stream records `snapshot.error` but must not flip a playing session to `error`, because playback continues.
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`. Frame-copy
projects that state through `EmbeddedMpvControlsAdapter` and emits the shared
controls' previous/next outputs; native-view keeps the equivalent buttons in
its legacy dock. Both paths show navigation only for non-live series playback.
The navigation payload contains `canPrevious`, `canNext`, and
`autoplayEnabled`; the component guards the output handlers as well as the
button disabled state at current-season boundaries.
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.
## Session Options (Extra libmpv Options)
`Settings > Playback > Extra embedded MPV options` is a free-form textarea,
one `key=value` per line without the leading `--`. The renderer keeps the
canonical text (`normalizeEmbeddedMpvExtraOptions`) and refuses to save
malformed lines or the keys the embed depends on
(`EMBEDDED_MPV_FORBIDDEN_OPTION_KEYS`: `wid`, `vo`, `force-window`,
`input-ipc-server`, `idle`, `keep-open`, `config`, `include`, `terminal`);
the `SETTINGS_UPDATE` handler mirrors the text into the main-process config
(`EMBEDDED_MPV_EXTRA_OPTIONS`), because sessions are created there.
At session creation the IPC handler reads the mirror through
`readEmbeddedMpvSessionOptions()` and passes
`resolveEmbeddedMpvSessionOptionArguments()` to
`EmbeddedMpvNativeService.createSession(..., options)`. That list is the
network defaults followed by the user's allowed lines, and every engine
applies it after its own built-in options and before `mpv_initialize`, so a
user line overrides both:
| Engine | Transport |
| ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| Windows native-view | `createSession(..., string[])` → `mpv_set_option_string` |
| macOS native-view | same, in `embedded_mpv.mm` |
| Linux native-view | written to a user-only (0600) config file under `userData/embedded-mpv/options-<pid>/` and referenced as `--include=<path>` on the `mpv --wid` command line (before the per-playback options, so a playlist's user-agent/headers still win); the file is removed on dispose, the instance directory on shutdown, and another instance's leftovers only once its process is gone (two instances may share one `userData`) |
| Frame-copy helper | the first stdin line (`mpv-options`, helper started with `--mpv-options-stdin`), applied after the helper's built-in block |
The helper's own built-in block is `vo=libmpv`, the session's `hwdec`,
`keep-open=yes`, `idle=yes`, `input-default-bindings=no`, `osc=no` and
`ytdl=no`. The last one keeps parity with the native-view addons and the
external MPV launch path (`--ytdl=no` in `mpv-session.service.ts`): with
mpv's youtube-dl hook enabled, a refused HTTP open falls through to yt-dlp
before it fails, which delays the `error` transition and can leave the
session error reading `youtube-dl failed: unexpected error occurred`
instead of the load error (the helper stores every error-level libmpv log
line as the snapshot error, so whichever line lands last wins). A user
`ytdl=yes` line overrides it like every other built-in.
The list never appears on a command line: an option such as
`http-header-fields=Authorization: …` would otherwise be readable by every
local user through `ps` / `/proc/<pid>/cmdline`.
`EMBEDDED_MPV_NETWORK_DEFAULT_OPTIONS` (`network-timeout=10`,
`demuxer-lavf-o=reconnect=1,reconnect_streamed=1,reconnect_delay_max=5`) are
part of that list on purpose: a stalled IPTV connection then surfaces as an
mpv error within seconds instead of libmpv's 60 s default, and ffmpeg
re-requests a dropped HTTP stream on its own before the app-level reconnect
has to. Options mpv rejects never fail session creation, and a rejection is
reported without any trace flag, by key only (a value may carry credentials):
the Windows and macOS native-view engines collect the refused keys into the
snapshot's `rejectedOptionKeys`, which the service logs once per session as a
main-process warning; the frame-copy helper emits a `log` warn event with
prefix `iptvnator` that the adapter forwards unconditionally. The Linux
native-view path is the exception: mpv skips a bad `--include` line and
continues, but reports it only on its own stderr, which the addon discards
(`--really-quiet`; `IPTVNATOR_TRACE_EMBEDDED_MPV` writes it to
`/tmp/iptvnator-embedded-mpv.log`). The service itself never
touches the config store — it is constructed at module load, and importing it
would drag electron-conf into every consumer of the service, its unit tests
included.
## Network Auto-Reconnect
`EmbeddedMpvReconnectCoordinator`
(`apps/electron-backend/src/app/services/embedded-mpv-reconnect.ts`) reloads
the last user-requested playback when a session reports a stream loss: an
`error` status, or `ended` while the playback is live (`isLive`, else "no
`contentInfo`", the same rule the renderer uses). It is driven by
`refreshSession()` status transitions, so it covers every engine, and it is
captured per session from `Settings.embeddedMpvAutoReconnect` (default on;
mirrored as `EMBEDDED_MPV_AUTO_RECONNECT`). The policy is deliberately narrow:
- Only a load that already reached `playing` is retried. A URL that never
worked (404, refused credentials, unsupported container) keeps the manual
Retry instead of hammering the panel six times with the same request. The
Linux `mpv --wid` path keeps a freshly spawned process at `loading` until
its poller sees a decoded `time-pos`, so a URL that never opens is never
counted as played there either; the poller also reads `eof-reached` over
the IPC socket so a stream that ended reports `ended` rather than the
`paused` that keep-open would otherwise look like, and an mpv process that
exits abnormally reports `error`.
- Backoff is 2 s, 4 s, 8 s, 16 s, 30 s, 30 s, at most six attempts per
outage. The budget resets only after 30 s of uninterrupted `playing`, so a
stream that flaps every few seconds runs out of attempts instead of being
retried forever.
- A user-driven `loadPlayback`, `paused`/`idle`, dispose, and shutdown cancel
a pending attempt; a stream that recovers on its own (ffmpeg-level
reconnect) while a retry is pending drops the retry. A pause also disarms
the policy until the stream plays again, so a drop while the user has the
stream paused shows the error instead of resuming playback unasked.
- Each attempt is tracked from the moment its reload is issued, so a reload
that fails before the 500 ms poll ever observes it `loading` still counts
as a failed attempt and schedules the next one.
- An `error` the engine attributes to itself — `errorOrigin: 'engine'` in
the native snapshot: a fatal libmpv log in the frame-copy helper, a dead
helper, a macOS render-context failure — is not a stream loss. Reloading
media cannot repair a broken engine, so it stays the terminal error with
Retry; only stream-side errors (load and `END_FILE` failures, the Linux
process exiting) are retried.
- A reload the engine cannot take at all — a frame-copy helper that has
exited (exit code, signal death, or a closed stdin), reported by the
adapter as `EmbeddedMpvSessionGoneError` — ends the
reconnect immediately: only a new session can recover, and the renderer's
Retry creates one, so the actionable error is shown instead of a reconnect
spinner that nothing could ever advance.
- Any other refused reload (the addon throws) continues the backoff itself,
because no status transition will ever arrive for it.
- A non-live reload carries the last position observed while playing as
`startTime` (seeded from the playback's own `startTime`), so a movie or
episode resumes where the connection dropped instead of at the offset the
user originally resumed from; a reported zero counts only after a positive
position was seen, because engine snapshots start at zero before the first
`time-pos`. Live reloads go back to the live edge.
- Every reload is followed by `setPaused(false)`: sessions run with
`keep-open=yes`, so EOF leaves mpv paused at the end of the old file and a
plain `loadfile` inherits that pause — the reloaded live stream would
buffer, report `paused` and never play. The engines apply that pause
toggle optimistically only while already `playing`/`paused`, so the
reload stays `loading` until the media actually opens and the attempt
indicator is not cleared early.
Every engine flips to `loading` synchronously on a load (the frame-copy
adapter does so optimistically before the helper confirms), which is what
lets a failed attempt always be observed as `loading` → loss and never as
loss → loss. While an attempt is scheduled or in flight the session carries
`EmbeddedMpvSession.reconnect` (`attempt`, `maxAttempts`, `nextAttemptAt`);
the renderer shows "Reconnecting… attempt N of M" in the error overlay and,
for shared controls, as a `loading` status message, keeps Retry available
(it disposes the session, which cancels the timer), and never schedules
anything itself. A live stream that sits at `ended` with no reconnect in
progress (the budget is spent, or the setting is off) is shown as "The live
stream ended." with Retry — for shared controls as an `error` status — since
the plain `ended` state otherwise looks like a paused player. A recording that
was running when the stream dropped is finalized by the recording tracker as
an interrupted partial at the moment the reload replaces the stream (which
stops `stream-record`; the tracker's `onRecordingInterrupted` keeps that
verdict even if the engine shows the old recording as active for one more
tick), and once the reload plays the service starts it again into a fresh
file with the same folder, title and metadata, so the manager lists both
parts. A stream that recovers by itself before the reload fires keeps its
recording running untouched; an explicit stop or a user-driven load never
restarts anything. An external subtitle file added through `sub-add` is
dropped by the reload as well (the engines clear their tracks on
`START_FILE`), so the service remembers the last added file and re-adds it
once the reload plays — the helper's `sub-add` selects the added track, and
`sub-delay` is an mpv-global property that survives on its own. An explicit
subtitle-track pick after the add, a user-driven load or a missing file hand
the selection back to the user and restore nothing.
## Live Stream Recording
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:
- `startEmbeddedMpvRecording(sessionId, { directory, title })` resolves a unique `.ts` filename in the requested directory and calls the native addon's `startRecording(sessionId, targetPath)`.
- `stopEmbeddedMpvRecording(sessionId)` calls the native addon's `stopRecording(sessionId)`.
- The native addon sets mpv's `stream-record` property to the target path on start and to an empty value on stop.
- Loading a replacement stream or disposing the embedded session stops any active recording before the MPV handle is reused or destroyed.
- `EmbeddedMpvSession.recording` carries `{ active, targetPath, startedAt, error }` so the renderer can show active elapsed time, final save path, or a failure.
For frame-copy shared controls, recording command completion and session
snapshot delivery are independent asynchronous signals. The component-scoped
recording coordinator permits one pending toggle, ignores the pre-command
baseline snapshot, and correlates only fresh observations from the same
playback identity and session. It waits for command settlement and the expected
active state before completing a successful transition, preserves addon error
text, and uses a bounded failure timeout. Playback replacement, session
replacement, engine handoff, or component destruction cancels pending
ownership, timers, and stale feedback. Native-view retains its existing
recording UI and timer path.
The default recording folder is `app.getPath('downloads')`, matching the desktop download manager's fallback. Users can override it in Settings through `Settings.recordingFolder`; an empty setting means system Downloads. Recordings never enter the Downloads queue (MPV writes from the active playback session while the download manager owns independent backend download jobs), but their lifecycle IS tracked: `EmbeddedMpvRecordingTracker` (`apps/electron-backend/src/app/services/embedded-mpv-recording-tracker.ts`) persists each recording into the dedicated `recordings` SQLite table — start hook and explicit-stop hook from `EmbeddedMpvNativeService`, plus a session-snapshot observer that catches the three implicit stop paths (stream-replacement auto-stop, frame-copy helper crash leaving `active: true` behind, session error/close). Rows left in `recording` by a hard app kill are repaired at startup into playable `interrupted` partials or `failed`. The download manager surfaces these rows; see `docs/architecture/download-manager.md` ("Live-TV recordings").
mpv's own caveats apply: the output container is inferred from the target extension, and seeking or switching streams while recording can produce broken output. IPTVnator limits the UI to live streams and stops recording on playback replacement to avoid the most obvious corruption path, but the feature should still be treated as an experimental embedded MPV capability.
## Renderer Architecture And Reactivity
The Angular side of the embedded MPV player is intentionally split so the
player component stays a view-oriented orchestrator and engine-specific
controls host. The renderer files live under
`libs/ui/playback/src/lib/embedded-mpv-player/`:
- `embedded-mpv-format.utils.ts` — pure helpers (`formatTime`, `audioTrackLabel`, `subtitleTrackLabel`, `speedLabel`, `aspectLabel`, `volumeIcon`, `volumeLabel`, `readStoredVolume`, `persistVolume`, `measureBounds`) and preset constants (`SPEED_PRESETS`, `ASPECT_PRESETS`, `HIDDEN_BOUNDS`).
- `embedded-mpv-controls.adapter.ts` — component-scoped `PlayerController`
adapter for frame-copy. Maps session/support/playback signals to shared
controls state and capabilities, delegates commands to
`EmbeddedMpvSessionController`, and projects series navigation and recording.
- `embedded-mpv-controls-recording.ts` — frame-copy shared-controls recording
coordinator. Serializes toggles, correlates command settlement with fresh
same-owner snapshots, and cancels pending state on ownership or engine
changes.
- `embedded-mpv-controls-recording-feedback.ts` — semantic raw/translated
recording feedback values and late translation resolution.
- `embedded-mpv-controls-recording-timers.ts` — frame-copy recording feedback
signal plus acknowledgement and message-dismiss timer ownership. Keeps
transient timing lifecycle out of the recording correlation state machine.
- `embedded-mpv-legacy-interactions.ts` — native-view-only pointer listeners,
click/double-click arbitration, popover closing, controls auto-hide, and
volume-close timers. An engine handoff cancels its pending legacy
interactions before frame-copy takes ownership.
- `embedded-mpv-shortcuts.ts` — native-view-only `EmbeddedMpvShortcuts` class
with `attach(handlers)` / `detach()`. Owns the legacy document keydown
listener and routes through a callback interface; the component supplies
callbacks for Space/K, F, arrow keys, M, and Escape. The optional
`arrowKeysBlocked` handler suspends the seek/volume arrows while a dock
chip panel owns them for chip navigation.
- `embedded-mpv-overlay-visibility.service.ts` — singleton service that exposes
`overlayActive: signal<boolean>`. Tracks `MatDialog.afterOpened`/
`afterAllClosed` for dialog-shaped overlays and falls back to a
`MutationObserver` on the CDK overlay container for remaining
backdrop-bearing CDK overlays. Native-view uses it to move the platform host
off-screen; frame-copy uses it to gate shared playback shortcuts.
- `embedded-mpv-ui-state.ts` — legacy native-view
`EmbeddedMpvMenuState` (single-open menu state machine, incl. the
`dockPanelOpen` chip-panel signal that suspends arrow shortcuts) and
`EmbeddedMpvFeedback` (transient keypress feedback). They are not the
frame-copy shared-controls state.
- `embedded-mpv-dock-panels.ts` — native-view `EmbeddedMpvDockPanelState`:
builds the active horizontal chip-panel view model (audio, subtitle, speed,
aspect) from the menu state, routes chip selection back to the session
controller, and restores toggle-button focus after a panel closes.
- `embedded-mpv-dock-panel.component.ts` — standalone
`app-embedded-mpv-dock-panel` that morphs the dock row inside the
fixed-height controls strip: back button + title + horizontally scrollable
chip ribbon (`role="menu"` with `aria-orientation="horizontal"`,
`menuitemradio` chips, wheel-to-horizontal-scroll mapping, edge fades,
active-chip reveal/focus, roving arrow keys, RTL-aware). Keeping the panels
inside the strip is what lets menus open without any MPV bounds change.
- `embedded-mpv-command-runner.ts` — transport/track/recording IPC delegation; contains addon-side throws; reconciles a returned snapshot only when the current canonical session id and returned snapshot id both match the captured command session id.
- `embedded-mpv-session-factory.ts` — side-effect-free loading/error placeholder factories plus `waitForStartupPaint`.
- `embedded-mpv-stalled-tracker.ts` — owns the 30-second loading timer and `stalled` signal.
- `embedded-mpv-session-controller.ts` — component-scoped lifecycle coordinator. It exposes `support`, `session`, `sessionId`, `stalled`, and `retryToken`; subscribes to native updates; coordinates prepare/create/load/dispose, frame-copy attachment, and bounds sync; and delegates commands, placeholders, and stalled timing.
- `embedded-mpv-player.component.ts` — view shell and engine-specific controls
host. It mounts shared controls only for frame-copy and the legacy dock only
for native-view; holds view children, derived `computed` signals, DOM event
handling for fullscreen, and effects for session lifecycle, bounds sync,
session fan-out, playback-ended emission, engine handoff, and native-only
recording ticks.
### Bounds compositing strategy
The following strategy applies only to the native-view engine. Its video
host paints outside the normal DOM stacking model, so any DOM region it covers
cannot reliably receive pointer events and any CSS `z-index` competition is
unwinnable. The component compensates with a single `boundsProvider(host)`
closure on the controller that returns one of two bound shapes, evaluated
each time the active bounds-sync runs:
- **Modal overlay open** (any MatDialog, including the command palette) → `HIDDEN_BOUNDS`. The MPV video host moves off-screen so the dialog has the full window.
- **Otherwise** → full host bounds.
Control menus never influence bounds: all five (volume, audio, subtitle,
speed, aspect) render horizontally inside the fixed-height controls strip
below the video host. Volume expands as an inline horizontal slider next to
the mute button; the audio/subtitle/speed/aspect menus morph the dock row
into `app-embedded-mpv-dock-panel` — back button, panel title, and a
horizontally scrollable chip ribbon (vertical wheel mapped to horizontal
scroll, edge fades as continuation hints, auto-reveal and focus of the active
chip, roving arrow-key navigation, RTL-aware). Because the strip height never
changes, opening or closing a menu sends no new MPV bounds and the video never
re-letterboxes. The popover-era 300 px bottom cutout
(`MENU_OPEN_BOTTOM_CUTOUT_PX`) is gone; while a chip panel is open, the
global arrow-key shortcuts (seek/volume) are suspended so arrows walk the
chips instead.
The viewport DOM element reserves `--embedded-mpv-controls-height` (64 px;
88 px under the narrow breakpoint) at the bottom when controls are enabled, so
the controls strip — including the in-dock panels — is always DOM and always
reachable for hover-to-reveal.
For frame-copy, `boundsProvider` always returns the measured full host bounds:
there is no `HIDDEN_BOUNDS` or reserved dock height. Dialogs
and controls layer naturally over the canvas, while bounds sync still updates
the helper's render size.
### Coordinate spaces (CSS → native units)
The renderer measures bounds in CSS pixels (`getBoundingClientRect()`), but
the native-view engines position OS windows, not DOM nodes: the win32 child
`HWND` (`SetWindowPos`) and the Linux child X11 window (`XMoveResizeWindow`)
live in physical pixels, and the macOS `NSView` (`setFrame`) lives in points
(device-independent pixels). CSS values match points only at 100% page zoom
and match physical pixels only at 100% page zoom AND 100% display scale.
`EmbeddedMpvNativeService` therefore converts every native-view bounds payload
in the main process (`toNativeViewBounds` in `embedded-mpv-bounds.util.ts`):
all platforms scale by the webContents zoom factor, win32/linux additionally
by the scale factor of the display hosting the window. The renderer sends
unrounded CSS edges (`measureBounds` does not round) and the conversion
rounds exactly once, after scaling — edges first, width/height derived from
them — so fractional CSS layouts and fractional scales cannot open 1px
seams against the surrounding DOM UI. Skipping this conversion is issue #1145: on scaled
displays (Windows 125%, Linux fractional scaling, HiDPI TVs) the video landed
toward the window's top-left corner at `1/scale` of its size, in windowed and
fullscreen mode alike.
Frame-copy bounds bypass the conversion: the canvas is laid out by the DOM in
CSS pixels, and the frame-copy adapter already multiplies the render size by
the display scale factor itself.
Because a monitor change can rescale this mapping without resizing the host
element (moving the window to a display with a different scale keeps the DIP
layout), the session controller also watches `devicePixelRatio` through a
re-armed `matchMedia('(resolution: …dppx)')` query and re-syncs bounds when
it changes; page zoom changes are covered by the same watch plus the ordinary
resize-driven syncs.
An ancestor re-layout can also translate the host **without resizing it** —
the channel sidebar's content settling, panels loading below the player.
`ResizeObserver` reports size changes only and no DOM event observes
"position changed", so before #1428 the native child window silently kept its
stale coordinates and rendered offset from the DOM stage. The session
controller therefore polls the host bounds every 500 ms while a session is
active, compares them against the last synced bounds with a half-pixel
tolerance, and schedules a re-sync only on drift — idle cost is one
`getBoundingClientRect` per tick with no IPC. The interval is registered
outside Angular's zone (a zone timer would run app-wide change detection
every tick for the whole stream) and never re-enters it, because the drift
path is rAF → `setEmbeddedMpvBounds` IPC and touches no Angular state.
Frame-copy skips the measurement entirely: its canvas is laid out by the
DOM and moves with the layout, so only the native-view child window can go
stale on a position-only shift.
### Controls ownership by engine
`EmbeddedMpvPlayerComponent` selects one control owner from
`support.engine`:
- **Frame-copy** mounts `app-player-controls` with the component-scoped
`EmbeddedMpvControlsAdapter`. The shared layer owns surface pointer/click/
double-click behavior, document playback shortcuts, cursor hiding, menus,
recording feedback, and DOM fullscreen. Setting `showControls` to false
detaches the shared surface and playback shortcuts. A modal/backdrop overlay
disables shared playback shortcuts.
- **Native-view** mounts the legacy fixed dock. Its existing component
handlers, `EmbeddedMpvShortcuts`, menu state, cursor logic, recording
feedback, and recording elapsed timer stay authoritative. The shared
recording adapter is inert outside frame-copy.
An engine handoff asks `EmbeddedMpvLegacyInteractions` to clear native
controls-hide/click/volume timers and close native menus when frame-copy takes
ownership. Legacy feedback is cleared on each engine transition and its overlay
is never rendered for frame-copy, so a late native command completion cannot
paint above shared controls. The handoff also changes the shared recording
owner, which cancels pending operations, acknowledgement/message timers, and
feedback. This prevents both systems from acting on the same session.
Both engines keep the component's `fullscreenchange` listener because
fullscreen changes require bounds sync; both resolve the fullscreen owner as
`fullscreenTarget ?? playerRoot` and read its state once on mount, so a player
remounted for the next episode inside an active fullscreen starts fullscreen
without waiting for an event. Frame-copy's shared `ControlsFullscreen`
additionally synchronizes when its DOM surface attaches or changes, including
when the owner is already fullscreen. Native-view does not
gain a transparent-window, native-fullscreen, or native-surface overlay path
from this integration.
### Reactivity rules (signals and effects)
A signal read inside an `effect()` becomes a tracked dependency and re-runs the entire effect on change. The cleanup-then-rebuild pattern that lives in `effect((onCleanup) => { ... })` is catastrophic for stateful resources like MPV sessions — every dependency change disposes the active session and creates a new one, restarting playback.
Defensive practice for this component:
> Any signal read inside an effect that is used as **input to a one-shot side effect** (write a value, emit an event, schedule a timer, pass an initial argument) must be wrapped in `untracked()`. Only signals whose change is supposed to trigger a re-run go in the tracked block.
Concrete bugs from the audit, recorded so they don't get reintroduced:
- **Infinite session-create loop.** `EmbeddedMpvSessionController.startSession` once wrote `this.support.set(prepared)` after the `prepareEmbeddedMpv` round-trip. The component's session-creation effect tracks `this.support()`, so the write fired the effect → cleanup disposed the session → new session was created → prepare ran again → support was set again. Symptom: endless "Loading stream…" spinner. Fix: do not write `support` inside `startSession`; the constructor's `loadSupport()` already populates it including capabilities.
- **Stream restart on volume change.** The session-creation effect once read `this.volume()` directly to pass to `startSession`'s `initialVolume`. Each volume tick re-ran the effect, disposing and recreating the session — for VOD/series this restarted playback from the beginning. Fix: read it via `untracked(() => this.volume())`. Subsequent volume changes flow through `controller.applyVolume()`, never through the effect graph.
- **Spurious `timeUpdate` re-emits and `volume.set` calls.** The session-fan-out effect calls `scheduleControlsHide()`, which reads `isPlaying`, `menus.anyOpen`, `statusLabel`, and `controlsVisible`. Those reads became tracked deps, so opening any popover, pausing, or hovering re-ran the body. No loop in isolation, but a parent that wires `timeUpdate` back into `playback.startTime` would have hit the volume-restart bug class. Fix: wrap the side-effect block in `untracked()` so the effect listens only to session changes.
- **2 Hz no-op stalled-tracker re-runs.** Position polling updates `session` around 2 Hz. Tracking the full session would re-run stalled logic for snapshots with unchanged status, so the controller tracks only `sessionStatus` and invokes `EmbeddedMpvStalledTracker.track` inside `untracked()`, avoiding full-session reruns.
When adding a new effect, audit it the same way: list every tracked signal read explicitly, justify each one as a _re-trigger source_, and wrap everything else in `untracked()`. When extending an existing helper that is called from inside an effect, treat the helper's signal reads as if they were inline in the effect.
### IPC safety
Renderer command IPC in `EmbeddedMpvCommandRunner` uses the canonical `sessionId()` signal as the gate, **not** `session()?.id`. The session payload during the loading window carries a placeholder id (`embedded-mpv-starting`) set by `createLoadingSession()`; pushing that placeholder to the addon would hit `getSessionOrThrow` for a session that does not exist. The native side throws `Napi::Error` rather than `std::runtime_error` so that misuse surfaces as a JS exception rather than a process abort, but the renderer should still gate properly so the addon never sees the placeholder.
- **Playback-load teardown.** If teardown happens while `loadEmbeddedMpvPlayback` is in flight, the asynchronous startup task exits immediately after the load resolves, before frame attachment or bounds scheduling. The teardown path owns disposal of that session.
- **Command identity.** Renderer commands capture the canonical `sessionId` before starting IPC. Default recording-folder resolution is asynchronous preflight before the recording-command IPC. The runner revalidates the captured id immediately after that preflight and skips IPC when it no longer matches, preventing a late recording command from being issued against the superseded session.
- **Reply reconciliation.** `EmbeddedMpvCommandRunner` applies a returned snapshot only when both the current canonical `sessionId` and the returned snapshot id match the captured command session id. Late or mismatched replies are ignored. Its `guardIpc` helper contains addon-side errors, and the next broadcast session update resynchronizes state.
- **Frame-attachment teardown.** If teardown happens during asynchronous frame attachment, the asynchronous startup task exits immediately after the attachment await, before bounds scheduling. The preload detach/attachment epochs abort pending frame setup, so the teardown's detach is not followed by a second late global detach.
### Power management
The Electron main process holds an `electron.powerSaveBlocker` of type `prevent-display-sleep` whenever any embedded MPV session has status `playing`. Released on pause, EOF (`ended`), dispose, or shutdown. Necessary because libmpv-rendered video does not own the windowing surface, so MPV's own screensaver inhibition does not apply. See `EmbeddedMpvNativeService.updatePowerBlocker()` for the implementation.
## Packaging State
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 staged inputs at `vendor/embedded-mpv/<platform>-<arch>/`. On Linux, local development can fall back to distribution `libmpv-dev` headers and libraries. `LIBMPV_INCLUDE_DIR` overrides the header root. `LINUX_NATIVE_LIBRARY_DIR` is a link-time override and must name a directory already visible to the system dynamic loader; it is never inherited as helper `LD_LIBRARY_PATH`.
- When the staged-input path is used, it must contain `include/mpv/client.h`,
`runtime-manifest.json`, and the platform runtime/build files. The Linux
source builder also stages the complete declared `.so` closure.
- 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 the staged
`mpv-2.dll`/`libmpv-2.dll`/`mpv.dll`/`libmpv.dll` runtime name plus import
libraries. Linux source-runtime builds copy only the manifest-declared
closure.
- Linux never bundles or loads libmpv in the Electron process. The native-view
addon remains X11/process-only; the inverse rule applies to frame-copy:
`iptvnator_mpv_helper` must link exactly the declared `libmpv.so.2`, while
the addon and frame reader must not.
- `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.
- Electron Builder excludes `electron-backend/native{,/**/*}` from `app.asar`;
package verification rejects any archived native entry so `afterPack`
remains the single profile-aware owner.
Linux release profiles:
- `IPTVNATOR_LINUX_FRAME_COPY_PROFILE=system` builds DEB, RPM, and Pacman.
`afterPack` removes the private `lib` directory, writes a
`system-libmpv-frame-copy` manifest, and package metadata requires the exact
libmpv plus EGL/GL/GBM package set listed above. The DEB path is verified
on Ubuntu 24.04+; Ubuntu 22.04 users need the x64 AppImage because Jammy only
provides `libmpv1`.
- `IPTVNATOR_LINUX_FRAME_COPY_PROFILE=portable` builds AppImage and Snap with
the pinned source-built closure and a `bundled-lgpl-frame-copy` manifest.
- `IPTVNATOR_LINUX_FRAME_COPY_PROFILE=flatpak` builds Flatpak with the same
source-built closure and manifest origin. Its app-level probe reconstructs
only the exact Freedesktop 24.08 EGL external-platform search path inside the
trusted `/app` payload.
- Flatpak is an isolated packaging pass and keeps `iptvnator` as the real
Electron ELF so Electron Builder's `electron-wrapper` passes it directly to
Zypak. Other Linux targets retain the conditional `iptvnator` wrapper and
`iptvnator.bin`. Mixed Flatpak/non-Flatpak target sets fail before mutation.
- Linux packages for other architectures (arm64, armv7l) must not ship x64
native artifacts. `afterPack` replaces the native directory with
`embedded-mpv-unavailable.txt`, and package verification requires that marker.
- Every packaged manifest names its exact artifacts, profile/targets, libmpv
SONAME, loader closure, byte sizes, SHA-256 hashes, package dependencies, and
native-view fallback. Artifact modes and ELF dependency isolation are
verified after packaging.
- macOS release packaging rejects embedded MPV binaries linked to `/opt/homebrew` or `/usr/local`.
- Windows release packaging verifies that the platform runtime file is present
when Embedded MPV is required.
- Local development can opt into Homebrew `libmpv` only by setting `IPTVNATOR_EMBEDDED_MPV_ALLOW_HOMEBREW=1`; packaged release validation rejects that runtime origin.
Release packaging must:
- stage an LGPL-compatible libmpv runtime for each bundled release
platform/architecture, including the pinned Linux x64 source runtime
- 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 Electron/addon/reader binaries do not gain a direct libmpv
dependency and the helper does
- execute the helper capability probe in each intended x64 package environment
- publish corresponding source archives, git/submodule records, checksums,
exact flags, local patches, and build scripts for every bundled runtime
Users on macOS and Windows do not need the MPV GUI application for this
architecture. Linux native-view still requires an `mpv` executable. Linux
frame-copy system packages need their declared libmpv and EGL/GL/GBM
packages, while AppImage, Snap, and Flatpak carry their own runtime closure.
If frame-copy prerequisites are missing, x64 falls back to native-view; if all
Embedded MPV prerequisites are unavailable, the existing inline/external
players remain available.
## Runtime Staging
Runtime staging tooling lives in:
- `tools/embedded-mpv/`
- `vendor/embedded-mpv/`
Release runtime policy:
- FFmpeg must be built without `--enable-gpl` and without `--enable-nonfree`.
- mpv must be built with `-Dlibmpv=true` and `-Dgpl=false`.
- The runtime must be dynamically linked and shipped with license/source-distribution notices.
After building an LGPL-compatible prefix for a platform/architecture:
```bash
pnpm embedded-mpv:stage-runtime -- darwin arm64 /path/to/lgpl-prefix
pnpm embedded-mpv:stage-runtime -- darwin x64 /path/to/lgpl-prefix
pnpm embedded-mpv:stage-runtime -- win32 x64 /path/to/lgpl-prefix
pnpm embedded-mpv:stage-runtime -- linux x64 /path/to/lgpl-prefix
```
Tagged macOS release CI builds that prefix from pinned source archives first. The workflow can temporarily run the same path for macOS PR artifacts while the bundled runtime is being tested:
```bash
pnpm embedded-mpv:build-runtime -- arm64 /tmp/embedded-mpv-prefix
pnpm embedded-mpv:stage-runtime -- darwin arm64 /tmp/embedded-mpv-prefix
```
Linux x64 builds the release runtime from pinned source inputs and stages it
before compiling the helper:
```bash
pnpm embedded-mpv:build-runtime:linux -- /tmp/embedded-mpv-linux-prefix
pnpm embedded-mpv:stage-runtime -- linux x64 /tmp/embedded-mpv-linux-prefix
```
The Linux builder is intentionally host-restricted to Linux x64. It checks
minimum build-tool versions, uses an owned staging directory plus atomic
publish, rejects host pkg-config/runtime leakage, rewrites every bundled
library to an `$ORIGIN` RUNPATH, and enforces the portable ABI ceilings
`GLIBC_2.35` and `GLIBCXX_3.4.30`. It also verifies the exact libmpv SONAME,
complete dependency closure, and absence of build-prefix paths.
CI may restore exact-keyed caches for staged
`vendor/embedded-mpv/<platform>-<arch>/` runtimes. The Linux cache contains
only generated headers, libraries, the runtime manifest, and immutable source
inputs: exact archives, a clean recursive libplacebo checkout, and collected
license inputs. It never contains `embedded_mpv.node`, generated notices, or
the finished source-compliance archive. Runtime cache entries are saved only
from trusted repository refs. The Linux cache key covers the builder/stager,
notice generator, pinned sources, and toolchain. On every run, including a
cache hit, CI validates the cached hashes and clean checkout, regenerates the
notices for the current runtime manifest, converts libplacebo into a
VCS-metadata-free working-tree snapshot while retaining the validated
commit/submodule record, and creates
`linux-frame-copy-runtime-sources.tar.xz` for the current repository revision
and binary diff with normalized tar metadata.
The workflow keeps the macOS/Windows package matrix independent from the Linux
runtime prerequisite. Only the three Linux profile jobs depend on the runtime
builder; both matrices reuse one YAML-anchored step list to prevent packaging
logic drift. Draft release assembly still requires both matrices, so a public
release cannot silently omit a promised platform.
Windows CI reads its single checksum-pinned `win32-x64` input from
`tools/embedded-mpv/windows-runtime-pin.json`. The validated pin owns the exact
upstream release, non-v3 `mpv-dev-lgpl-x86_64` asset URL, GitHub-published
SHA-256 digest, mpv commit, build-run evidence, retention policy, and the
limited license-verification statement. PR, master, and tag builds all consume
that checked-in record; mutable repository variables cannot silently change a
build or drift from the cache key.
`refresh-windows-embedded-mpv-runtime.yaml` checks the pin weekly. If its asset
is unavailable or 14 days old, the dependency-free updater selects the newest
matching public release with a GitHub digest and upstream build evidence,
verifies that it is downloadable, updates only the JSON pin, and opens or
refreshes a reviewable bot PR. The `PAT` repository secret is used deliberately
for that PR so its create/synchronize events trigger the normal CI workflows;
it needs repository contents and pull-request access. Run
`pnpm embedded-mpv:windows-runtime-pin:check` for a local schema check or
`pnpm embedded-mpv:windows-runtime-pin:refresh -- --force` to prepare the same
update manually.
The zhongfly asset contains only libmpv headers, its import library, and the
DLL. Upstream labels it LGPLv2.1+ with statically linked LGPLv3 FFmpeg, but also
states that its transitive LGPL compatibility is not guaranteed. IPTVnator
therefore verifies availability, checksum, and archive layout only, records
that limitation in the pin and generated runtime manifest, and does not host a
long-lived mirror of the binary. A future mirror or first-party Windows build
must first publish complete corresponding source, exact build scripts and
patches, license notices, and a validated transitive license closure beside the
binary. The archive helper accepts normal `lib/` + `bin/` prefixes and common
flat archives, and preserves the DLL basename encoded by the import library.
The Linux 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`, HarfBuzz `8.5.0`,
Expat `2.8.2`, Fontconfig `2.16.0`, OpenSSL `3.5.7`, hwdata `0.409`, and
libdisplay-info `0.1.1`. FFmpeg disables autodetected external libraries.
Libplacebo is checked out at an exact git commit with all required submodules.
The hwdata archive and its `pnp.ids` build input are pinned so
libdisplay-info cannot silently consume `/usr/share/hwdata` from the builder.
The generated manifest records source URLs/checksums or git commits,
submodules, licenses, exact flags, build-host/toolchain data, runtime hashes,
and the dynamic closure. FFmpeg/mpv remain LGPL-compatible and dynamically
linked; codecs outside that build configuration are not implied.
`generate-linux-runtime-notices.cjs` collects the exact upstream license files
for every pinned package and all recursive libplacebo submodules. Generation
is fail-closed for a missing, undeclared, symlinked, size-mismatched, or
hash-mismatched file. Portable and Flatpak package hooks copy only the
validated notice manifest, aggregate notice, and per-package license tree;
package-layout verification revalidates that legal payload against the
embedded source-runtime manifest.
The Electron backend build consumes the staged runtime/build inputs. On Linux
it links the helper against the verified staged `libmpv.so.2`, never against a
generic host `-lmpv`, then verifies the helper's `DT_NEEDED` and
`$ORIGIN/lib` RUNPATH with `readelf`. The addon and frame reader are checked
for the opposite invariant. The profile-aware packaging hook later retains or
removes the private closure. Local Linux builds may still use distribution
headers/libraries, but a required package build must use the staged manifest.
macOS additionally rewrites Mach-O paths and re-signs modified local binaries;
release signing/notarization still happens later.
For local development before the vendored runtime exists, Homebrew can be used explicitly:
```bash
pnpm run serve:backend:embedded-mpv
```
That script first runs the local native build with `IPTVNATOR_EMBEDDED_MPV_ALLOW_HOMEBREW=1`, then starts Electron with `IPTVNATOR_ENABLE_EMBEDDED_MPV_EXPERIMENT=1`. This path is intentionally macOS development-only. Packaged builds reject `homebrew-dev` manifests and macOS packages reject any `/opt/homebrew` or `/usr/local` embedded MPV links.
If the settings page does not show `Embedded MPV (Experimental)` after starting with those flags, check the native build output:
```bash
ls apps/electron-backend/native/build/Release/embedded_mpv.node
```
If only `embedded-mpv-unavailable.txt` exists, the dev app started from a build where no runtime was available. Stop the Electron dev process and rerun `pnpm run serve:backend:embedded-mpv` so the native target is rebuilt before Electron starts. The native MPV build target is intentionally uncached because it depends on local runtime files and environment variables such as `IPTVNATOR_EMBEDDED_MPV_ALLOW_HOMEBREW` and `IPTVNATOR_EMBEDDED_MPV_ARCH`.
If opening Settings hard-crashes Electron on macOS and the crash report says `Code Signature Invalid`, one of the copied runtime binaries was modified by `install_name_tool` without being re-signed. Rebuild the native target and verify the copied addon/runtime files:
```bash
IPTVNATOR_EMBEDDED_MPV_ALLOW_HOMEBREW=1 node apps/electron-backend/build-embedded-mpv.js
codesign --verify --verbose=2 apps/electron-backend/native/build/Release/embedded_mpv.node
codesign --verify --verbose=2 apps/electron-backend/native/build/Release/lib/libmpv.2.dylib
```
If macOS support detection reports a missing `@rpath/...` dependency, the dependency collector missed an indirect runtime file. The packaging helper must copy that file into `native/lib/`, rewrite the dependency to `@loader_path/<name>`, and include non-`.dylib` Mach-O files in the asset copy glob.
## Same-Version Desktop Release Gate
The normal release tag can produce Linux, Windows, and macOS artifacts from the same source version. Embedded MPV is required only for jobs where `IPTVNATOR_REQUIRE_EMBEDDED_MPV=1`; otherwise package validators still reject a present but invalid runtime while allowing the addon to be absent.
For tagged macOS builds, CI must:
- build the pinned LGPL-compatible runtime for the matrix architecture
- stage it into `vendor/embedded-mpv/darwin-${arch}` before `pnpm run build:backend`
- set `IPTVNATOR_EMBEDDED_MPV_PLATFORM=darwin`
- set `IPTVNATOR_EMBEDDED_MPV_ARCH=${arch}` for backend build and packaging
- set `IPTVNATOR_REQUIRE_EMBEDDED_MPV=1` for packaging and package-layout verification
For Windows builds, CI must resolve the validated checked-in pin, restore its
exact-keyed `win32-x64` staged runtime cache or stage that checksum-pinned
archive, and only then run `pnpm run build:backend`. The Windows job must set
`IPTVNATOR_EMBEDDED_MPV_PLATFORM=win32`,
`IPTVNATOR_EMBEDDED_MPV_ARCH=x64`, and
`IPTVNATOR_REQUIRE_EMBEDDED_MPV=1` for backend build, package make, and
package-layout verification. CI narrows `electron-builder.json` to x64 Windows
targets while only a `win32-x64` runtime is available. The Windows job is
pinned to `windows-2022` until the Electron `node-gyp` toolchain can identify
Visual Studio 18 from `windows-latest`.
For Linux builds, CI first builds or restores the pinned x64 source runtime and
stages it under `vendor/embedded-mpv/linux-x64`. It then runs three isolated
packaging passes with `IPTVNATOR_EMBEDDED_MPV_PLATFORM=linux`,
`IPTVNATOR_EMBEDDED_MPV_ARCH=x64`,
`IPTVNATOR_REQUIRE_EMBEDDED_MPV=1`, and one exact
`IPTVNATOR_LINUX_FRAME_COPY_PROFILE`. Each produced artifact is extracted and
verified, and the x64 helper probe runs in the intended runtime environment.
That extracted-artifact probe uses its own 15-second bound rather than the
application gate's three seconds: nothing waits on it but the CI job, which
already has a job-level timeout, while a premature kill would report a healthy
package as broken. Cold sandboxes — Flatpak most of all — can spend seconds
merely loading libmpv plus EGL/GL/GBM. A hard timeout is also the only probe
outcome that says nothing about the payload, so the verifier repeats it once
(two attempts in total, announced on stderr) before failing. Every other
outcome — spawn error, termination by signal, nonzero exit, or a malformed
protocol line — remains fail-closed on the first attempt, so a wrapper launched
instead of the real ELF, a missing helper, or a hung helper still fails
verification.
The packaged x64 Playwright smoke first runs its fixture-contract target and
passes Chromium `--ignore-gpu-blocklist` so Mesa llvmpipe can expose WebGL2 in
CI. That launch-only flag does not bypass any manifest, hash, loader, or helper
capability check; `--no-sandbox` is added only when the runner is root.
Bundled package layouts must include the generated notices and exact license
tree. The separately uploaded
`linux-frame-copy-runtime-sources.tar.xz` contains the exact archive set,
the VCS-metadata-free libplacebo working tree plus the exact pinned commit and
six recursive submodule records, notice/license inputs, runtime metadata,
current revision/diff, and build tooling. Each submodule record is canonical
`full-commit safe/path`; clone-depth-dependent `git describe` annotations are
discarded. The source index also records a
globally sorted exact inventory
of every libplacebo directory, regular file, and symlink. File hashes, sizes,
normalized executable bits, link targets, aggregate counts/bytes, and the
canonical inventory digest must match the trusted pinned v7.360.1 checkout;
an arbitrary or incomplete self-declared tree is rejected. Its tar metadata is
normalized, its member/type layout is exact, and
`metadata/archive-sha256.txt` must describe the actual source archive bytes.
Tar listing continues past every end marker, so concatenated xz/tar streams
cannot hide undeclared members. ARM artifacts are independently verified as
marker-only and never run the x64 helper.
After constructing the final
`linux-frame-copy-runtime-sources.tar.xz`, CI hashes its exact bytes and stages
`source-archive-binding.json` beside the x64 runtime. AppImage, Snap, and
Flatpak manifests copy that binding unchanged as `sourceArchive`, including the
SHA-256 and repository revision. System-package manifests and marker-only
non-x64 packages must not carry it, so a portable package cannot advertise
source correspondence inherited from another profile or architecture.
The build workflow creates a draft GitHub release but never publishes Snap in
parallel with that draft. The separate Snap workflow runs only for a public
`release.published` event whose tag starts with `v`; before any Store upload it
requires at least one exact `.snap` asset and exactly one non-empty
`linux-frame-copy-runtime-sources.tar.xz` in that public release. It hashes and
safely inspects the bounded downloaded archive, requires regular metadata,
archive, legal, and tooling member/type set, validates link targets and the
archive checksum metadata, and requires the clean checkout and source index to
match the released tag. It verifies the actual pinned source-member hashes,
the six recursive libplacebo submodule records, license-input and notice
hashes, the exact VCS-free libplacebo tree inventory/digest, and byte-identical
tooling from the released tag. Checkout and both artifact-transfer actions use
full pinned commits, and checkout sets `persist-credentials: false`.
The bounded SquashFS preflight and extraction then require the canonical
snap-root layout (Electron app at `/`, so `/iptvnator.bin` and
`/resources/**` — the layout Electron Builder's Snap target produces) and
reuse the static package validator for every selected Snap. The public-release verifier separately reapplies the exact
strict `meta/snap.yaml` graphics/shared-memory/layout contract and enumerates
the extracted `resources/app.asar`; any archived
`electron-backend/native/**` entry fails before Store publication. The bounded
ASAR header reader uses only Node built-ins plus released local tooling, keeping
this check runnable from the clean tag checkout without `node_modules`. Exactly
one x64 Snap must contain a bundled portable manifest
whose exact `sourceArchive` and `sourceRuntime` match the archive; non-x64
Snaps must remain marker-only. Repository credentials are scoped to the two
GitHub asset steps. The secretless verification job copies downloaded files
through no-follow descriptors into a private snapshot, hashes them before and
after inspection, writes an exact receipt, root-seals the snapshot, and reruns
the complete source/package verifier against those bytes. It then transfers
only the sealed data through the pinned artifact service, publishes the exact
receipt digest separately as a job output, and terminates.
The dependent publish job runs on a bounded GitHub-hosted `ubuntu-latest`
runner with no checkout or release-tag code. It requires the separately
transmitted receipt digest, validates the exact receipt schema and every asset
size/hash, accepts only the expected regular `.snap`, source archive, and
receipt layout, rejects links and extra entries, and root-seals the transferred
files again before installing the official stable Snapcraft snap.
Only its final fixed shell step receives the Store credential. That step uses a
bounded Bash glob, resolves no PATH command, executes no released code, and
passes the credential only to each exact
`/snap/bin/snapcraft upload --release=edge` process. Any verification or
transfer mismatch aborts before Store credentials are available.
Candidate/stable promotion is manual after installed-Snap frame-copy and
missing-runtime fallback smoke; GitHub Actions never promotes automatically.
During temporary artifact tests, CI may also set `IPTVNATOR_REQUIRE_EMBEDDED_MPV=1` for PR and `master` push jobs where a runtime is known to exist. After the artifacts are manually validated, remove temporary conditions so ordinary development builds leave `IPTVNATOR_REQUIRE_EMBEDDED_MPV` unset or `0`. This keeps the native feature in-tree without making every non-release build depend on runtime artifacts.
## Release Safety
The feature is still experimental. The largest risks are native-process risks, not normal Angular UI risks:
- a bad native addon or `libmpv` crash can crash the Electron main process
- packaging can fail if `libmpv` or one of its platform runtime dependencies is missing, unsigned where signing applies, or linked to the wrong runtime path
- macOS graphics behavior can vary across Intel, Apple Silicon, external displays, fullscreen transitions, and hardware decoding paths
- Windows `HWND` and Linux X11/Xwayland embedding need packaged-app smoke coverage for focus, resize, and fullscreen behavior
- Linux native-view remains unsupported on native Wayland; frame-copy has no
window-embedding dependency but still requires a working EGL probe
- Homebrew `libmpv` builds can target a newer macOS version than IPTVnator's declared deployment target
It is reasonable to ship the code in-tree behind the current experiment flag. It is not yet safe to make it the default player. It can be exposed as desktop experimental if support detection is strict, the UI clearly labels it experimental, and fallback to Video.js or external MPV/VLC stays available.
If an embedded session fails to initialize, the app should keep the user in control by preserving the normal player setting choices. If a native crash occurs, normal settings fallback cannot intercept that crash, so broader OS-specific smoke testing and packaged-app testing are required before broad release.
## Suggested Release Gate
Do not expose embedded MPV broadly until these pass on every supported target:
- macOS/Windows packaged apps start without system `mpv`; Linux x64
frame-copy starts in each declared package profile, and a missing frame-copy
dependency falls back without crashing
- bundled libmpv and dependent runtime files pass package validation;
DEB/RPM/Pacman contain no private closure and declare the exact system
dependency
- Electron, its shipped libraries, `embedded_mpv.node`, and the frame reader
have no direct libmpv `DT_NEEDED`; the helper resolves the exact declared
libmpv runtime
- AppImage, DEB, RPM, Pacman, Snap, and Flatpak payloads pass extraction,
manifest/mode/ELF checks and the applicable helper probe; ARM payloads are
marker-only
- 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
- live HLS, MPEG-TS, MP4/VOD, headers, referrer, volume, seek, fullscreen, route changes, and cleanup work
- audio-track switching works on a stream with multiple audio tracks
- live stream recording starts, stops, writes a `.ts` file in Downloads/custom recording folder, and stops on route/playback changes
- fallback behavior is clear when the addon or native dependencies are unavailable