* feat(embedded-mpv): Linux frame-copy helper via headless EGL Port the frame-copy engine's native layer to Linux (PORTING.md items 1-4): - frame_helper_gl.h: platform GlContext abstraction. macOS keeps the CGL path (moved verbatim); Linux acquires an EGL display in order surfaceless-Mesa -> default display -> GBM render node, binds a 3.2 core desktop-GL context surfaceless (1x1 pbuffer fallback), and hands mpv eglGetProcAddress. The helper's own GL calls link against glvnd libOpenGL, so no display server is required. - frame_shm.h: portable frame_shm_now_ns() (CLOCK_MONOTONIC) shared by the helper and the reader addon, replacing the macOS-only clock_gettime_nsec_np(CLOCK_MONOTONIC_RAW); producer and consumer stay on the same clock. - embedded_mpv_frame_reader.c: real implementation now also on __linux__ (the code was already POSIX apart from the clock call). - binding.gyp: OS==linux executable branch for iptvnator_mpv_helper linking system libmpv (-lmpv) + EGL/OpenGL/gbm, with rpaths for $ORIGIN/lib and the build-time library dir. The in-process addon still does not link libmpv - the ban only binds in-process, the helper is out of process. - build-embedded-mpv.js: system-dev fallback on Linux (LIBMPV_INCLUDE_DIR or /usr/include) so a distro libmpv-dev install builds without staging a vendored runtime; a pre-set LINUX_NATIVE_LIBRARY_DIR now wins over the vendored lib dir. Verified on Ubuntu 25.04 / i7-1165G7 (Iris Xe): lavfi smoke per PORTING.md (idle->loading->playing snapshots at 4 Hz, aspect-fit generation bump g1 1280x720 -> g2 960x720 for a 4:3 source), reader probe 60 fps at 1080p60 with 0 torn reads, clean quit with no leaked processes or shm. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(embedded-mpv): enable the frame-copy engine gates on Linux Flip the TypeScript side of the Linux port (PORTING.md item 5). A shared dependency-free predicate, isFrameCopyPlatformSupported() (linux any-arch, darwin arm64-only), now backs all four gates so they cannot drift: - main.ts: the persisted Settings toggle promotes to the env flag on Linux too (this runs before window creation and controls the sandbox relax). - EmbeddedMpvNativeService.isFrameCopyEngineActive/isFrameCopyAvailable. - EmbeddedMpvFrameCopyAdapter.isSupported. getSupport() ordering: the frame-copy branch moves above the Linux-only native-engine prerequisites - the X11/Xwayland display-server check and the system-mpv-on-PATH probe only bind the --wid native engine, while the frame-copy helper renders offscreen (headless EGL) and links libmpv itself. createSession() also skips resolving the native window handle for frame-copy sessions, which the adapter ignores anyway, so native-Wayland sessions no longer trip the window-handle assertion. Settings copy: the i18n frame-copy description now says macOS (Apple Silicon) and Linux in all 18 languages; stale macOS-only doc comments in the settings/support interfaces updated alongside. Tests: platform-gate matrix for the adapter (darwin arm64/x64, linux x64/arm64, win32) and service specs covering Linux activation under native Wayland, macOS arm64 staying active, macOS x64 staying native, and the skipped window handle for frame-copy sessions. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * chore(packaging): CI + package guards for the Linux frame-copy helper - build-and-make.yaml: install libegl-dev/libgl-dev/libgbm-dev on the Linux runner (the helper's EGL backend needs them now that the helper target builds on Linux), and verify the built helper exists and DOES link libmpv - the inverse of the addon's no-libmpv rule, which still holds and stays validated. - electron-after-pack.cjs: strip iptvnator_mpv_helper from packaged Linux apps. It links the build host's system libmpv, which end-user systems cannot be assumed to have; the support probe treats the missing helper as frame-copy-unavailable (dev-build-only engine until the bundled-runtime staging milestone). - frame_helper_gl.h: log the chosen EGL display tier to stderr (the adapter mirrors helper stderr), so bring-up problems on exotic setups are diagnosable. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs(embedded-mpv): document the Linux frame-copy port - architecture doc: frame-copy section covers Linux (EGL display tiers, build deps, package strip), Linux support matrix notes the frame-copy exception to the X11 + system-mpv requirements, Linux measured baseline. - RESULTS.md: Ubuntu 25.04 / i7-1165G7 (Iris Xe) measurement rows via the production helper + reader probe; viewport-size claim reproduced. - PORTING.md: Linux marked done with pointers to what changed; Windows remains the open port and its perf gate the open decision. - CLAUDE.md + tools/embedded-mpv/README.md: platform scope, Linux dev build requirements, system-headers fallback, helper strip. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(embedded-mpv): commit the Linux frame-copy measurement probe linux-frame-probe.mjs reproduces the RESULTS.md Linux rows: spawns the production helper, attaches the frame-reader addon to the announced shm generation, and reports new-frame fps, copy wall time, produce->copy age, torn reads and pixel spread. Usage documented in RESULTS.md. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(embedded-mpv): address multi-agent review findings on the Linux port Confirmed findings (each verified by 3 adversarial reviewers): - CI would fail to link the helper: -lOpenGL needs the unversioned glvnd libOpenGL.so, shipped only by libopengl-dev, which neither the runner images nor the previous apt line provide. Added to the workflow and to every documented Linux build-dep list. - The new 'test -x' dist guard could never pass: webpack's dist asset copy drops file modes (helper arrives as 0644). The guard is now 'test -f'; electron-after-pack.cjs restores the execute bit on packaged helpers (also fixes packaged-macOS spawns); the support probe now requires X_OK, so a mode-stripped helper reads as frame-copy-unavailable and falls back to native instead of failing spawn with EACCES. - The Settings frame-copy toggle was unreachable in exactly the Linux states the port targets: the native-Wayland and missing-system-mpv unsupported payloads omitted frameCopyAvailable, and toggle visibility derives solely from it. Both returns now advertise availability. Also from review: - build-embedded-mpv.js keeps the old graceful-skip contract when the new system-dev fallback finds libmpv-dev but the GL/EGL/gbm dev stack is missing (previously such machines skipped; a hard electron-build failure was a regression). - createSession derives the window-handle skip from the dispatched addon instead of re-evaluating the engine gate, so the two cannot disagree. - The render thread logs the GL renderer string (surfaceless Mesa can silently pick llvmpipe on non-Mesa-primary systems; now diagnosable — verified 'Mesa Intel Iris Xe' on this machine). - Specs pin the new semantics: frameCopyAvailable advertised while native is unsupported (Wayland / no mpv), frame-copy supported without a system mpv, and the handle-skip test disposes its session through the owning adapter. - Docs: PORTING.md file map reflects the frame_helper_gl.h seam for the Windows porter; helper-strip removal correctly gated on milestone 4 (bundled libmpv), not milestone 3; RESULTS.md preamble notes the RAW->MONOTONIC clock change; stale '(macOS)' scope comments updated. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(embedded-mpv): address Greptile/Codex review comments - Sandbox gate requires a usable helper (Greptile P1, security): the main.ts env promotion now also probes for an executable iptvnator_mpv_helper before relaxing the window sandbox — a stale opt-in on packaged Linux (helper deliberately stripped) or after a cleaned native build no longer costs a sandboxless launch for an engine that cannot activate. Helper discovery (addon candidate paths + X_OK probe) moved into embedded-mpv-frame-copy-platform.util.ts, shared by main.ts and the service; the service keeps thin instance wrappers so tests can stub per scenario. New util spec pins the platform matrix, candidate resolution, and the execute-bit semantics. - Stale frame-copy artifacts on skipped builds (Codex P2): cleanOutput() now also removes iptvnator_mpv_helper and embedded_mpv_frame_reader.node, so a failed/skipped rebuild cannot leave a previous helper advertising frame-copy support against a runtime the build just declared unavailable. - Multiarch default lib dir (Greptile P1, partially refuted): -l resolution never depended on our -L (the compiler's built-in search paths include the Debian/Ubuntu multiarch dir — proven by the green CI run linking with a nonexistent -L dir), but the system-dev fallback now defaults to /usr/lib/<multiarch-triple> when present so the -L flag and the helper's baked rpath point somewhere real. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(embedded-mpv): harden Linux frame-copy port --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
48 KiB
Embedded MPV Native Integration
This document explains how IPTVnator embeds MPV inside the Electron app, which files are source versus generated build output, and what must be true before the feature is safe to expose to users.
What To Commit
Source files for the embedded MPV integration:
apps/electron-backend/build-embedded-mpv.jsbuilds the native addon for the target Electron runtime.apps/electron-backend/native/binding.gypdefines the native addon build.apps/electron-backend/native/src/embedded_mpv.mmowns the macOSlibmpvrender integration.apps/electron-backend/native/src/embedded_mpv_win32.ccowns the WindowsHWND+ mpvwidbackend.apps/electron-backend/native/src/embedded_mpv_linux.ccowns the Linux X11/XwaylandWindow+ mpvwidbackend.apps/electron-backend/native/src/embedded_mpv_wid_common.howns the shared Windows/Linux session surface, including Linuxmpv --widprocess control and JSON IPC.apps/electron-backend/src/app/services/embedded-mpv-native.service.tsowns Electron main-process session lifecycle and support detection.apps/electron-backend/src/app/events/embedded-mpv.events.tsregisters the IPC contract.apps/electron-backend/src/app/api/main.preload.tsexposes the preload bridge to the renderer.libs/shared/interfaces/src/lib/embedded-mpv-session.interface.tsdefines the shared session and audio-track contract.libs/ui/playback/src/lib/embedded-mpv-player/owns the Angular UI and controls.
Frame-copy engine sources (experimental, macOS Apple Silicon and Linux — see the "Frame-Copy Engine" section below):
apps/electron-backend/native/helper/—iptvnator_mpv_helperprocess (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 theNativeEmbeddedMpvAddonsurface.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
The embedded player renders MPV frames into an app-owned native video surface. macOS uses the libmpv render API in an NSOpenGLView because the mpv wid path produced a black video surface inside Electron. Windows loads libmpv through the native Node addon and uses mpv's wid option against an IPTVnator-owned child HWND. Linux creates an IPTVnator-owned X11/Xwayland child Window and starts an out-of-process mpv --wid=<window> instance for that child window.
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.
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
Embedded MPV on Linux is supported only for x64 desktop builds where Electron runs under X11 or Xwayland and an mpv executable is available on PATH. Native Wayland embedding is not supported in this implementation.
The experimental frame-copy engine (below) is the exception to both requirements: it renders offscreen through headless EGL into a renderer canvas — no window embedding — and the helper links libmpv itself, so neither the X11/Xwayland constraint nor the system-mpv-on-PATH probe applies while it is active. It is currently a dev-build-only engine on Linux (the helper links the build host's system libmpv and is stripped from packaged apps until the bundled-runtime staging lands). Packaged Linux launchers pass --ozone-platform=x11 so Wayland desktops use Xwayland when it is available, and main.ts appends the same switch on Linux when it is absent so direct binary/AppImage launches from a terminal behave like launcher starts. Explicit user intent is never overridden: both a user-provided --ozone-platform switch and the ELECTRON_OZONE_PLATFORM_HINT environment variable suppress the fallback.
When the mpv executable probe fails inside a Flatpak or Snap sandbox (FLATPAK_ID/SNAP env present), the support reason explains that sandboxed packages cannot access a system mpv instead of asking the user to install it.
Current release-announcement wording should stay close to this:
- Supported display path: X11 or Xwayland.
- Not supported: native Wayland embedding.
- Validated locally: Ubuntu 24.04 GNOME Wayland session with Electron forced to X11/Xwayland and system
mpv. - Validated in CI: Ubuntu 22.04 standard Linux package build and Ubuntu 24.04 Flatpak package build.
- Expected standard packages:
.debon Ubuntu/Debian,pacmanon Arch/Manjaro,.rpmon RPM-based distributions, and AppImage on x64 glibc systems, all with systemmpvinstalled. - Sandbox caveat: Flatpak and Snap packages build and continue to support the normal inline/external-player flows, but embedded MPV is not announced as supported there yet because the Linux backend launches
mpv --widand those sandboxed formats do not expose the hostmpvexecutable to the app by default.
The flow is:
- Angular receives a
ResolvedPortalPlaybackpayload and rendersEmbeddedMpvPlayerComponent. - The component paints a loading state before requesting native startup work.
- If available, the preload API asks the main process to prepare the embedded MPV addon. This loads
embedded_mpv.nodeand its platform runtime files, but does not create a native view or MPV playback session. - The component asks the preload API to create an embedded MPV session with the current viewport bounds and initial volume.
- The Electron preload forwards calls through IPC to the main process.
EmbeddedMpvNativeServiceowns sessions, polls snapshots, and emits session updates to the renderer.- The native addon creates an app-owned platform video host inside the Electron window.
- On macOS the addon configures
vo=libmpv, creates ampv_render_context, and draws into the OpenGL surface. On Windows it creates anmpv_handle, disables MPV's own OSC/input handling, and passes the child-window id throughwid. On Linux it startsmpv --wid=<x11-window>in a separate process with a private JSON IPC socket and tracks that process until playback replacement or dispose. - Resize, scroll, and fullscreen changes are measured in Angular and sent back to the addon as native bounds so the platform video host stays aligned with the Angular layout.
- Playback controls remain IPTVnator-owned Angular UI. MPV receives commands only through the controlled IPC surface.
The renderer never gets direct native-module access. It can only call the preload contract:
- prepare native addon
- create session
- load playback
- set bounds
- play/pause
- seek
- set volume
- set audio track
- 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, addon presence, bundled runtime presence, or the Linux mpv executable check fails. 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.
The MPV video surface is a native platform view/window, not a normal DOM element. Do not place critical Angular overlays on top of the video viewport and expect CSS z-index to win. The embedded MPV controls use a compositor-safe control dock below the native viewport instead of a true overlay on top of the native video surface.
The dock has a stable reserved height while embedded controls are enabled. Controls fade in and out inside that fixed dock, so normal show/hide behavior does not resize the native MPV viewport or make the video jump. Volume and audio-track panels replace the default transport controls inside the same dock and provide a back button to return to the default controls. Popovers and menus must stay inside that dock unless the native layering strategy changes. The native MPV view deliberately ignores hit testing so mouse movement passes through to Chromium and can reveal Angular controls even when the pointer moves quickly across the video area.
Frame-Copy Engine (Experimental, Apple Silicon and Linux)
IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY=1 (on top of the regular
embedded MPV experiment flag) switches macOS/arm64 and Linux to a second
rendering engine that replaces the native-view compositing entirely
(gate: isFrameCopyPlatformSupported() in
embedded-mpv-frame-copy-platform.util.ts, shared by main.ts, the
service and the adapter):
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), publishes BGRA frames into a POSIX shm seqlock ring (frame_shm.h, 3 slots, resize creates a new-g<N>generation), and plays audio directly. Control protocol: tab-separated commands on stdin, JSON events on stdout; thesnapshotevent mirrorsNativeEmbeddedMpvSessionSnapshot. Status semantics are ported fromembedded_mpv.mm.apps/electron-backend/src/app/services/embedded-mpv-frame-copy.adapter.ts— implements the sameNativeEmbeddedMpvAddonsurface over the helper process, soEmbeddedMpvNativeService(polling, diffing, power blocker, recording paths) is reused unchanged. The flag routesgetAddon()to the adapter and support reportsengine: '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 exposesattachEmbeddedMpvFrameView/detachEmbeddedMpvFrameView.- Renderer:
EmbeddedMpvPlayerComponentrenders the canvas whensupport.engine === 'frame-copy'and skips the compositor workarounds — noHIDDEN_BOUNDSwhen dialogs open, no popover bottom cutout; dialogs and controls stack above the canvas as ordinary DOM. 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.
Enabling it: the Settings > Playback > Embedded MPV: frame-copy engine
checkbox (shown only when support reports frameCopyAvailable) 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 discovery finds both an executable
(X_OK) helper and a readable regular frame-reader addon in the same native
directory. Packaged discovery is limited to packaged resource locations and
never falls through to writable cwd/dist development paths. A disabled base
experiment keeps the renderer sandbox enabled and embedded MPV unavailable.
When the base feature is enabled, a missing, mode-stripped, or incomplete
frame-copy runtime keeps the sandbox enabled and falls back to the native
engine.
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 background (app surface shows at the sides; fullscreen keeps a
black backdrop). 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
package validation requires iptvnator_mpv_helper and
embedded_mpv_frame_reader.node next to the addon whenever the addon
ships. The after-pack hook restores the 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; it is not a compatibility check for a complete
but version-mismatched runtime pair. Linux packages deliberately do NOT ship
the helper yet: it links the build host's system libmpv, which packaged
apps cannot assume is installed, so the centralized after-pack artifact
preparation strips both possible helper basenames and package validation
rejects either one if it survives. The support probe therefore reports
frame-copy unavailable in Linux packages.
The engine is dev-build-only on Linux until bundled-libmpv runtime staging
lands (PORTING.md milestone 4).
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
requirethe reader addon);contextIsolationandnodeIntegration:falsestay 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. Linux (any arch) is ported —
headless EGL, works under native Wayland since nothing embeds into a
window; dev builds need
libmpv-dev,libegl-dev,libgl-dev,libopengl-devandlibgbm-dev(the helper links system libmpv, which is legal out-of-process — the in-process libmpv ban still binds the addon). 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. The Windows port of the helper (WGL) is future work — the shm protocol and adapter are platform-agnostic. - 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.
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:
endedwhen the end-file reason isMPV_END_FILE_REASON_EOFerrorwhen MPV reports an end-file errorloadingwhen MPV reportsMPV_END_FILE_REASON_REDIRECT, because playback continues with the redirected playlist contentsidlefor other successful end-file reasons such as replacement/stopclosedonly 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. The embedded MPV controls show skip_previous and skip_next buttons only for non-live series playback. The shared navigation payload contains canPrevious, canNext, and autoplayEnabled; the component disables previous/next at the current-season boundaries and guards the output handlers as well as the button disabled state.
Autoplay is enabled by default for series playback in embedded MPV. On ended, Xtream and Stalker series detail views start the next episode only when the current episode has a next item in the same season. Playback stops on the last episode of the current season. Previous always switches to the previous episode in the current season; it does not implement a restart-threshold behavior.
Live Stream Recording
Embedded MPV can record live streams through mpv's stream-record option. IPTVnator exposes this only for 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.tsfilename in the requested directory and calls the native addon'sstartRecording(sessionId, targetPath).stopEmbeddedMpvRecording(sessionId)calls the native addon'sstopRecording(sessionId).- The native addon sets mpv's
stream-recordproperty 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.recordingcarries{ active, targetPath, startedAt, error }so the renderer can show active elapsed time, final save path, or a failure.
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 are intentionally not inserted into the Downloads database or queue in v1 because MPV writes from the active playback session while the download manager owns independent backend download jobs.
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-only orchestrator. 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,MENU_OPEN_BOTTOM_CUTOUT_PX).embedded-mpv-shortcuts.ts—EmbeddedMpvShortcutsclass withattach(handlers)/detach(). Owns the document keydown listener and routes through a callback interface; the component supplies the callbacks. Listens for Space/K (toggle), F (fullscreen), arrow keys (seek/volume), M (mute), Escape (close popovers).embedded-mpv-overlay-visibility.service.ts— singleton service that exposesoverlayActive: signal<boolean>. TracksMatDialog.afterOpened/afterAllClosedfor dialog-shaped overlays and falls back to aMutationObserveron the CDK overlay container for any remaining backdrop-bearing CDK overlays. The native MPV video host is hidden off-screen while a modal is open so DOM dialogs can paint above it.embedded-mpv-ui-state.ts—EmbeddedMpvMenuState(single-open popover state machine withvolumeOpen,audioOpen,subtitleOpen,speedOpen,aspectOpensignals plusanyOpencomputed;toggle/open/close/closeAllhelpers) andEmbeddedMpvFeedback(transient overlay that auto-clears after a configurable delay; used for keypress feedback).embedded-mpv-session-controller.ts— component-scopedInjectableservice that owns thesupport,session,sessionId,stalled, andretryTokensignals. Subscribes toonEmbeddedMpvSessionUpdate, runs the polling-drivenstalledtimer, owns bounds-sync (resize, scroll, overlay state), and exposes the imperative IPC surface (startSession,togglePaused,seekBy/seekTo,applyVolume,setAudioTrack,setSubtitleTrack,setSpeed,setAspect,startRecording,stopRecording,retry).embedded-mpv-player.component.ts— view-only shell. Holds view children, derivedcomputedsignals, DOM event listeners (pointermove, pointerdown, fullscreenchange, dblclick), and threeeffect()s.
Bounds compositing strategy
The native 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 three 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. - Control popover open (any of the menu states above) → host bounds with
MENU_OPEN_BOTTOM_CUTOUT_PX(300 px) removed from the bottom. The popover region becomes DOM-receiving while video keeps playing in the upper region. - Idle → full host bounds.
The viewport DOM element also reserves --embedded-mpv-controls-height (64 px) at the bottom when controls are enabled, so the controls strip itself is always DOM and always reachable for hover-to-reveal even before the popover-cutout takes effect.
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.startSessiononce wrotethis.support.set(prepared)after theprepareEmbeddedMpvround-trip. The component's session-creation effect tracksthis.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 writesupportinsidestartSession; the constructor'sloadSupport()already populates it including capabilities. - Stream restart on volume change. The session-creation effect once read
this.volume()directly to pass tostartSession'sinitialVolume. Each volume tick re-ran the effect, disposing and recreating the session — for VOD/series this restarted playback from the beginning. Fix: read it viauntracked(() => this.volume()). Subsequent volume changes flow throughcontroller.applyVolume(), never through the effect graph. - Spurious
timeUpdatere-emits andvolume.setcalls. The session-fan-out effect callsscheduleControlsHide(), which readsisPlaying,menus.anyOpen,statusLabel, andcontrolsVisible. Those reads became tracked deps, so opening any popover, pausing, or hovering re-ran the body. No loop in isolation, but a parent that wirestimeUpdateback intoplayback.startTimewould have hit the volume-restart bug class. Fix: wrap the side-effect block inuntracked()so the effect listens only to session changes. - 2 Hz no-op stalled-tracker re-runs. The controller's stalled effect tracked the full
sessionsignal, which updates on every position-poll snapshot.handleStalledTrackingis a no-op for non-loading status, so the re-runs cost nothing useful. Fix: track asessionStatus = computed(() => this.session()?.status ?? null)instead so the effect fires only on real status transitions.
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-side IPC methods on the controller use 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.
Every IPC call goes through a guardIpc helper that swallows addon-side throws — sessions can be torn down while a call is in flight, and snapshot polling will resync state on the next tick.
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, andlinux; 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 distributionlibmpv-devheaders and libraries;LIBMPV_INCLUDE_DIRandLINUX_NATIVE_LIBRARY_DIRoverride the default system paths. - When the staged-input path is used, it must contain
include/mpv/client.handruntime-manifest.json. macOS and Windows staging also contains the platform runtime files that are bundled into the app. - The compiled
.nodeaddon is copied intodist/apps/electron-backend/native/embedded_mpv.node. - Bundled runtime files are copied into
dist/apps/electron-backend/native/lib/for macOS and Windows. macOS copies.dyliband non-.dylibMach-O dependencies; Windows copies the stagedmpv-2.dll/libmpv-2.dll/mpv.dll/libmpv.dllruntime name plus import libraries. Linux writes anexternal-mpv-processmanifest and intentionally leaveslibmpv.soout of the package. - Linux does not bundle or load
libmpvin the Electron process. The addon can compile against staged or system-development MPV headers. Its native engine still depends on an X11/Xwayland window handle plus anmpvexecutable onPATH; the dev-only frame-copy helper is a separate process linked to systemlibmpvand renders through headless EGL, so it bypasses those native-engine prerequisites. afterPackcopiesdist/apps/electron-backend/native/intoapp.asar.unpacked/electron-backend/native/on macOS, Windows, and Linux so the addon, manifest, and runtime libraries are filesystem-addressable.
Current release caveat:
- Release packaging requires a
vendored-lgplruntime manifest on macOS and Windows, and anexternal-mpv-processmanifest on Linux. - The Linux addon is built once per CI host architecture (x64). Linux packages for other architectures (arm64, armv7l) must not ship that foreign addon:
afterPackreplaces the native directory with anembedded-mpv-unavailable.txtmarker explaining that embedded MPV is not bundled for that architecture, and package-layout verification rejects a foreign-architectureembedded_mpv.nodewhile requiring the marker. - macOS release packaging rejects embedded MPV binaries linked to
/opt/homebrewor/usr/local. - Windows release packaging verifies that the platform runtime file is present when Embedded MPV is required. Linux release packaging verifies that the addon and manifest are present, no bundled
libmpv.sofiles slipped into the package, and no development-only frame-copy helper survivedafterPack. - Local development can opt into Homebrew
libmpvonly by settingIPTVNATOR_EMBEDDED_MPV_ALLOW_HOMEBREW=1; packaged release validation rejects that runtime origin.
Before public release, packaging must:
- stage an LGPL-compatible
libmpvruntime for each macOS/Windows release platform/architecture, and stage Linux MPV headers/build metadata for Linux - collect indirect macOS dependencies expressed as absolute paths,
@loader_path, or@rpath - rewrite macOS install names and dependency paths to app-relative paths such as
@loader_path - code-sign and notarize the full macOS dependency set
- ensure Windows runtime staging includes both the DLL and the import library used by
node-gyp - ensure Linux native builds do not gain a direct
libmpvdependency; the runtime playback path ismpv --widin a separate process - publish the corresponding FFmpeg/libmpv source and build metadata for bundled macOS/Windows runtimes; Linux should document the distribution package versions used as build inputs
Users on macOS and Windows do not need the MPV GUI application for this architecture. Linux currently requires an mpv executable because the supported backend is process-isolated. If the native addon/runtime prerequisites or Linux mpv executable are missing, embedded MPV is hidden/unsupported and the existing inline/external players remain available.
Runtime Staging
Runtime staging tooling lives in:
/Users/4gray/Code/iptvnator/tools/embedded-mpv//Users/4gray/Code/iptvnator/vendor/embedded-mpv/
Release runtime policy:
- FFmpeg must be built without
--enable-gpland without--enable-nonfree. - mpv must be built with
-Dlibmpv=trueand-Dgpl=false. - The runtime must be dynamically linked and shipped with license/source-distribution notices.
After building an LGPL-compatible prefix for a platform/architecture:
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:
pnpm embedded-mpv:build-runtime -- arm64 /tmp/embedded-mpv-prefix
pnpm embedded-mpv:stage-runtime -- darwin arm64 /tmp/embedded-mpv-prefix
During temporary PR and master artifact testing, CI can restore an exact-keyed GitHub Actions cache for the staged vendor/embedded-mpv/<platform>-<arch>/ runtime and skip the expensive source build or archive staging path where one exists. The cache only contains include/, lib/, and runtime-manifest.json; it never contains the compiled embedded_mpv.node addon because that target depends on Electron headers, ABI, architecture, and build environment. Runtime cache entries are saved only from trusted repository refs, and tagged public macOS release builds continue to rebuild from pinned sources until a dedicated signed and attested runtime artifact flow exists. Windows CI uses a checksum-pinned win32-x64 runtime archive configured through IPTVNATOR_WINDOWS_EMBEDDED_MPV_RUNTIME_URL and IPTVNATOR_WINDOWS_EMBEDDED_MPV_RUNTIME_SHA256 repository variables or secrets on cache miss. Non-tag artifact builds have a pinned zhongfly/mpv-winbuild mpv-dev-lgpl-x86_64 fallback so PR builds can produce a Windows embedded MPV artifact before repository variables are configured; tagged releases still require explicit repository configuration. The Windows archive helper accepts normal lib/ + bin/ prefixes and common mpv-dev-lgpl flat archives, including libmpv-2.dll names, and preserves the DLL basename expected by the import library; when the archive does not include runtime-manifest.json, it generates a minimal manifest from the archive URL/path and checksum. Linux stages Ubuntu package build inputs only; adding pinned source builders for Windows and Linux remains a separate release-hardening task.
The CI builder pins FFmpeg 8.1, mpv 0.41.0, libplacebo 7.360.1, libass 0.17.3, FreeType 2.13.3, FriBidi 1.0.16, and HarfBuzz 8.5.0. FFmpeg disables autodetected external libraries so Homebrew libraries cannot silently enter the runtime. Libplacebo is checked out from git with the submodules required by its Meson build because the generated GitHub archive does not include submodule contents. Even with Vulkan disabled, libplacebo still compiles Vulkan stubs and needs 3rdparty/Vulkan-Headers. The generated manifest records source URLs, archive SHA-256 values where applicable, libplacebo git commit/submodule metadata, FFmpeg configure flags, and mpv Meson flags. The staging step normalizes macOS/Windows manifests to origin: vendored-lgpl, which release package validation requires on those platforms.
The Electron backend build consumes the staged runtime/build inputs and copies macOS/Windows runtime files into the native build output. Linux consumes staged MPV headers when available or distribution development headers for local builds, writes an external-mpv-process manifest, and does not copy libmpv.so into the package. macOS additionally rewrites Mach-O paths so embedded_mpv.node loads @loader_path/lib/libmpv.2.dylib instead of a machine-local Homebrew path. After install_name_tool rewrites any addon or runtime binary, the build re-signs that binary with an ad-hoc signature for local development. Release packaging still performs the normal app signing and notarization later.
For local development before the vendored runtime exists, Homebrew can be used explicitly:
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:
ls apps/electron-backend/native/build/Release/embedded_mpv.node
If only embedded-mpv-unavailable.txt exists, the dev app started from a build where no runtime was available. Stop the Electron dev process and rerun pnpm run serve:backend:embedded-mpv so the native target is rebuilt before Electron starts. The native MPV build target is intentionally uncached because it depends on local runtime files and environment variables such as IPTVNATOR_EMBEDDED_MPV_ALLOW_HOMEBREW and IPTVNATOR_EMBEDDED_MPV_ARCH.
If opening Settings hard-crashes Electron on macOS and the crash report says Code Signature Invalid, one of the copied runtime binaries was modified by install_name_tool without being re-signed. Rebuild the native target and verify the copied addon/runtime files:
IPTVNATOR_EMBEDDED_MPV_ALLOW_HOMEBREW=1 node apps/electron-backend/build-embedded-mpv.js
codesign --verify --verbose=2 apps/electron-backend/native/build/Release/embedded_mpv.node
codesign --verify --verbose=2 apps/electron-backend/native/build/Release/lib/libmpv.2.dylib
If 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}beforepnpm 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=1for packaging and package-layout verification
For Windows builds, CI must restore the win32-x64 staged runtime cache or stage the checksum-pinned runtime archive before 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 must set IPTVNATOR_EMBEDDED_MPV_PLATFORM=linux, IPTVNATOR_EMBEDDED_MPV_ARCH=x64, and IPTVNATOR_REQUIRE_EMBEDDED_MPV=1 after staging the Ubuntu package build inputs. Linux package verification checks the external-mpv-process manifest and confirms that no bundled libmpv.so files are present.
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
libmpvcrash can crash the Electron main process - packaging can fail if
libmpvor 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
HWNDand Linux X11/Xwayland embedding need packaged-app smoke coverage for focus, resize, and fullscreen behavior - Linux native Wayland is unsupported until a dedicated Wayland embedding path exists
- Homebrew
libmpvbuilds can target a newer macOS version than IPTVnator's declared deployment target
It is reasonable to ship the code in-tree behind the current experiment flag. It is not yet safe to make it the default player. It can be exposed as 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 app starts without system
mpvinstalled; Linux reports Embedded MPV unsupported with a clear message when systemmpvis missing - bundled
libmpvand dependent runtime files pass macOS/Windows package validation; Linux package validation confirms the external-process manifest and absence of bundledlibmpv.so - macOS bundled
libmpvand dependent dylibs pass code signing and notarization - VOD resume starts near the saved offset
- series EOF emits
endedand 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
.tsfile in Downloads/custom recording folder, and stops on route/playback changes - fallback behavior is clear when the addon or native dependencies are unavailable