diff --git a/CLAUDE.md b/CLAUDE.md index e5f494d8b..c8af9c203 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -617,7 +617,7 @@ This project uses modern Angular signal-based APIs and patterns. **ALWAYS** use - Built-in HTML5 player with HLS.js or Video.js - External players: MPV, VLC (via IPC to Electron backend) - Embedded MPV (experimental, macOS/Windows/Linux): renders mpv video inside the Electron window through a native addon. macOS uses the libmpv render API in an `NSOpenGLView`; Windows uses in-process libmpv with `--wid` against an app-owned child `HWND`; Linux spawns an out-of-process `mpv --wid=` controlled over a JSON IPC socket (X11/XWayland only, requires system `mpv` on PATH; subtitles/speed/aspect/recording are not exported there). mpv's own screensaver inhibition does not apply to any of these paths, so `EmbeddedMpvNativeService` holds an Electron `powerSaveBlocker` (`prevent-display-sleep`) whenever any session's status is `playing`, and releases it on pause, dispose, or shutdown. Service: `apps/electron-backend/src/app/services/embedded-mpv-native.service.ts`; full architecture: `docs/architecture/embedded-mpv-native.md`. -- Embedded MPV frame-copy engine (experimental, macOS Apple Silicon only, `IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY=1` on top of the embedded MPV experiment flag): a per-session `iptvnator_mpv_helper` process renders mpv offscreen at viewport size and publishes BGRA frames into a shm ring; the preload frame pump uploads them onto a renderer ``, so controls/dialogs are ordinary DOM above the video (no native-surface compositing workarounds; the flag relaxes the window sandbox for the preload's native reader addon). Adapter: `apps/electron-backend/src/app/services/embedded-mpv-frame-copy.adapter.ts`; helper: `apps/electron-backend/native/helper/`; details in `docs/architecture/embedded-mpv-native.md` ("Frame-Copy Engine"). +- Embedded MPV frame-copy engine (experimental, macOS Apple Silicon only; enabled via `Settings > Playback > Embedded MPV: frame-copy engine` (restart required) or `IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY=1` on top of the embedded MPV experiment flag): a per-session `iptvnator_mpv_helper` process renders mpv offscreen at viewport size and publishes BGRA frames into a shm ring; the preload frame pump uploads them onto a renderer ``, so controls/dialogs are ordinary DOM above the video (no native-surface compositing workarounds; the flag relaxes the window sandbox for the preload's native reader addon). Adapter: `apps/electron-backend/src/app/services/embedded-mpv-frame-copy.adapter.ts`; helper: `apps/electron-backend/native/helper/`; details in `docs/architecture/embedded-mpv-native.md` ("Frame-Copy Engine"). **VOD/Series Detail Pages (two-state layout)**: diff --git a/docs/architecture/embedded-mpv-native.md b/docs/architecture/embedded-mpv-native.md index 63054ecc5..9cd8e7f28 100644 --- a/docs/architecture/embedded-mpv-native.md +++ b/docs/architecture/embedded-mpv-native.md @@ -124,6 +124,31 @@ engine that replaces the native-view compositing entirely: runs: the helper re-renders at the new viewport size (device pixels via the display scale factor). +Enabling it: the `Settings > Playback > Embedded MPV: frame-copy engine` +checkbox (shown only when support reports `frameCopyAvailable`) persists to +the main-process config store (`electron-conf`), which `main.ts` reads +before creating the window and translates into the env flag; an explicitly +set env var (including `0`) always wins. Changing the toggle requires an +app restart because the sandbox relaxation is 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=` passes through to mpv's +`audio-delay` for lip-sync tuning until a calibration flow exists. + +Lifecycle safety: `EmbeddedMpvNativeService` watches the main window for +`render-process-gone` and `did-navigate` (full reloads) and disposes every +session — Angular teardown never runs on a renderer crash/hard reload, and +without the watch helper processes (or native mpv handles) would leak until +app shutdown. Unexpected helper exits surface as a session `error`. macOS +package validation requires `iptvnator_mpv_helper` and +`embedded_mpv_frame_reader.node` next to the addon whenever the addon +ships. + Trade-offs and constraints: - The experiment flag relaxes the BrowserWindow sandbox (preload must diff --git a/tools/packaging/electron-package-identity.test.mjs b/tools/packaging/electron-package-identity.test.mjs index 4edcd317b..64d41d931 100644 --- a/tools/packaging/electron-package-identity.test.mjs +++ b/tools/packaging/electron-package-identity.test.mjs @@ -327,6 +327,51 @@ test('embedded MPV package validation accepts Windows runtime files and Linux pr ); } + const darwinResourceDir = join(tempDir, 'darwin'); + const darwinNativeDir = join( + darwinResourceDir, + 'app.asar.unpacked', + 'electron-backend', + 'native' + ); + fs.mkdirSync(join(darwinNativeDir, 'lib'), { recursive: true }); + fs.writeFileSync(join(darwinNativeDir, 'embedded_mpv.node'), ''); + fs.writeFileSync( + join(darwinNativeDir, 'embedded-mpv-runtime.json'), + JSON.stringify({ origin: 'vendored-lgpl' }) + ); + fs.writeFileSync(join(darwinNativeDir, 'lib', 'libmpv.2.dylib'), ''); + + // macOS packages that ship the addon must also ship the frame-copy + // engine artifacts built by the same binding.gyp run. + const missingFrameCopyErrors = validatePackagedEmbeddedMpv( + darwinResourceDir, + { platform: 'darwin', required: true } + ); + assert.ok( + missingFrameCopyErrors.some((error) => + error.includes('iptvnator_mpv_helper') + ) + ); + assert.ok( + missingFrameCopyErrors.some((error) => + error.includes('embedded_mpv_frame_reader.node') + ) + ); + + fs.writeFileSync(join(darwinNativeDir, 'iptvnator_mpv_helper'), ''); + fs.writeFileSync( + join(darwinNativeDir, 'embedded_mpv_frame_reader.node'), + '' + ); + assert.deepEqual( + validatePackagedEmbeddedMpv(darwinResourceDir, { + platform: 'darwin', + required: true, + }), + [] + ); + const linuxResourceDir = join(tempDir, 'linux'); const linuxNativeDir = join( linuxResourceDir, diff --git a/tools/packaging/embedded-mpv-packaging.cjs b/tools/packaging/embedded-mpv-packaging.cjs index 8e3147d1a..08f508e40 100644 --- a/tools/packaging/embedded-mpv-packaging.cjs +++ b/tools/packaging/embedded-mpv-packaging.cjs @@ -540,6 +540,24 @@ function validatePackagedEmbeddedMpv(resourceDir, options = {}) { return errors; } + if (platform === 'darwin') { + // The frame-copy engine artifacts are built by the same binding.gyp + // run as the addon; a macOS package that ships the addon without + // them would silently lose the engine (support probe hides it). + const missingFrameCopyArtifacts = [ + 'iptvnator_mpv_helper', + 'embedded_mpv_frame_reader.node', + ] + .map((name) => path.join(unpackedNativeDir, name)) + .filter((artifactPath) => !fs.existsSync(artifactPath)); + errors.push( + ...missingFrameCopyArtifacts.map( + (artifactPath) => + `Missing embedded MPV frame-copy artifact: ${artifactPath}` + ) + ); + } + if (!fs.existsSync(manifestPath)) { errors.push(`Missing embedded MPV runtime manifest: ${manifestPath}`); } else {