Files
iptvnator/docs/architecture/embedded-mpv-native.md
T
5b2eb515d1 feat(downloads): track live-TV recordings in the download manager (#1452)
* feat(downloads): track live-TV recordings in the download manager

Embedded MPV recordings were written to disk and forgotten: no list, no
reveal/play, no missing-file handling, and the channel/EPG context was lost
the moment the recording stopped. Recordings now live beside downloads:

- New `recordings` table (no unique index, no playlist FK — recordings
  survive source deletion; playlist name stored via playlistDisplayLabel).
- EmbeddedMpvRecordingTracker persists the lifecycle: start/stop hooks plus
  a session-snapshot observer for implicit stops (stream-replacement
  auto-stop, frame-copy helper crash, session error/close); startup repair
  turns rows a hard kill left behind into playable `interrupted` partials.
- Channel/EPG metadata is captured at recording START in all four live
  hosts (M3U, Xtream, Stalker ITV, unified live tab); a clean stop triggers
  renderer-side enrichment with every program overlapping the recorded
  window, keyed by target path — covering recordings that span a program
  boundary. Provider EPG never reaches SQLite, so post-hoc lookup is
  impossible by design.
- Own RECORDINGS_* IPC surface + RECORDINGS_UPDATE_EVENT ping and a
  separate supportsRecordings capability gate (the supportsDownloads
  allowlist is all-or-nothing and stays untouched). Reveal/play shell IPCs
  are gated on the recordings table, so the renderer-supplied recording
  directory stays a write-location preference, not a shell-access grant.
- Manager UI: `recording` filter chip, "Recording now" queue section (REC
  pulse, elapsed, live file size — no percentage, the length is unknown),
  16:9 channel-logo Recordings library, Needs attention with Remove only,
  focused detail at /workspace/downloads/recording/:recordingId.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(downloads): close the stop-enrichment race and repair player stubs

Greptile spotted a real ordering bug: the stop IPC returns as soon as mpv
acknowledges, while the recording row's terminal-state update is still queued
in the tracker. The renderer answers that snapshot with stop enrichment, whose
handler only accepts a terminal row — so the covered-program metadata could be
silently dropped with "Recording not found".

- EmbeddedMpvRecordingTracker.whenSettled() exposes the serialized write
  chain; RECORDINGS_UPDATE_PROGRAMS awaits it before the terminal-row lookup.
  Regression covered from both sides: the handler must not touch the database
  until the barrier resolves, and the barrier must imply a committed row.

CI also caught spec stubs that had not learned the new player inputs (my local
run-many had been an Nx cache hit, so the failures only surfaced in CI):

- Teach the `app-web-player-view` and `app-embedded-mpv-player` stubs the
  `recordingMetadata` input and `recordingStopped` output across the m3u,
  Xtream, Stalker, unified-live-tab and web-player-view specs.
- The races spec now asserts the metadata argument explicitly instead of
  matching a two-argument call.
- Extract the Stalker and unified-live-tab spec stubs into sibling
  `*.spec-stubs.ts` files (the pattern ui/playback already uses) so both specs
  stay under the 1200-line test limit without shaving assertions.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(downloads): make the recordings events spec a module

The spec deliberately has no static imports — every dependency is swapped
through jest.doMock before the harness's dynamic import — which also made it a
TS script rather than a module, so its top-level `registeredHandlers` landed in
the global scope and collided with the same-named const in stream-probe.spec.ts
(TS2451). Local per-project runs compile the specs separately and stayed green;
only the Tier A coverage suite builds them into one program, so CI caught it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(downloads): address Codex review on recording lifecycle

Four findings from the Codex review, all real:

- P1: `addon.stopRecording()` only dispatches — native-view uses
  `mpv_set_property_async`, frame-copy writes a helper command — so
  finalizing inside the stop hook could stat a file mpv had not flushed and
  even unlink bytes still being written. The tracker now treats the hook as a
  request and finalizes on the acknowledged inactive snapshot, with a 10 s
  bound so a lost acknowledgement cannot strand the row. Only a recording
  that never went active has its empty reservation removed. Stop enrichment
  follows through `whenFinalized(targetPath)` (bounded) instead of merely
  draining the write queue.
- Live file size: `file_size_bytes` is written at finalization only, so the
  manager's 15 s refresh reported nothing while recording. Active rows are
  now decorated with a current `fs.stat` size.
- Manager-initiated Stop bypassed both player stop paths, so recordings
  spanning program boundaries kept only the start-time program.
  `EmbeddedMpvPlayerComponent` now owns the active→inactive edge and emits
  `recordingStopped` for every trigger; the adapter and legacy toggle no
  longer emit it themselves.
- Startup recovery could terminate a row another live instance was still
  writing under IPTVNATOR_ALLOW_MULTIPLE_INSTANCES. Rows carry `owner_pid`
  and recovery skips those whose owner process is alive.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(downloads): derive the enrichment wait from the stop fallback

Greptile caught the seam my previous fix left: the enrichment barrier waited
5 s while the tracker's acknowledgement fallback only finalizes at 10 s, so a
stop mpv never confirms let the terminal-row lookup expire early and drop the
covered programs with no retry — precisely the case the fallback exists for.
The wait is now derived from the acknowledgement bound (fallback + 1 s), with
a regression test that fails if the two ever drift apart again.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(downloads): address the second Codex pass on recordings

Four more findings, all real:

- P1 (macOS native-view): `StopRecording` clears `recordingActive` *before*
  dispatching the async property set and restores it if the request is
  rejected, so the first inactive snapshot is optimistic, not an
  acknowledgement — the tracker could finalize (and stat) a file mpv was
  still writing, and a rejected stop would leave the row `completed` while
  recording continued. An inactive snapshot now has to survive a 1.5 s settle
  window (three poll cycles); a revived recording cancels the pending
  finalization.
- Removing a failed row unlinked its path unconditionally, which takes the
  file of a newer recording that reused the freed name within the same
  timestamp second. The cleanup now runs only while no other row claims it.
- The All chip and the header's active badge ignored recordings, so a manager
  holding only recordings read "All 0" and an active recording never showed
  up in the badge.
- Switching channels auto-stops the recording, but by the time the host
  handled the stop its `activeChannel`/EPG already described the NEW channel,
  so the old recording was enriched with the wrong schedule (and an unrelated
  program could be promoted to its title). The stop event now carries the EPG
  key captured while the recording was active and every host compares it
  before enriching.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(downloads): close the persistence race and two recording UX gaps

- Greptile P1: the enrichment deadline (fallback + 1 s) still raced the
  terminal write — if the tracker queue or the UPDATE took longer than the
  remaining margin, `whenFinalized` returned while the row was still
  `recording` and the one-shot enrichment was dropped. The deadline now
  bounds only the wait for mpv; `finalize()` removes the entry synchronously,
  so once it has started the wait follows the write itself.
- Codex: `RECORDINGS_STOP` ignored `owner_pid`. Session ids restart per
  process, so under IPTVNATOR_ALLOW_MULTIPLE_INSTANCES stopping another
  instance's row could stop an unrelated local recording. Foreign rows are
  now refused.
- Codex: the In progress chip counted active recordings while its filter
  deliberately hid them, so clicking it showed "no matches". Active
  recordings now belong to that filter — a chip whose count disagrees with
  its page is a lie.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(downloads): drop the enrichment barrier instead of tuning it

Three review rounds circled the same class: synchronizing mpv's asynchronous
stop acknowledgement with a one-shot program enrichment. Each fix moved the
deadline (5 s → fallback+1 s → wait-on-the-write) without removing the reason
a deadline existed at all — the handler insisted on a *terminal* row.

It never needed one. `openSync('wx')` makes the reserved path exclusive while
a recording owns it, so the newest row for that path IS the recording that was
stopped, and `finalize()` writes only status/end time/size and never
`programs_json`. Enrichment and finalization are therefore order-independent:

- `RECORDINGS_UPDATE_PROGRAMS` matches the newest row for the path in any
  status and awaits only the tracker's write queue, which exists solely to
  guarantee the INSERT committed (a recording stopped milliseconds after it
  started).
- `whenFinalized`, its deadline constant, and the per-entry finalized promise
  are gone; the tracker keeps only the settle window and fallback that make
  *finalization* itself correct.

No behavior is lost and the whole timing class disappears with the code.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(downloads): bind recording finalization to its entry and shield live rows from startup repair

Two races from the Codex review:

- Tracker timers finalized by reusable session id, so a stop followed by an
  immediate restart on the same session let the old settle timer finalize
  the NEW row (marked completed while mpv kept writing) and strand the old
  row in 'recording'. Finalization is now bound to the exact open entry,
  and replacing a session's entry arms the old entry's settle timer so an
  unobserved stop still finalizes it.

- reconcileStaleRecordings() runs after the renderer is interactive; a
  recording started during bootstrap has ownerPid === process.pid and was
  repaired to interrupted/failed mid-write. Recovery now skips rows the
  tracker reports as actively tracked (activeRowIds()).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(downloads): harden recording startup repair against recycled pids and stale renderer lists

Second Codex pass on the recovery path:

- A live ownerPid alone no longer shields a row: after a crash the OS can
  recycle the pid for an unrelated process, which would park the row in
  'recording' with no instance able to finalize it. Recovery now also
  checks (best-effort, ps/tasklist) that the process looks like an
  IPTVnator/Electron instance; an unreadable name stays conservative and
  keeps the skip.

- The renderer loads before the repair pass runs and may already hold the
  pre-repair list with a stale Stop affordance; recovery now broadcasts
  one RECORDINGS_UPDATE_EVENT after changing any rows.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(downloads): defer teardown finalization behind the flush window and bound the live-size stat

Third Codex pass:

- A synthetic error/closed snapshot from disposeSession() arrives while
  the frame-copy helper may still be flushing (0.5 s quit grace + 2 s
  SIGTERM grace before SIGKILL). Finalizing there statted a file mid-write
  — short captures became terminal 'failed', longer rows persisted a
  truncated size, and startup recovery could repair neither. The tracker
  now defers that finalization behind a 2.5 s flush window; the row stays
  'recording' (repairable) meanwhile, and an already-acknowledged stop's
  settle timer keeps its 'completed' verdict instead of being relabelled
  'interrupted'.

- The active row's live file size used a bare await stat(): one stat
  hanging on a dead network filesystem wedged every RECORDINGS_GET_LIST.
  The probe now mirrors the availability probe's contract — in-flight
  coalescing plus a 1 s deadline degrading to no size.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(downloads): unmask recycled recording owners, guard the PWA recording route, and unblock file probes

Fourth Codex pass:

- Recycled-pid discrimination no longer stops at the process-name family
  check (any Electron app could shield the row): a live holder must also
  not provably have started after the recording did (ps -o etime= /
  PowerShell StartTime). A pid frees only when its previous owner dies, so
  a recycled pid's holder is always younger than the recording; unreadable
  evidence stays conservative.

- /workspace/downloads/recording/:recordingId gets a supportsRecordings
  capability guard redirecting the PWA to the manager — RecordingsService
  never becomes authoritative there, so the detail rendered a permanently
  blank workspace.

- Finalization and startup repair stat through a bounded async probe (3 s
  deadline, ENOENT/ENOTDIR as the only proof of absence) instead of
  main-thread statSync: a dead network mount no longer freezes the main
  thread or the tracker queue, repair leaves unjudgeable rows recoverable,
  and finalization keeps the requested status with an unknown size rather
  than branding a likely-good file failed. The 0-byte reservation unlink
  is fire-and-forget for the same reason.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(downloads): keep inconclusive recording probes out of Needs attention and bound repair batches

Fifth Codex pass:

- Recording list decoration now uses the bounded availability variant that
  preserves 'unknown': a timed-out or permission-errored probe is not
  proof of absence, so a good recording on a slow mount no longer lands in
  Needs attention with its Play/Reveal hidden.
  ElectronRecordingItem.fileAvailability widens accordingly; consumers
  already gate on === 'missing'.

- Startup repair probes its whole batch concurrently, so main.ts awaits
  roughly one 3 s deadline instead of one per stale row.

Cross-process ping propagation under IPTVNATOR_ALLOW_MULTIPLE_INSTANCES
stays out of scope (debug-only flag, same single-window design as
DOWNLOADS_UPDATE_EVENT) — rationale left on the review thread.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(downloads): fix duration rounding at hour boundaries and bound owner-process probes

Sixth Codex pass:

- The recording duration formatter rounded minutes after flooring hours,
  so 59:45 read '60 min' and 1:59:45 read '1 h 60 min'. One shared
  recordingDurationLabel() now rounds the total minutes before splitting
  (both the detail page and the library card used a duplicated copy).

- Startup repair's synchronous ps/tasklist/PowerShell ownership probes get
  a 2 s spawn timeout and are memoized per unique pid, so a batch of rows
  from one crashed instance costs at most one name query and one
  start-time query, and a hung process query degrades to the conservative
  fallback instead of blocking the main thread.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(downloads): return to the manager through history from the recording detail

Seventh Codex pass (single finding): with a validated returnUrl the manager
is already the previous history entry, so Back now uses Location.back()
instead of pushing a third entry that made the browser Back button reopen
the detail; router navigation remains the fallback for direct links —
matching the offline-detail navigation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(downloads): bound removal cleanup and shell gates, date interrupted rows by file mtime

Eighth Codex pass:

- RECORDINGS_REMOVE no longer awaits an unbounded unlink of a failed
  row's leftover reservation: cleanup is raced against the 1 s deadline,
  so a hung network unlink cannot keep the Remove action busy — the row
  deletion is what matters.

- Reveal/Play swap the synchronous lstat gate for the bounded async
  availability probe: a dead mount no longer blocks the main process, and
  only PROVEN absence refuses the action — an inconclusive probe lets the
  shell try and answer honestly.

- Startup repair dates an interrupted row's endedAt from the captured
  file's mtime (mpv's last write) instead of the repair time, so an
  overnight shutdown no longer inflates a five-minute capture into an
  hours-long recording; the repair-time fallback remains when mtime is
  unreadable.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(downloads): keep recording-start program metadata fresh across EPG boundaries

Ninth Codex pass (single finding): the unified live tab's
recordingMetadata computed cached its Date.now() verdict — starting a
recording after an EPG boundary snapshotted the previous show. It now
tracks the existing 30 s progress tick. The Stalker live layout's
currentProgram had the same memoization (feeding recording metadata, the
EPG panel summary, and external-player metadata); it gains a 30 s clock
tick with interval cleanup in ngOnDestroy.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(downloads): re-select the Xtream current program against the 30 s tick at recording start

Tenth Codex pass (single finding): the Xtream live layout's recording
snapshot read withEpg().currentEpgItem, a computed whose Date.now()
verdict stays cached until epgItems changes — a recording started after
an EPG boundary snapshotted the previous show. The selection logic is
extracted as the pure findCurrentEpgItem(items, nowMs), the store
computed delegates to it unchanged, and recordingMetadata re-selects
with the layout's existing 30 s currentTimeMs tick.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(downloads): scope stop enrichment to the exact recorded list item

Eleventh Codex pass (single finding): the stop-enrichment guard compared
only the EPG key, which is not unique for M3U items — two list entries
sharing a tvgId (or the display-name fallback) could hand the first
item's recording the second item's schedule after a switch-triggered
auto-stop. RecordingStartMetadata/RecordingStoppedEvent gain an opaque
sourceItemKey (unified tab: item.uid; M3U player: channel.id), captured
while the recording is active exactly like the EPG key, carried through
the player's stop edge, and compared by the hosts before enriching.
Xtream/Stalker keys are already playlist+id-scoped and need no extra key.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(downloads): derive the M3U start-snapshot program from the active channel's schedule

Twelfth Codex pass (single finding): the M3U recording snapshot read the
NgRx currentEpgProgram, which retains its last value across a channel
switch and through EPG gaps (the mirror effect only dispatches when a
program exists) — a recording started on a channel with no airing
program could persist the previous channel's title, which stop
enrichment deliberately never overwrites. The snapshot now derives the
program from the active channel's own schedule against the existing 30 s
clock, and an EPG gap snapshots no program.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(downloads): keep finalizing rows in the recovery ledger and guard the repair update

Thirteenth Codex pass (single finding): finalize() removes an entry from
the open map before its queued terminal update commits, so
activeRowIds() briefly omitted a row still persisted as 'recording' —
startup recovery overlapping a clean stop could relabel it interrupted,
after which the tracker's status-guarded update could not restore
'completed'. Finalizing entries now stay in a dedicated ledger until the
update settles, and the repair UPDATE itself is guarded on
status='recording' as a second belt against a finalization that commits
between recovery's SELECT and its write.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(downloads): register update listeners before the initial list load

Fourteenth Codex pass (single finding): RecordingsService awaited its
initial RECORDINGS_GET_LIST before subscribing to the update ping — a
recording transition during that request pinged into the void while the
response still reflected the pre-transition state, and recording pings
are rare enough that nothing self-healed until the 15 s poll (armed only
once an active row is visible). The listener now registers first so the
load-state coalescing queues the trailing refresh. DownloadsService had
the same latent window and gets the same reorder.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: 4gray <fourgray@proton.me>
2026-08-23 08:05:25 +02:00

79 KiB
Raw Blame History

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
  • 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 player root, 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 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 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.

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.

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.

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. Frame-copy's shared ControlsFullscreen additionally synchronizes when its DOM surface attaches or changes, including when the root 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:

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

Linux x64 builds the release runtime from pinned source inputs and stages it before compiling the helper:

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 uses a checksum-pinned win32-x64 runtime archive configured through IPTVNATOR_WINDOWS_EMBEDDED_MPV_RUNTIME_URL and IPTVNATOR_WINDOWS_EMBEDDED_MPV_RUNTIME_SHA256. Non-tag artifact builds have a pinned zhongfly/mpv-winbuild mpv-dev-lgpl-x86_64 fallback; tagged releases require explicit repository configuration. Upstream retains only its latest 30 daily builds, so the fallback and any repository-variable copy must be refreshed as one URL/checksum pair before expiry. A long-lived mirror must publish the matching source/build records and license notices with the binary. The archive helper accepts normal lib/ + bin/ prefixes and common flat archives, preserves the DLL basename encoded by the import library, and generates minimal build metadata only when the archive lacks it.

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:

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} 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 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 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 /usr/lib/iptvnator layout 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