diff --git a/.github/workflows/build-and-make.yaml b/.github/workflows/build-and-make.yaml index caa78d4ee..36ace9e00 100644 --- a/.github/workflows/build-and-make.yaml +++ b/.github/workflows/build-and-make.yaml @@ -23,18 +23,33 @@ jobs: - os: macos runner: macos-15-intel arch: x64 + embedded_mpv_platform: darwin + embedded_mpv_arch: x64 + embedded_mpv_build_runtime: true - os: macos runner: macos-latest arch: arm64 + embedded_mpv_platform: darwin + embedded_mpv_arch: arm64 + embedded_mpv_build_runtime: true # Linux and Windows - os: linux runner: ubuntu-22.04 linux_profile: standard + embedded_mpv_platform: linux + embedded_mpv_arch: x64 + embedded_mpv_build_runtime: false - os: linux runner: ubuntu-24.04 linux_profile: flatpak + embedded_mpv_platform: linux + embedded_mpv_arch: x64 + embedded_mpv_build_runtime: false - os: windows runner: windows-latest + embedded_mpv_platform: win32 + embedded_mpv_arch: x64 + embedded_mpv_build_runtime: false steps: - name: Checkout code @@ -53,7 +68,7 @@ jobs: if: matrix.os == 'linux' run: | sudo apt-get update - sudo apt-get install --no-install-recommends -y rpm libarchive-tools flatpak flatpak-builder appstream + sudo apt-get install --no-install-recommends -y rpm libarchive-tools flatpak flatpak-builder appstream libx11-dev libxext-dev # Configure Flatpak # 1. Add the Flathub repository (source of runtimes) @@ -91,84 +106,115 @@ jobs: - name: Resolve embedded MPV runtime cache key # TEMPORARY ARTIFACT TEST: remove `|| github.event_name == 'pull_request' || github.ref == 'refs/heads/master'` # after the macOS Embedded MPV artifacts are built and manually tested. - if: matrix.os == 'macos' && !startsWith(github.ref, 'refs/tags/v') && (github.event_name == 'pull_request' || github.ref == 'refs/heads/master') + if: matrix.embedded_mpv_platform && !startsWith(github.ref, 'refs/tags/v') && (github.event_name == 'pull_request' || github.ref == 'refs/heads/master') id: embedded-mpv-runtime-cache-key shell: bash run: | set -euo pipefail - deployment_target="${MACOSX_DEPLOYMENT_TARGET:-11.0}" - safe_deployment_target="$(printf '%s' "${deployment_target}" | tr -c 'A-Za-z0-9_.-' '-')" - xcode_version="$(xcodebuild -version | tr '\n' ' ' | sed 's/[[:space:]]*$//')" - xcode_hash="$(printf '%s' "${xcode_version}" | shasum -a 256 | awk '{print $1}')" - # Runtime source pins, source URLs, git tags, and build flags - # live in build-macos-runtime.mjs; staging layout and manifest - # normalization live in stage-macos-runtime.mjs. - source_hash="$( - shasum -a 256 \ - tools/embedded-mpv/build-macos-runtime.mjs \ - tools/embedded-mpv/stage-macos-runtime.mjs | - shasum -a 256 | - awk '{print $1}' - )" - cache_key="embedded-mpv-runtime-v1-${RUNNER_OS}-${{ matrix.arch }}-macos${safe_deployment_target}-xcode${xcode_hash}-${source_hash}" + node <<'NODE' + const childProcess = require('child_process'); + const crypto = require('crypto'); + const fs = require('fs'); - echo "deployment-target=${deployment_target}" >> "${GITHUB_OUTPUT}" - echo "xcode-version=${xcode_version}" >> "${GITHUB_OUTPUT}" - echo "key=${cache_key}" >> "${GITHUB_OUTPUT}" - echo "Embedded MPV runtime cache key: ${cache_key}" - echo "Xcode version: ${xcode_version}" + const hash = (value) => crypto.createHash('sha256').update(value).digest('hex'); + const targetPlatform = '${{ matrix.embedded_mpv_platform }}'; + const targetArch = '${{ matrix.embedded_mpv_arch }}'; + const sourceHashFiles = [ + 'tools/embedded-mpv/stage-runtime.mjs', + ]; + const cacheKeyParts = [ + 'embedded-mpv-runtime-v2', + targetPlatform, + targetArch, + process.env.RUNNER_OS, + ]; + let deploymentTarget = ''; + let xcodeVersion = 'none'; + if (targetPlatform === 'darwin') { + deploymentTarget = process.env.MACOSX_DEPLOYMENT_TARGET || '11.0'; + const safeDeploymentTarget = deploymentTarget.replace(/[^A-Za-z0-9_.-]/g, '-'); + sourceHashFiles.push( + 'tools/embedded-mpv/build-macos-runtime.mjs', + 'tools/embedded-mpv/stage-macos-runtime.mjs' + ); + try { + xcodeVersion = childProcess + .execSync('xcodebuild -version', { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }) + .replace(/\s+$/g, '') + .replace(/\n/g, ' '); + } catch { + xcodeVersion = 'none'; + } + cacheKeyParts.push( + `macos${safeDeploymentTarget}`, + `xcode${hash(xcodeVersion)}` + ); + } + const sourceHash = hash( + sourceHashFiles.map((filePath) => fs.readFileSync(filePath)).join('\n') + ); + cacheKeyParts.push(sourceHash); + const cacheKey = cacheKeyParts.join('-'); + + fs.appendFileSync(process.env.GITHUB_OUTPUT, `deployment-target=${deploymentTarget}\n`); + fs.appendFileSync(process.env.GITHUB_OUTPUT, `xcode-version=${xcodeVersion}\n`); + fs.appendFileSync(process.env.GITHUB_OUTPUT, `key=${cacheKey}\n`); + console.log(`Embedded MPV runtime cache key: ${cacheKey}`); + console.log(`Xcode version: ${xcodeVersion}`); + NODE - name: Restore embedded MPV runtime cache # TEMPORARY ARTIFACT TEST: remove `|| github.event_name == 'pull_request' || github.ref == 'refs/heads/master'` # after the macOS Embedded MPV artifacts are built and manually tested. - if: matrix.os == 'macos' && !startsWith(github.ref, 'refs/tags/v') && (github.event_name == 'pull_request' || github.ref == 'refs/heads/master') + if: matrix.embedded_mpv_platform && !startsWith(github.ref, 'refs/tags/v') && (github.event_name == 'pull_request' || github.ref == 'refs/heads/master') id: embedded-mpv-runtime-cache uses: actions/cache/restore@v4 with: path: | - vendor/embedded-mpv/darwin-${{ matrix.arch }}/include - vendor/embedded-mpv/darwin-${{ matrix.arch }}/lib - vendor/embedded-mpv/darwin-${{ matrix.arch }}/runtime-manifest.json + vendor/embedded-mpv/${{ matrix.embedded_mpv_platform }}-${{ matrix.embedded_mpv_arch }}/include + vendor/embedded-mpv/${{ matrix.embedded_mpv_platform }}-${{ matrix.embedded_mpv_arch }}/lib + vendor/embedded-mpv/${{ matrix.embedded_mpv_platform }}-${{ matrix.embedded_mpv_arch }}/runtime-manifest.json key: ${{ steps.embedded-mpv-runtime-cache-key.outputs.key }} - name: Clear stale embedded MPV runtime files # TEMPORARY ARTIFACT TEST: remove `|| github.event_name == 'pull_request' || github.ref == 'refs/heads/master'` # after the macOS Embedded MPV artifacts are built and manually tested. - if: matrix.os == 'macos' && !startsWith(github.ref, 'refs/tags/v') && (github.event_name == 'pull_request' || github.ref == 'refs/heads/master') && steps.embedded-mpv-runtime-cache.outputs.cache-hit != 'true' + if: matrix.embedded_mpv_platform && !startsWith(github.ref, 'refs/tags/v') && (github.event_name == 'pull_request' || github.ref == 'refs/heads/master') && steps.embedded-mpv-runtime-cache.outputs.cache-hit != 'true' shell: bash run: | set -euo pipefail rm -rf \ - "vendor/embedded-mpv/darwin-${{ matrix.arch }}/include" \ - "vendor/embedded-mpv/darwin-${{ matrix.arch }}/lib" \ - "vendor/embedded-mpv/darwin-${{ matrix.arch }}/runtime-manifest.json" + "vendor/embedded-mpv/${{ matrix.embedded_mpv_platform }}-${{ matrix.embedded_mpv_arch }}/include" \ + "vendor/embedded-mpv/${{ matrix.embedded_mpv_platform }}-${{ matrix.embedded_mpv_arch }}/lib" \ + "vendor/embedded-mpv/${{ matrix.embedded_mpv_platform }}-${{ matrix.embedded_mpv_arch }}/runtime-manifest.json" - name: Build embedded MPV runtime (macOS release) # TEMPORARY ARTIFACT TEST: remove `|| github.event_name == 'pull_request' || github.ref == 'refs/heads/master'` # after the macOS Embedded MPV artifacts are built and manually tested. - if: matrix.os == 'macos' && (startsWith(github.ref, 'refs/tags/v') || github.event_name == 'pull_request' || github.ref == 'refs/heads/master') && steps.embedded-mpv-runtime-cache.outputs.cache-hit != 'true' + if: matrix.embedded_mpv_build_runtime && (startsWith(github.ref, 'refs/tags/v') || github.event_name == 'pull_request' || github.ref == 'refs/heads/master') && steps.embedded-mpv-runtime-cache.outputs.cache-hit != 'true' shell: bash run: | set -euo pipefail brew install meson ninja pkg-config nasm autoconf automake libtool xz - RUNTIME_PREFIX="${RUNNER_TEMP}/embedded-mpv-runtime/${{ matrix.arch }}/prefix" - pnpm embedded-mpv:build-runtime -- "${{ matrix.arch }}" "${RUNTIME_PREFIX}" - pnpm embedded-mpv:stage-runtime -- "${{ matrix.arch }}" "${RUNTIME_PREFIX}" + RUNTIME_PREFIX="${RUNNER_TEMP}/embedded-mpv-runtime/${{ matrix.embedded_mpv_arch }}/prefix" + pnpm embedded-mpv:build-runtime -- "${{ matrix.embedded_mpv_arch }}" "${RUNTIME_PREFIX}" + pnpm embedded-mpv:stage-runtime -- "${{ matrix.embedded_mpv_platform }}" "${{ matrix.embedded_mpv_arch }}" "${RUNTIME_PREFIX}" - name: Build backend env: - IPTVNATOR_EMBEDDED_MPV_ARCH: ${{ matrix.arch || '' }} - IPTVNATOR_REQUIRE_EMBEDDED_MPV: ${{ matrix.os == 'macos' && (startsWith(github.ref, 'refs/tags/v') || github.event_name == 'pull_request' || github.ref == 'refs/heads/master') && '1' || '0' }} + IPTVNATOR_EMBEDDED_MPV_PLATFORM: ${{ matrix.embedded_mpv_platform || '' }} + IPTVNATOR_EMBEDDED_MPV_ARCH: ${{ matrix.embedded_mpv_arch || matrix.arch || '' }} + IPTVNATOR_REQUIRE_EMBEDDED_MPV: ${{ ((matrix.os == 'macos' && (startsWith(github.ref, 'refs/tags/v') || github.event_name == 'pull_request' || github.ref == 'refs/heads/master')) || (steps.embedded-mpv-runtime-cache.outputs.cache-hit == 'true')) && '1' || '0' }} run: pnpm run build:backend - name: Verify embedded MPV build output # TEMPORARY ARTIFACT TEST: remove `|| github.event_name == 'pull_request' || github.ref == 'refs/heads/master'` # after the macOS Embedded MPV artifacts are built and manually tested. - if: matrix.os == 'macos' && (startsWith(github.ref, 'refs/tags/v') || github.event_name == 'pull_request' || github.ref == 'refs/heads/master') + if: (matrix.os == 'macos' && (startsWith(github.ref, 'refs/tags/v') || github.event_name == 'pull_request' || github.ref == 'refs/heads/master')) || steps.embedded-mpv-runtime-cache.outputs.cache-hit == 'true' shell: bash run: | set -euo pipefail @@ -184,7 +230,17 @@ jobs: test -f apps/electron-backend/native/build/Release/embedded_mpv.node test -f dist/apps/electron-backend/native/embedded_mpv.node test -f dist/apps/electron-backend/native/embedded-mpv-runtime.json - test -f dist/apps/electron-backend/native/lib/libmpv.2.dylib + case "${{ matrix.embedded_mpv_platform }}" in + darwin) + test -f dist/apps/electron-backend/native/lib/libmpv.2.dylib || test -f dist/apps/electron-backend/native/lib/libmpv.dylib + ;; + win32) + test -f dist/apps/electron-backend/native/lib/mpv-2.dll || test -f dist/apps/electron-backend/native/lib/mpv.dll + ;; + linux) + test -f dist/apps/electron-backend/native/lib/libmpv.so.2 || test -f dist/apps/electron-backend/native/lib/libmpv.so.1 || test -f dist/apps/electron-backend/native/lib/libmpv.so + ;; + esac - name: Validate AppStream metadata if: matrix.os == 'linux' @@ -305,7 +361,8 @@ jobs: CSC_LINK: ${{ secrets.CSC_LINK }} CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }} DEBUG: electron-builder,electron-notarize* - IPTVNATOR_EMBEDDED_MPV_ARCH: ${{ matrix.arch }} + IPTVNATOR_EMBEDDED_MPV_PLATFORM: ${{ matrix.embedded_mpv_platform }} + IPTVNATOR_EMBEDDED_MPV_ARCH: ${{ matrix.embedded_mpv_arch }} # TEMPORARY MASTER ARTIFACT TEST: remove `|| github.ref == 'refs/heads/master'` # after the macOS Embedded MPV artifacts are built and manually tested. IPTVNATOR_REQUIRE_EMBEDDED_MPV: ${{ (startsWith(github.ref, 'refs/tags/v') || github.ref == 'refs/heads/master') && '1' || '0' }} @@ -363,10 +420,11 @@ jobs: - name: Make Electron app if: matrix.os != 'macos' || github.event_name == 'pull_request' env: - IPTVNATOR_EMBEDDED_MPV_ARCH: ${{ matrix.arch || '' }} + IPTVNATOR_EMBEDDED_MPV_PLATFORM: ${{ matrix.embedded_mpv_platform || '' }} + IPTVNATOR_EMBEDDED_MPV_ARCH: ${{ matrix.embedded_mpv_arch || matrix.arch || '' }} # TEMPORARY PR TEST: change this back to '0' after manually # testing the macOS PR artifact with Embedded MPV included. - IPTVNATOR_REQUIRE_EMBEDDED_MPV: ${{ matrix.os == 'macos' && github.event_name == 'pull_request' && '1' || '0' }} + IPTVNATOR_REQUIRE_EMBEDDED_MPV: ${{ ((matrix.os == 'macos' && github.event_name == 'pull_request') || (steps.embedded-mpv-runtime-cache.outputs.cache-hit == 'true')) && '1' || '0' }} run: pnpm run make:app - name: Verify packaged worker layout @@ -376,19 +434,19 @@ jobs: PACKAGE_ARCH: ${{ matrix.arch || '' }} # TEMPORARY ARTIFACT TEST: remove `|| github.event_name == 'pull_request' || github.ref == 'refs/heads/master'` # after the macOS Embedded MPV artifacts are built and manually tested. - IPTVNATOR_REQUIRE_EMBEDDED_MPV: ${{ matrix.os == 'macos' && (startsWith(github.ref, 'refs/tags/v') || github.event_name == 'pull_request' || github.ref == 'refs/heads/master') && '1' || '0' }} + IPTVNATOR_REQUIRE_EMBEDDED_MPV: ${{ ((matrix.os == 'macos' && (startsWith(github.ref, 'refs/tags/v') || github.event_name == 'pull_request' || github.ref == 'refs/heads/master')) || (steps.embedded-mpv-runtime-cache.outputs.cache-hit == 'true')) && '1' || '0' }} run: pnpm run verify:package-layout -- "$PACKAGE_OS" "$PACKAGE_ARCH" - name: Save embedded MPV runtime cache # TEMPORARY ARTIFACT TEST: remove `|| github.ref == 'refs/heads/master'` # after the macOS Embedded MPV artifacts are built and manually tested. - if: matrix.os == 'macos' && !startsWith(github.ref, 'refs/tags/v') && github.repository == '4gray/iptvnator' && github.event_name != 'pull_request' && github.ref == 'refs/heads/master' && steps.embedded-mpv-runtime-cache.outputs.cache-hit != 'true' + if: matrix.embedded_mpv_build_runtime && !startsWith(github.ref, 'refs/tags/v') && github.repository == '4gray/iptvnator' && github.event_name != 'pull_request' && github.ref == 'refs/heads/master' && steps.embedded-mpv-runtime-cache.outputs.cache-hit != 'true' uses: actions/cache/save@v4 with: path: | - vendor/embedded-mpv/darwin-${{ matrix.arch }}/include - vendor/embedded-mpv/darwin-${{ matrix.arch }}/lib - vendor/embedded-mpv/darwin-${{ matrix.arch }}/runtime-manifest.json + vendor/embedded-mpv/${{ matrix.embedded_mpv_platform }}-${{ matrix.embedded_mpv_arch }}/include + vendor/embedded-mpv/${{ matrix.embedded_mpv_platform }}-${{ matrix.embedded_mpv_arch }}/lib + vendor/embedded-mpv/${{ matrix.embedded_mpv_platform }}-${{ matrix.embedded_mpv_arch }}/runtime-manifest.json key: ${{ steps.embedded-mpv-runtime-cache-key.outputs.key }} - name: Smoke test packaged Flatpak launcher diff --git a/.plans/2026-06-08-embedded-mpv-linux-windows.md b/.plans/2026-06-08-embedded-mpv-linux-windows.md new file mode 100644 index 000000000..d973ed1a8 --- /dev/null +++ b/.plans/2026-06-08-embedded-mpv-linux-windows.md @@ -0,0 +1,43 @@ +# Embedded MPV for Windows and Linux + +## Summary + +Add support through three implementation slices: + +1. Foundation: extract the shared libmpv session/IPC core from the macOS-only native source and generalize build/runtime/package contracts without enabling new platforms. +2. Windows: add a Windows x64 backend using `HWND` plus mpv `wid`, runtime staging, packaging, and CI verification. +3. Linux: add a Linux x64 X11/Xwayland backend using an X11 `Window` plus mpv `wid`, runtime staging, packaging, and CI verification. + +Reuse the current Angular UI, IPC bridge, `EmbeddedMpvSession` contract, Settings support probe, polling/session logic, recording path logic, and E2E infrastructure. The main work is native addon structure, runtime tooling, package verification, and GitHub Actions. + +## Key Changes + +- Split `apps/electron-backend/native/src/embedded_mpv.mm` into shared C++ session/event/command code plus platform backends. +- Keep macOS render backend behavior unchanged. +- Add Windows x64 and Linux x64 platform backends while preserving existing N-API exports. +- Generalize `vendor/embedded-mpv/-/` beyond `darwin-*`, adding `win32-x64` and `linux-x64`. +- Replace macOS-only build/stage helpers with cross-platform helpers, keeping macOS script names as compatibility wrappers. +- Bundle only LGPL-compatible dynamic runtimes with manifests; release builds reject system-package/Homebrew-style runtime origins. +- Update `electron-builder.json` to unpack/copy `.node`, `.dylib`, `.dll`, `.so`, `.so.*`, and runtime manifests. +- Extend package layout verification for macOS, Windows, and Linux. +- Keep existing renderer IPC shape; `EmbeddedMpvSupport.platform` may now report `win32` or `linux`. +- Show Embedded MPV in Settings on Windows/Linux only when `getEmbeddedMpvSupport()` reports `supported: true`. +- Extend `.github/workflows/build-and-make.yaml` with platform/arch-aware embedded MPV cache keys and runtime build/stage steps. + +## Test Plan + +- `pnpm nx test electron-backend`: platform support matrix, missing runtime reasons, addon candidate paths, recording/session behavior after native split. +- `pnpm nx test web`: Settings option visibility for `darwin`, `win32`, `linux`, unsupported arch, and unsupported Wayland. +- `pnpm nx test ui-playback`: embedded player capability-driven controls remain stable. +- `pnpm nx test packaging`: `electron-builder.json`, `asarUnpack`, runtime manifest, and package layout checks for `.dll`/`.so`. +- `pnpm nx run electron-backend:build-embedded-mpv` on macOS, Windows x64, and Linux x64. +- `pnpm run build:backend`. +- `pnpm run verify:package-layout -- macos `, `-- windows x64`, and `-- linux x64`. +- Extend `electron-backend-e2e:e2e-ci--src/settings.e2e.ts`. +- For supported OSes, run Electron with CDP port `9222` and verify with `agent-browser --cdp 9222 tab`, `snapshot -i`, and `screenshot`. + +## Assumptions + +- v1 supports Windows x64 and Linux x64 X11/Xwayland only. +- Linux native Wayland support is out of scope for these PRs because Electron and mpv embedding both have platform limitations there. +- Existing macOS behavior must not regress. diff --git a/apps/electron-backend/build-embedded-mpv.js b/apps/electron-backend/build-embedded-mpv.js index 73ed27634..c12ad9b5f 100644 --- a/apps/electron-backend/build-embedded-mpv.js +++ b/apps/electron-backend/build-embedded-mpv.js @@ -6,7 +6,7 @@ const { findLibMpv, patchAddonForBundledRuntime, validateNoForbiddenRuntimeLinks, -} = require('../../tools/packaging/embedded-mpv-macos.cjs'); +} = require('../../tools/packaging/embedded-mpv-packaging.cjs'); const workspaceRoot = process.cwd(); const addonRoot = path.join(workspaceRoot, 'apps', 'electron-backend', 'native'); @@ -23,6 +23,8 @@ const distNativeDir = path.join( const unavailableMarkerFile = path.join(outputDir, 'embedded-mpv-unavailable.txt'); const homebrewIncludeDir = '/opt/homebrew/include'; const homebrewLibDir = '/opt/homebrew/lib'; +const targetPlatform = + process.env.IPTVNATOR_EMBEDDED_MPV_PLATFORM || process.platform; const targetArch = process.env.IPTVNATOR_EMBEDDED_MPV_ARCH || process.env.npm_config_arch || @@ -31,10 +33,11 @@ const vendoredRuntimeRoot = path.join( workspaceRoot, 'vendor', 'embedded-mpv', - `darwin-${targetArch}` + `${targetPlatform}-${targetArch}` ); const vendoredIncludeDir = path.join(vendoredRuntimeRoot, 'include'); const vendoredLibDir = path.join(vendoredRuntimeRoot, 'lib'); +const vendoredBinDir = path.join(vendoredRuntimeRoot, 'bin'); const homebrewFallbackEnabled = process.env.IPTVNATOR_EMBEDDED_MPV_ALLOW_HOMEBREW === '1'; const embeddedMpvRequired = ['1', 'true', 'yes', 'on'].includes( @@ -48,6 +51,9 @@ function log(message) { function cleanOutput() { fs.rmSync(outputFile, { force: true }); fs.rmSync(outputLibDir, { recursive: true, force: true }); + for (const windowsDllName of ['mpv-2.dll', 'mpv.dll']) { + fs.rmSync(path.join(outputDir, windowsDllName), { force: true }); + } fs.rmSync(path.join(outputDir, '.deps'), { recursive: true, force: true }); fs.rmSync(path.join(outputDir, 'obj.target'), { recursive: true, @@ -75,20 +81,113 @@ function readRuntimeManifest(runtimeRoot) { return JSON.parse(fs.readFileSync(manifestPath, 'utf8')); } +function fileExists(filePath) { + return fs.existsSync(filePath) && fs.statSync(filePath).isFile(); +} + +function listRuntimeFiles(runtimeDir, predicate) { + if (!runtimeDir || !fs.existsSync(runtimeDir)) { + return []; + } + + return fs + .readdirSync(runtimeDir, { withFileTypes: true }) + .filter((entry) => entry.isFile() || entry.isSymbolicLink()) + .map((entry) => path.join(runtimeDir, entry.name)) + .filter((filePath) => { + try { + return fs.statSync(filePath).isFile(); + } catch { + return false; + } + }) + .filter(predicate) + .sort(); +} + +function runtimeFilePredicate(filePath) { + const fileName = path.basename(filePath); + + switch (targetPlatform) { + case 'darwin': + return fileName.endsWith('.dylib'); + case 'win32': + return fileName.endsWith('.dll') || fileName.endsWith('.lib'); + case 'linux': + return /\.so(?:\.\d+)*$/.test(fileName); + default: + return false; + } +} + +function findWindowsImportLib(libDir) { + for (const candidate of ['mpv.lib', 'mpv-2.lib', 'libmpv.dll.a']) { + const candidatePath = path.join(libDir, candidate); + if (fileExists(candidatePath)) { + return candidatePath; + } + } + + return null; +} + +function findWindowsLibMpv(runtimeRoot) { + for (const candidate of [ + path.join(runtimeRoot, 'lib', 'mpv-2.dll'), + path.join(runtimeRoot, 'bin', 'mpv-2.dll'), + path.join(runtimeRoot, 'lib', 'mpv.dll'), + path.join(runtimeRoot, 'bin', 'mpv.dll'), + ]) { + if (fileExists(candidate)) { + return candidate; + } + } + + return null; +} + +function findLinuxLibMpv(libDir) { + for (const candidate of ['libmpv.so.2', 'libmpv.so.1', 'libmpv.so']) { + const candidatePath = path.join(libDir, candidate); + if (fileExists(candidatePath)) { + return candidatePath; + } + } + + return null; +} + function resolveRuntime() { - const vendoredLibMpv = findLibMpv(vendoredLibDir); + const vendoredLibMpv = + targetPlatform === 'darwin' + ? findLibMpv(vendoredLibDir) + : targetPlatform === 'win32' + ? findWindowsLibMpv(vendoredRuntimeRoot) + : targetPlatform === 'linux' + ? findLinuxLibMpv(vendoredLibDir) + : null; const vendoredHeader = path.join(vendoredIncludeDir, 'mpv', 'client.h'); if (vendoredLibMpv && fs.existsSync(vendoredHeader)) { + const windowsImportLib = + targetPlatform === 'win32' + ? findWindowsImportLib(vendoredLibDir) + : null; + if (targetPlatform === 'win32' && !windowsImportLib) { + return null; + } + return { origin: 'vendored-lgpl', includeDir: vendoredIncludeDir, libDir: vendoredLibDir, + binDir: vendoredBinDir, manifest: readRuntimeManifest(vendoredRuntimeRoot), + windowsImportLib, }; } - if (homebrewFallbackEnabled) { + if (targetPlatform === 'darwin' && homebrewFallbackEnabled) { const homebrewLibMpv = findLibMpv(homebrewLibDir); const homebrewHeader = path.join(homebrewIncludeDir, 'mpv', 'client.h'); if (homebrewLibMpv && fs.existsSync(homebrewHeader)) { @@ -99,10 +198,12 @@ function resolveRuntime() { origin: 'homebrew-dev', includeDir: homebrewIncludeDir, libDir: homebrewLibDir, + binDir: undefined, manifest: { warning: 'Development-only runtime. Do not ship this in release artifacts.', }, + windowsImportLib: null, }; } } @@ -110,6 +211,68 @@ function resolveRuntime() { return null; } +function copyFile(sourcePath, destinationPath) { + fs.mkdirSync(path.dirname(destinationPath), { recursive: true }); + fs.copyFileSync(sourcePath, destinationPath); + fs.chmodSync(destinationPath, 0o755); +} + +function copyGenericRuntimeToNativeBuild(runtime) { + fs.rmSync(outputLibDir, { recursive: true, force: true }); + fs.mkdirSync(outputLibDir, { recursive: true }); + + const runtimeFiles = [ + ...listRuntimeFiles(runtime.libDir, runtimeFilePredicate), + ...listRuntimeFiles(runtime.binDir, runtimeFilePredicate), + ]; + const copiedFiles = new Set(); + + for (const runtimeFile of runtimeFiles) { + const fileName = path.basename(runtimeFile); + if (copiedFiles.has(fileName)) { + continue; + } + copyFile(runtimeFile, path.join(outputLibDir, fileName)); + copiedFiles.add(fileName); + } + + if (targetPlatform === 'win32') { + for (const fileName of copiedFiles) { + if (fileName.endsWith('.dll')) { + copyFile( + path.join(outputLibDir, fileName), + path.join(outputDir, fileName) + ); + } + } + } + + if (targetPlatform === 'linux') { + const libMpvPath = findLinuxLibMpv(outputLibDir); + if (libMpvPath && path.basename(libMpvPath) !== 'libmpv.so') { + copyFile(libMpvPath, path.join(outputLibDir, 'libmpv.so')); + copiedFiles.add('libmpv.so'); + } + } + + const manifest = { + origin: runtime.origin, + generatedAt: new Date().toISOString(), + libDir: 'lib', + runtimeFiles: [...copiedFiles].sort(), + ...runtime.manifest, + platform: targetPlatform, + targetArch, + }; + + fs.writeFileSync( + path.join(outputDir, 'embedded-mpv-runtime.json'), + `${JSON.stringify(manifest, null, 2)}\n` + ); + + return manifest; +} + function resolveElectronNodeGypBin() { const pnpmRoot = path.join(workspaceRoot, 'node_modules', '.pnpm'); @@ -167,12 +330,16 @@ function main() { fs.mkdirSync(outputDir, { recursive: true }); cleanDistNativeOutput(); - if (process.platform !== 'darwin') { + if (targetPlatform !== process.platform) { cleanOutput(); if (embeddedMpvRequired) { - throw new Error('Embedded MPV is required but this host is not macOS.'); + throw new Error( + `Embedded MPV is required for ${targetPlatform}-${targetArch}, but this host is ${process.platform}-${process.arch}.` + ); } - log('Skipping build on non-macOS host.'); + log( + `Skipping build for ${targetPlatform}-${targetArch} on ${process.platform}-${process.arch}.` + ); return; } @@ -180,9 +347,11 @@ function main() { if (!runtime) { cleanOutput(); const message = [ - `Skipping build because no embedded MPV runtime was found for darwin-${targetArch}.`, + `Skipping build because no embedded MPV runtime was found for ${targetPlatform}-${targetArch}.`, `Expected vendored runtime at ${vendoredRuntimeRoot}.`, - 'For local development only, set IPTVNATOR_EMBEDDED_MPV_ALLOW_HOMEBREW=1 to use Homebrew libmpv.', + targetPlatform === 'darwin' + ? 'For local development only, set IPTVNATOR_EMBEDDED_MPV_ALLOW_HOMEBREW=1 to use Homebrew libmpv.' + : 'Stage a vendored LGPL-compatible runtime before requiring Embedded MPV on this platform.', ].join('\n'); if (embeddedMpvRequired) { throw new Error(message); @@ -192,15 +361,18 @@ function main() { return; } - const runtimeManifest = copyRuntimeToNativeBuild({ - runtimeLibDir: runtime.libDir, - outputLibDir, - runtimeOrigin: runtime.origin, - runtimeManifest: { - targetArch, - ...runtime.manifest, - }, - }); + const runtimeManifest = + targetPlatform === 'darwin' + ? copyRuntimeToNativeBuild({ + runtimeLibDir: runtime.libDir, + outputLibDir, + runtimeOrigin: runtime.origin, + runtimeManifest: { + targetArch, + ...runtime.manifest, + }, + }) + : copyGenericRuntimeToNativeBuild(runtime); fs.rmSync(unavailableMarkerFile, { force: true }); const electronPackageJson = require(path.join( @@ -220,10 +392,13 @@ function main() { npm_config_update_binary: 'false', LIBMPV_INCLUDE_DIR: runtime.includeDir, LIBMPV_LIBRARY_DIR: outputLibDir, + ...(runtime.windowsImportLib + ? { LIBMPV_IMPORT_LIB: path.join(outputLibDir, path.basename(runtime.windowsImportLib)) } + : {}), }; log( - `Building native addon against Electron ${electronVersion} using ${runtime.origin} runtime for darwin-${targetArch}...` + `Building native addon against Electron ${electronVersion} using ${runtime.origin} runtime for ${targetPlatform}-${targetArch}...` ); runNodeGyp('configure', env); runNodeGyp('build', env); @@ -232,13 +407,17 @@ function main() { throw new Error(`Build finished without producing ${outputFile}.`); } - patchAddonForBundledRuntime(outputFile, outputLibDir); - const forbiddenLinkErrors = validateNoForbiddenRuntimeLinks([ - outputFile, - ...runtimeManifest.dylibs.map((dylib) => path.join(outputLibDir, dylib)), - ]); - if (runtime.origin === 'vendored-lgpl' && forbiddenLinkErrors.length > 0) { - throw new Error(forbiddenLinkErrors.join('\n')); + if (targetPlatform === 'darwin') { + patchAddonForBundledRuntime(outputFile, outputLibDir); + const forbiddenLinkErrors = validateNoForbiddenRuntimeLinks([ + outputFile, + ...runtimeManifest.dylibs.map((dylib) => + path.join(outputLibDir, dylib) + ), + ]); + if (runtime.origin === 'vendored-lgpl' && forbiddenLinkErrors.length > 0) { + throw new Error(forbiddenLinkErrors.join('\n')); + } } log(`Built ${path.relative(workspaceRoot, outputFile)}.`); diff --git a/apps/electron-backend/native/binding.gyp b/apps/electron-backend/native/binding.gyp index a829bc56a..4a637da2e 100644 --- a/apps/electron-backend/native/binding.gyp +++ b/apps/electron-backend/native/binding.gyp @@ -2,12 +2,11 @@ "targets": [ { "target_name": "embedded_mpv", - "sources": [ - "src/embedded_mpv.mm" - ], + "sources": [], "include_dirs": [ "<(module_root_dir)/../../../node_modules/.pnpm/node_modules/node-addon-api", - " +#include + +#include "embedded_mpv_wid_types.h" + +#include +#include +#include +#include +#include +#include + +namespace { + +class NativeVideoHost { +public: + static bool isAvailable() + { + return std::getenv("DISPLAY") != nullptr; + } + + static std::string lastError() + { + return lastError_; + } + + bool create(uintptr_t parentHandle, const Bounds& bounds) + { + if (!isAvailable()) { + lastError_ = + "Embedded MPV on Linux requires X11 or Xwayland; DISPLAY is not set."; + return false; + } + + display_ = XOpenDisplay(nullptr); + if (!display_) { + lastError_ = "Unable to open the X11 display for embedded MPV."; + return false; + } + + parentWindow_ = static_cast(parentHandle); + if (!parentWindow_) { + lastError_ = "Unable to resolve Electron X11 window handle."; + destroy(); + return false; + } + + XSetWindowAttributes attributes{}; + attributes.background_pixel = BlackPixel(display_, DefaultScreen(display_)); + attributes.event_mask = ExposureMask | StructureNotifyMask; + + window_ = XCreateWindow( + display_, + parentWindow_, + 0, + 0, + 1, + 1, + 0, + CopyFromParent, + InputOutput, + CopyFromParent, + CWBackPixel | CWEventMask, + &attributes + ); + if (!window_) { + lastError_ = "Failed to create embedded MPV X11 child window."; + destroy(); + return false; + } + + clearInputShape(); + XMapWindow(display_, window_); + setBounds(bounds); + XFlush(display_); + return true; + } + + void setBounds(const Bounds& bounds) + { + if (!display_ || !window_) { + return; + } + + const int x = static_cast(std::lround(bounds.x)); + const int y = static_cast(std::lround(bounds.y)); + const unsigned int width = static_cast( + std::max(1, static_cast(std::lround(bounds.width))) + ); + const unsigned int height = static_cast( + std::max(1, static_cast(std::lround(bounds.height))) + ); + + XMoveResizeWindow(display_, window_, x, y, width, height); + XRaiseWindow(display_, window_); + XFlush(display_); + drainEvents(); + } + + std::string wid() const + { + std::ostringstream stream; + stream << static_cast(window_); + return stream.str(); + } + + void destroy() + { + if (display_ && window_) { + XDestroyWindow(display_, window_); + window_ = 0; + } + if (display_) { + XCloseDisplay(display_); + display_ = nullptr; + } + parentWindow_ = 0; + } + +private: + void drainEvents() + { + if (!display_) { + return; + } + + while (XPending(display_) > 0) { + XEvent event{}; + XNextEvent(display_, &event); + } + } + + void clearInputShape() + { + if (!display_ || !window_) { + return; + } + + int eventBase = 0; + int errorBase = 0; + if (!XShapeQueryExtension(display_, &eventBase, &errorBase)) { + return; + } + + XShapeCombineRectangles( + display_, + window_, + ShapeInput, + 0, + 0, + nullptr, + 0, + ShapeSet, + Unsorted + ); + } + + Display* display_ = nullptr; + Window parentWindow_ = 0; + Window window_ = 0; + static inline std::string lastError_ = + "Failed to create embedded MPV X11 child window."; +}; + +} // namespace + +#include "embedded_mpv_wid_common.h" diff --git a/apps/electron-backend/native/src/embedded_mpv_wid_common.h b/apps/electron-backend/native/src/embedded_mpv_wid_common.h new file mode 100644 index 000000000..a1d2a7489 --- /dev/null +++ b/apps/electron-backend/native/src/embedded_mpv_wid_common.h @@ -0,0 +1,1308 @@ +#pragma once + +#include "embedded_mpv_wid_types.h" + +#include + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +enum class SessionStatus { + Idle, + Loading, + Playing, + Paused, + Error, + Closed, +}; + +struct AudioTrack { + int64_t id = 0; + std::string title; + std::string language; + bool selected = false; + bool defaultTrack = false; + bool forced = false; +}; + +struct SessionSnapshot { + SessionStatus status = SessionStatus::Idle; + double positionSeconds = 0.0; + double durationSeconds = -1.0; + double volumePercent = 100.0; + std::string streamUrl; + std::string error; + std::vector audioTracks; + int64_t selectedAudioTrackId = -1; + std::vector subtitleTracks; + int64_t selectedSubtitleTrackId = -1; + double playbackSpeed = 1.0; + std::string aspectOverride = "no"; + bool recordingActive = false; + std::string recordingTargetPath; + std::string recordingStartedAt; + std::string recordingError; +}; + +struct Session { + std::string id; + mpv_handle* handle = nullptr; + NativeVideoHost host; + std::thread eventThread; + std::atomic running{false}; + std::mutex mutex; + SessionSnapshot snapshot; + uint64_t pendingRecordingStartRequestId = 0; + uint64_t pendingRecordingStopRequestId = 0; + std::string pendingRecordingTargetPath; + std::string pendingRecordingStartedAt; + std::string pendingRecordingStopStartedAt; + uint64_t pendingPlaybackLoadRequestId = 0; +}; + +std::atomic gNextSessionId{1}; +std::atomic gNextAsyncRequestId{1}; +std::mutex gSessionsMutex; +std::unordered_map> gSessions; + +std::string toStatusString(SessionStatus status) +{ + switch (status) { + case SessionStatus::Idle: + return "idle"; + case SessionStatus::Loading: + return "loading"; + case SessionStatus::Playing: + return "playing"; + case SessionStatus::Paused: + return "paused"; + case SessionStatus::Error: + return "error"; + case SessionStatus::Closed: + return "closed"; + } + + return "idle"; +} + +uint64_t nextAsyncRequestId() +{ + return gNextAsyncRequestId.fetch_add(1); +} + +double clampVolumePercent(double value) +{ + if (!std::isfinite(value)) { + return 100.0; + } + return std::max(0.0, std::min(100.0, value * 100.0)); +} + +std::string nowIsoString() +{ + const auto now = std::chrono::system_clock::now(); + const std::time_t nowTime = std::chrono::system_clock::to_time_t(now); + std::tm timeValue{}; +#if defined(_WIN32) + gmtime_s(&timeValue, &nowTime); +#else + gmtime_r(&nowTime, &timeValue); +#endif + char buffer[32]{}; + std::strftime(buffer, sizeof(buffer), "%Y-%m-%dT%H:%M:%SZ", &timeValue); + return buffer; +} + +void updateSessionError( + const std::shared_ptr& session, + const std::string& error) +{ + if (!session) { + return; + } + + std::lock_guard lock(session->mutex); + session->snapshot.status = SessionStatus::Error; + session->snapshot.error = error; +} + +std::shared_ptr findSession(const std::string& sessionId) +{ + std::lock_guard sessionsLock(gSessionsMutex); + const auto iterator = gSessions.find(sessionId); + if (iterator == gSessions.end()) { + return nullptr; + } + return iterator->second; +} + +std::shared_ptr getSessionOrThrow( + Napi::Env env, + const std::string& sessionId) +{ + auto session = findSession(sessionId); + if (!session) { + throw Napi::Error::New(env, "Embedded MPV session not found."); + } + return session; +} + +std::string normalizeEnvValue(const char* value) +{ + if (!value) { + return ""; + } + + std::string result(value); + std::transform( + result.begin(), + result.end(), + result.begin(), + [](unsigned char character) { + return static_cast(std::tolower(character)); + } + ); + return result; +} + +std::string joinHeaderFields(const Napi::Object& headers) +{ + const Napi::Array propertyNames = headers.GetPropertyNames(); + std::vector fields; + fields.reserve(propertyNames.Length()); + + for (uint32_t index = 0; index < propertyNames.Length(); index += 1) { + const Napi::Value propertyName = propertyNames.Get(index); + if (!propertyName.IsString()) { + continue; + } + + const std::string key = propertyName.As().Utf8Value(); + const Napi::Value propertyValue = headers.Get(propertyName); + if (!propertyValue.IsString() && !propertyValue.IsNumber()) { + continue; + } + + std::string value = propertyValue.ToString().Utf8Value(); + if (key.empty() || value.empty()) { + continue; + } + + fields.push_back(key + ": " + value); + } + + std::ostringstream stream; + for (size_t index = 0; index < fields.size(); index += 1) { + if (index > 0) { + stream << ","; + } + stream << fields[index]; + } + + return stream.str(); +} + +std::string readOptionalString(const Napi::Object& object, const char* key) +{ + if (!object.Has(key)) { + return ""; + } + + const Napi::Value value = object.Get(key); + if (value.IsUndefined() || value.IsNull()) { + return ""; + } + + return value.ToString().Utf8Value(); +} + +double readOptionalNumber( + const Napi::Object& object, + const char* key, + double fallbackValue) +{ + if (!object.Has(key)) { + return fallbackValue; + } + + const Napi::Value value = object.Get(key); + if (!value.IsNumber()) { + return fallbackValue; + } + + return value.As().DoubleValue(); +} + +Bounds readBounds(const Napi::Object& object) +{ + return { + readOptionalNumber(object, "x", 0), + readOptionalNumber(object, "y", 0), + readOptionalNumber(object, "width", 1), + readOptionalNumber(object, "height", 1), + }; +} + +bool parseIntegerString(const std::string& value, int64_t& result) +{ + if (value.empty()) { + return false; + } + + char* end = nullptr; + const long long parsed = std::strtoll(value.c_str(), &end, 10); + if (!end || *end != '\0') { + return false; + } + + result = static_cast(parsed); + return true; +} + +const mpv_node* getNodeMapValue(const mpv_node& node, const char* key) +{ + if (node.format != MPV_FORMAT_NODE_MAP || + !node.u.list || + !node.u.list->keys || + !node.u.list->values) { + return nullptr; + } + + for (int index = 0; index < node.u.list->num; index += 1) { + const char* candidateKey = node.u.list->keys[index]; + if (candidateKey && std::strcmp(candidateKey, key) == 0) { + return &node.u.list->values[index]; + } + } + + return nullptr; +} + +std::string readNodeString(const mpv_node& node) +{ + switch (node.format) { + case MPV_FORMAT_STRING: + return node.u.string ? node.u.string : ""; + case MPV_FORMAT_INT64: + return std::to_string(node.u.int64); + case MPV_FORMAT_DOUBLE: { + std::ostringstream stream; + stream << node.u.double_; + return stream.str(); + } + default: + return ""; + } +} + +bool readNodeFlag(const mpv_node& node) +{ + if (node.format == MPV_FORMAT_FLAG) { + return node.u.flag != 0; + } + + if (node.format == MPV_FORMAT_STRING && node.u.string) { + const std::string value = normalizeEnvValue(node.u.string); + return value == "yes" || value == "true" || value == "1"; + } + + return false; +} + +bool readNodeInteger(const mpv_node& node, int64_t& result) +{ + if (node.format == MPV_FORMAT_INT64) { + result = node.u.int64; + return true; + } + + if (node.format == MPV_FORMAT_DOUBLE) { + result = static_cast(node.u.double_); + return true; + } + + if (node.format == MPV_FORMAT_STRING && node.u.string) { + return parseIntegerString(node.u.string, result); + } + + return false; +} + +std::vector readTracksOfType( + const mpv_node& node, + const std::string& type) +{ + std::vector tracks; + if (node.format != MPV_FORMAT_NODE_ARRAY || !node.u.list) { + return tracks; + } + + for (int index = 0; index < node.u.list->num; index += 1) { + const mpv_node& trackNode = node.u.list->values[index]; + const mpv_node* typeNode = getNodeMapValue(trackNode, "type"); + if (!typeNode || readNodeString(*typeNode) != type) { + continue; + } + + const mpv_node* idNode = getNodeMapValue(trackNode, "id"); + int64_t id = 0; + if (!idNode || !readNodeInteger(*idNode, id)) { + continue; + } + + AudioTrack track; + track.id = id; + if (const mpv_node* titleNode = getNodeMapValue(trackNode, "title")) { + track.title = readNodeString(*titleNode); + } + if (const mpv_node* languageNode = getNodeMapValue(trackNode, "lang")) { + track.language = readNodeString(*languageNode); + } + if (const mpv_node* selectedNode = getNodeMapValue(trackNode, "selected")) { + track.selected = readNodeFlag(*selectedNode); + } + if (const mpv_node* defaultNode = getNodeMapValue(trackNode, "default")) { + track.defaultTrack = readNodeFlag(*defaultNode); + } + if (const mpv_node* forcedNode = getNodeMapValue(trackNode, "forced")) { + track.forced = readNodeFlag(*forcedNode); + } + + tracks.push_back(track); + } + + return tracks; +} + +void updateAudioTracksFromNode(SessionSnapshot& snapshot, const mpv_node& node) +{ + snapshot.audioTracks = readTracksOfType(node, "audio"); + snapshot.selectedAudioTrackId = -1; + for (const auto& track : snapshot.audioTracks) { + if (track.selected) { + snapshot.selectedAudioTrackId = track.id; + break; + } + } +} + +void updateSubtitleTracksFromNode(SessionSnapshot& snapshot, const mpv_node& node) +{ + snapshot.subtitleTracks = readTracksOfType(node, "sub"); + snapshot.selectedSubtitleTrackId = -1; + for (const auto& track : snapshot.subtitleTracks) { + if (track.selected) { + snapshot.selectedSubtitleTrackId = track.id; + break; + } + } +} + +bool reconcileRecordingPropertyReply( + const std::shared_ptr& session, + uint64_t requestId, + int error) +{ + if (requestId == 0) { + return false; + } + + if (requestId == session->pendingRecordingStartRequestId) { + session->pendingRecordingStartRequestId = 0; + const std::string targetPath = session->pendingRecordingTargetPath; + const std::string startedAt = session->pendingRecordingStartedAt; + session->pendingRecordingTargetPath.clear(); + session->pendingRecordingStartedAt.clear(); + + if (error < 0) { + session->snapshot.recordingActive = false; + session->snapshot.recordingTargetPath = targetPath; + session->snapshot.recordingStartedAt.clear(); + session->snapshot.recordingError = mpv_error_string(error); + return true; + } + + session->snapshot.recordingActive = true; + session->snapshot.recordingTargetPath = targetPath; + session->snapshot.recordingStartedAt = startedAt; + session->snapshot.recordingError.clear(); + return true; + } + + if (requestId == session->pendingRecordingStopRequestId) { + session->pendingRecordingStopRequestId = 0; + const std::string startedAt = session->pendingRecordingStopStartedAt; + session->pendingRecordingStopStartedAt.clear(); + + if (error < 0) { + session->snapshot.recordingActive = true; + session->snapshot.recordingStartedAt = startedAt; + session->snapshot.recordingError = mpv_error_string(error); + return true; + } + + session->snapshot.recordingActive = false; + session->snapshot.recordingStartedAt.clear(); + session->snapshot.recordingError.clear(); + return true; + } + + return false; +} + +bool reconcilePlaybackLoadReply( + const std::shared_ptr& session, + uint64_t requestId, + int error) +{ + if ( + requestId == 0 || + requestId != session->pendingPlaybackLoadRequestId + ) { + return false; + } + + session->pendingPlaybackLoadRequestId = 0; + if (error < 0) { + session->snapshot.status = SessionStatus::Error; + session->snapshot.error = mpv_error_string(error); + } + return true; +} + +void runEventLoop(std::shared_ptr session) +{ + while (session->running.load()) { + mpv_event* event = mpv_wait_event(session->handle, 0.1); + if (!event || event->event_id == MPV_EVENT_NONE) { + continue; + } + + std::lock_guard lock(session->mutex); + switch (event->event_id) { + case MPV_EVENT_START_FILE: + session->snapshot.status = SessionStatus::Loading; + session->snapshot.error.clear(); + break; + case MPV_EVENT_FILE_LOADED: + session->snapshot.status = + session->snapshot.status == SessionStatus::Paused + ? SessionStatus::Paused + : SessionStatus::Playing; + session->snapshot.error.clear(); + break; + case MPV_EVENT_END_FILE: { + const auto* endFile = + static_cast(event->data); + if (endFile && endFile->reason == MPV_END_FILE_REASON_ERROR) { + session->snapshot.status = SessionStatus::Error; + session->snapshot.error = endFile->error < 0 + ? mpv_error_string(endFile->error) + : "Embedded MPV playback ended with an error."; + } + break; + } + case MPV_EVENT_PROPERTY_CHANGE: { + const auto* property = + static_cast(event->data); + if (!property || !property->name || !property->data) { + break; + } + const std::string name(property->name); + if (name == "time-pos" && property->format == MPV_FORMAT_DOUBLE) { + session->snapshot.positionSeconds = + *static_cast(property->data); + } else if (name == "duration" && property->format == MPV_FORMAT_DOUBLE) { + session->snapshot.durationSeconds = + *static_cast(property->data); + } else if (name == "pause" && property->format == MPV_FORMAT_FLAG) { + const bool paused = *static_cast(property->data) != 0; + session->snapshot.status = paused + ? SessionStatus::Paused + : SessionStatus::Playing; + session->snapshot.error.clear(); + } else if (name == "volume" && property->format == MPV_FORMAT_DOUBLE) { + session->snapshot.volumePercent = + *static_cast(property->data); + } else if (name == "path" && property->format == MPV_FORMAT_STRING) { + const char* value = + *static_cast(property->data); + session->snapshot.streamUrl = value ? value : ""; + } else if (name == "track-list" && property->format == MPV_FORMAT_NODE) { + const mpv_node& node = + *static_cast(property->data); + updateAudioTracksFromNode(session->snapshot, node); + updateSubtitleTracksFromNode(session->snapshot, node); + } else if (name == "aid") { + const char* rawValue = property->format == MPV_FORMAT_STRING + ? *static_cast(property->data) + : nullptr; + const std::string value = rawValue ? rawValue : ""; + int64_t trackId = -1; + if (parseIntegerString(value, trackId)) { + session->snapshot.selectedAudioTrackId = trackId; + } + } else if (name == "sid") { + const char* rawValue = property->format == MPV_FORMAT_STRING + ? *static_cast(property->data) + : nullptr; + const std::string value = rawValue ? rawValue : ""; + int64_t trackId = -1; + if (parseIntegerString(value, trackId)) { + session->snapshot.selectedSubtitleTrackId = trackId; + } else { + session->snapshot.selectedSubtitleTrackId = -1; + } + } else if (name == "speed" && property->format == MPV_FORMAT_DOUBLE) { + session->snapshot.playbackSpeed = + *static_cast(property->data); + } else if (name == "video-aspect-override" && + property->format == MPV_FORMAT_STRING) { + const char* value = + *static_cast(property->data); + session->snapshot.aspectOverride = + value && value[0] ? value : "no"; + } + break; + } + case MPV_EVENT_COMMAND_REPLY: + case MPV_EVENT_SET_PROPERTY_REPLY: + if (reconcilePlaybackLoadReply( + session, + event->reply_userdata, + event->error + )) { + break; + } + if (reconcileRecordingPropertyReply( + session, + event->reply_userdata, + event->error + )) { + break; + } + if (event->error < 0) { + session->snapshot.error = mpv_error_string(event->error); + } + break; + case MPV_EVENT_SHUTDOWN: + session->running.store(false); + session->snapshot.status = SessionStatus::Closed; + break; + default: + break; + } + } +} + +void destroySession(const std::shared_ptr& session) +{ + if (!session) { + return; + } + + session->running.store(false); + if (session->handle) { + bool recordingActive = false; + { + std::lock_guard lock(session->mutex); + recordingActive = session->snapshot.recordingActive; + } + if (recordingActive) { + const char* disabledValue = ""; + mpv_set_property( + session->handle, + "stream-record", + MPV_FORMAT_STRING, + const_cast(&disabledValue) + ); + } + mpv_wakeup(session->handle); + } + + if (session->eventThread.joinable()) { + session->eventThread.join(); + } + + if (session->handle) { + mpv_terminate_destroy(session->handle); + session->handle = nullptr; + } + + session->host.destroy(); + { + std::lock_guard lock(session->mutex); + session->snapshot.status = SessionStatus::Closed; + session->snapshot.recordingActive = false; + session->snapshot.recordingStartedAt.clear(); + } +} + +Napi::Value IsSupported(const Napi::CallbackInfo& info) +{ + return Napi::Boolean::New(info.Env(), NativeVideoHost::isAvailable()); +} + +Napi::Value CreateSession(const Napi::CallbackInfo& info) +{ + Napi::Env env = info.Env(); + if (info.Length() < 2 || !info[0].IsBuffer() || !info[1].IsObject()) { + throw Napi::TypeError::New( + env, + "Expected window handle buffer and bounds object." + ); + } + + const auto windowHandle = info[0].As>(); + if (windowHandle.Length() < sizeof(uintptr_t)) { + throw Napi::Error::New(env, "Native window handle buffer is too small."); + } + + uintptr_t windowHandleValue = 0; + std::memcpy( + &windowHandleValue, + windowHandle.Data(), + std::min(windowHandle.Length(), sizeof(uintptr_t)) + ); + if (windowHandleValue == 0) { + throw Napi::Error::New(env, "Unable to resolve Electron native window handle."); + } + + const auto bounds = readBounds(info[1].As()); + const auto session = std::make_shared(); + session->id = "embedded-mpv-" + std::to_string(gNextSessionId.fetch_add(1)); + session->snapshot.volumePercent = + info.Length() >= 4 && info[3].IsNumber() + ? clampVolumePercent(info[3].As().DoubleValue()) + : 100.0; + + if (!session->host.create(windowHandleValue, bounds)) { + throw Napi::Error::New(env, NativeVideoHost::lastError()); + } + + session->handle = mpv_create(); + if (!session->handle) { + session->host.destroy(); + throw Napi::Error::New(env, "Failed to create libmpv handle."); + } + + const std::string wid = session->host.wid(); + mpv_set_option_string(session->handle, "terminal", "no"); + mpv_set_option_string(session->handle, "config", "no"); + mpv_set_option_string(session->handle, "osc", "no"); + mpv_set_option_string(session->handle, "idle", "yes"); + mpv_set_option_string(session->handle, "keep-open", "yes"); + mpv_set_option_string(session->handle, "input-default-bindings", "no"); + mpv_set_option_string(session->handle, "input-vo-keyboard", "no"); + mpv_set_option_string(session->handle, "ytdl", "no"); + mpv_set_option_string(session->handle, "wid", wid.c_str()); + mpv_set_option_string(session->handle, "vo", "gpu"); + mpv_set_option_string(session->handle, "hwdec", "auto-safe"); + + const auto initialVolume = + std::to_string(session->snapshot.volumePercent); + mpv_set_option_string(session->handle, "volume", initialVolume.c_str()); + mpv_request_log_messages(session->handle, "warn"); + + const int initializeResult = mpv_initialize(session->handle); + if (initializeResult < 0) { + destroySession(session); + throw Napi::Error::New( + env, + std::string("Failed to initialize libmpv: ") + + mpv_error_string(initializeResult) + ); + } + + mpv_observe_property(session->handle, 1, "time-pos", MPV_FORMAT_DOUBLE); + mpv_observe_property(session->handle, 2, "duration", MPV_FORMAT_DOUBLE); + mpv_observe_property(session->handle, 3, "pause", MPV_FORMAT_FLAG); + mpv_observe_property(session->handle, 4, "volume", MPV_FORMAT_DOUBLE); + mpv_observe_property(session->handle, 5, "path", MPV_FORMAT_STRING); + mpv_observe_property(session->handle, 6, "track-list", MPV_FORMAT_NODE); + mpv_observe_property(session->handle, 7, "aid", MPV_FORMAT_STRING); + mpv_observe_property(session->handle, 8, "sid", MPV_FORMAT_STRING); + mpv_observe_property(session->handle, 9, "speed", MPV_FORMAT_DOUBLE); + mpv_observe_property( + session->handle, + 10, + "video-aspect-override", + MPV_FORMAT_STRING + ); + + session->running.store(true); + session->eventThread = std::thread(runEventLoop, session); + session->host.setBounds(bounds); + + { + std::lock_guard sessionsLock(gSessionsMutex); + gSessions.emplace(session->id, session); + } + + return Napi::String::New(env, session->id); +} + +Napi::Value LoadPlayback(const Napi::CallbackInfo& info) +{ + Napi::Env env = info.Env(); + if (info.Length() < 2 || !info[0].IsString() || !info[1].IsObject()) { + throw Napi::TypeError::New(env, "Expected session id and playback object."); + } + + const std::string sessionId = info[0].As().Utf8Value(); + const auto playback = info[1].As(); + const auto session = getSessionOrThrow(env, sessionId); + const std::string streamUrl = readOptionalString(playback, "streamUrl"); + if (streamUrl.empty()) { + throw Napi::Error::New(env, "Embedded MPV playback requires a stream URL."); + } + const std::string title = readOptionalString(playback, "title"); + const std::string userAgent = readOptionalString(playback, "userAgent"); + const std::string referer = readOptionalString(playback, "referer"); + const double startTime = readOptionalNumber(playback, "startTime", -1); + + bool recordingActive = false; + { + std::lock_guard lock(session->mutex); + recordingActive = session->snapshot.recordingActive; + } + if (recordingActive) { + const char* disabledValue = ""; + const uint64_t stopRecordingRequestId = nextAsyncRequestId(); + { + std::lock_guard lock(session->mutex); + session->pendingRecordingStopRequestId = stopRecordingRequestId; + session->pendingRecordingStopStartedAt = + session->snapshot.recordingStartedAt; + } + + const int stopResult = mpv_set_property_async( + session->handle, + stopRecordingRequestId, + "stream-record", + MPV_FORMAT_STRING, + const_cast(&disabledValue) + ); + if (stopResult < 0) { + updateSessionError(session, mpv_error_string(stopResult)); + throw Napi::Error::New( + env, + std::string("Failed to stop recording before loading playback: ") + + mpv_error_string(stopResult) + ); + } + } + + std::vector> options; + if (!title.empty()) { + options.emplace_back("force-media-title", title); + } + if (!userAgent.empty()) { + options.emplace_back("user-agent", userAgent); + } + if (!referer.empty()) { + options.emplace_back("referrer", referer); + } + if (std::isfinite(startTime) && startTime >= 0) { + std::ostringstream startValue; + startValue << startTime; + options.emplace_back("start", startValue.str()); + } + if (playback.Has("headers") && playback.Get("headers").IsObject()) { + const auto headerFields = + joinHeaderFields(playback.Get("headers").As()); + if (!headerFields.empty()) { + options.emplace_back("http-header-fields", headerFields); + } + } + + std::vector optionValues(options.size()); + std::vector optionKeys(options.size()); + for (size_t index = 0; index < options.size(); index += 1) { + optionKeys[index] = const_cast(options[index].first.c_str()); + optionValues[index].format = MPV_FORMAT_STRING; + optionValues[index].u.string = + const_cast(options[index].second.c_str()); + } + + mpv_node_list optionList{}; + optionList.num = static_cast(options.size()); + optionList.values = options.empty() ? nullptr : optionValues.data(); + optionList.keys = options.empty() ? nullptr : optionKeys.data(); + + mpv_node optionMap{}; + optionMap.format = MPV_FORMAT_NODE_MAP; + optionMap.u.list = &optionList; + + mpv_node commandValues[5]{}; + commandValues[0].format = MPV_FORMAT_STRING; + commandValues[0].u.string = const_cast("loadfile"); + commandValues[1].format = MPV_FORMAT_STRING; + commandValues[1].u.string = const_cast(streamUrl.c_str()); + commandValues[2].format = MPV_FORMAT_STRING; + commandValues[2].u.string = const_cast("replace"); + commandValues[3].format = MPV_FORMAT_INT64; + commandValues[3].u.int64 = -1; + commandValues[4] = optionMap; + + mpv_node_list commandList{}; + commandList.num = 5; + commandList.values = commandValues; + commandList.keys = nullptr; + + mpv_node command{}; + command.format = MPV_FORMAT_NODE_ARRAY; + command.u.list = &commandList; + + const uint64_t loadPlaybackRequestId = nextAsyncRequestId(); + { + std::lock_guard lock(session->mutex); + session->snapshot.streamUrl = streamUrl; + session->snapshot.error.clear(); + session->snapshot.status = SessionStatus::Loading; + session->snapshot.recordingActive = false; + session->snapshot.recordingTargetPath.clear(); + session->snapshot.recordingStartedAt.clear(); + session->snapshot.recordingError.clear(); + session->pendingPlaybackLoadRequestId = loadPlaybackRequestId; + } + + const int commandResult = mpv_command_node_async( + session->handle, + loadPlaybackRequestId, + &command + ); + if (commandResult < 0) { + { + std::lock_guard lock(session->mutex); + session->pendingPlaybackLoadRequestId = 0; + } + updateSessionError(session, mpv_error_string(commandResult)); + throw Napi::Error::New( + env, + std::string("Failed to load playback: ") + + mpv_error_string(commandResult) + ); + } + + return env.Undefined(); +} + +Napi::Value SetBounds(const Napi::CallbackInfo& info) +{ + Napi::Env env = info.Env(); + if (info.Length() < 2 || !info[0].IsString() || !info[1].IsObject()) { + throw Napi::TypeError::New(env, "Expected session id and bounds object."); + } + const auto session = + getSessionOrThrow(env, info[0].As().Utf8Value()); + session->host.setBounds(readBounds(info[1].As())); + return env.Undefined(); +} + +Napi::Value SetPaused(const Napi::CallbackInfo& info) +{ + Napi::Env env = info.Env(); + if (info.Length() < 2 || !info[0].IsString() || !info[1].IsBoolean()) { + throw Napi::TypeError::New(env, "Expected session id and paused flag."); + } + const auto session = + getSessionOrThrow(env, info[0].As().Utf8Value()); + int paused = info[1].As().Value() ? 1 : 0; + const int result = mpv_set_property_async( + session->handle, + nextAsyncRequestId(), + "pause", + MPV_FORMAT_FLAG, + &paused + ); + if (result < 0) { + throw Napi::Error::New(env, mpv_error_string(result)); + } + return env.Undefined(); +} + +Napi::Value Seek(const Napi::CallbackInfo& info) +{ + Napi::Env env = info.Env(); + if (info.Length() < 2 || !info[0].IsString() || !info[1].IsNumber()) { + throw Napi::TypeError::New(env, "Expected session id and seek time."); + } + const auto session = + getSessionOrThrow(env, info[0].As().Utf8Value()); + const std::string seconds = + std::to_string(info[1].As().DoubleValue()); + const char* command[] = { "seek", seconds.c_str(), "absolute", nullptr }; + const int result = mpv_command_async( + session->handle, + nextAsyncRequestId(), + command + ); + if (result < 0) { + throw Napi::Error::New(env, mpv_error_string(result)); + } + return env.Undefined(); +} + +Napi::Value SetVolume(const Napi::CallbackInfo& info) +{ + Napi::Env env = info.Env(); + if (info.Length() < 2 || !info[0].IsString() || !info[1].IsNumber()) { + throw Napi::TypeError::New(env, "Expected session id and volume."); + } + const auto session = + getSessionOrThrow(env, info[0].As().Utf8Value()); + double volume = clampVolumePercent(info[1].As().DoubleValue()); + const int result = mpv_set_property_async( + session->handle, + nextAsyncRequestId(), + "volume", + MPV_FORMAT_DOUBLE, + &volume + ); + if (result < 0) { + throw Napi::Error::New(env, mpv_error_string(result)); + } + return env.Undefined(); +} + +Napi::Value SetAudioTrack(const Napi::CallbackInfo& info) +{ + Napi::Env env = info.Env(); + if (info.Length() < 2 || !info[0].IsString() || !info[1].IsNumber()) { + throw Napi::TypeError::New(env, "Expected session id and audio track id."); + } + const auto session = + getSessionOrThrow(env, info[0].As().Utf8Value()); + int64_t trackId = + static_cast(info[1].As().Int64Value()); + const int result = mpv_set_property_async( + session->handle, + nextAsyncRequestId(), + "aid", + MPV_FORMAT_INT64, + &trackId + ); + if (result < 0) { + throw Napi::Error::New(env, mpv_error_string(result)); + } + return env.Undefined(); +} + +Napi::Value SetSubtitleTrack(const Napi::CallbackInfo& info) +{ + Napi::Env env = info.Env(); + if (info.Length() < 2 || !info[0].IsString() || !info[1].IsNumber()) { + throw Napi::TypeError::New(env, "Expected session id and subtitle track id."); + } + const auto session = + getSessionOrThrow(env, info[0].As().Utf8Value()); + int64_t trackId = + static_cast(info[1].As().Int64Value()); + int result = 0; + if (trackId < 0) { + const char* disabledValue = "no"; + result = mpv_set_property_async( + session->handle, + nextAsyncRequestId(), + "sid", + MPV_FORMAT_STRING, + const_cast(&disabledValue) + ); + } else { + result = mpv_set_property_async( + session->handle, + nextAsyncRequestId(), + "sid", + MPV_FORMAT_INT64, + &trackId + ); + } + if (result < 0) { + throw Napi::Error::New(env, mpv_error_string(result)); + } + return env.Undefined(); +} + +Napi::Value SetSpeed(const Napi::CallbackInfo& info) +{ + Napi::Env env = info.Env(); + if (info.Length() < 2 || !info[0].IsString() || !info[1].IsNumber()) { + throw Napi::TypeError::New(env, "Expected session id and playback speed."); + } + const auto session = + getSessionOrThrow(env, info[0].As().Utf8Value()); + double speed = info[1].As().DoubleValue(); + const int result = mpv_set_property_async( + session->handle, + nextAsyncRequestId(), + "speed", + MPV_FORMAT_DOUBLE, + &speed + ); + if (result < 0) { + throw Napi::Error::New(env, mpv_error_string(result)); + } + return env.Undefined(); +} + +Napi::Value SetAspect(const Napi::CallbackInfo& info) +{ + Napi::Env env = info.Env(); + if (info.Length() < 2 || !info[0].IsString() || !info[1].IsString()) { + throw Napi::TypeError::New(env, "Expected session id and aspect override."); + } + const auto session = + getSessionOrThrow(env, info[0].As().Utf8Value()); + const std::string aspect = info[1].As().Utf8Value(); + const char* aspectValue = aspect.c_str(); + const int result = mpv_set_property_async( + session->handle, + nextAsyncRequestId(), + "video-aspect-override", + MPV_FORMAT_STRING, + const_cast(&aspectValue) + ); + if (result < 0) { + throw Napi::Error::New(env, mpv_error_string(result)); + } + return env.Undefined(); +} + +Napi::Value StartRecording(const Napi::CallbackInfo& info) +{ + Napi::Env env = info.Env(); + if (info.Length() < 2 || !info[0].IsString() || !info[1].IsString()) { + throw Napi::TypeError::New(env, "Expected session id and target path."); + } + const auto session = + getSessionOrThrow(env, info[0].As().Utf8Value()); + const std::string targetPath = info[1].As().Utf8Value(); + const char* targetValue = targetPath.c_str(); + const uint64_t requestId = nextAsyncRequestId(); + { + std::lock_guard lock(session->mutex); + session->pendingRecordingStartRequestId = requestId; + session->pendingRecordingTargetPath = targetPath; + session->pendingRecordingStartedAt = nowIsoString(); + session->snapshot.recordingError.clear(); + } + const int result = mpv_set_property_async( + session->handle, + requestId, + "stream-record", + MPV_FORMAT_STRING, + const_cast(&targetValue) + ); + if (result < 0) { + std::lock_guard lock(session->mutex); + session->pendingRecordingStartRequestId = 0; + session->pendingRecordingTargetPath.clear(); + session->pendingRecordingStartedAt.clear(); + session->snapshot.recordingError = mpv_error_string(result); + throw Napi::Error::New(env, mpv_error_string(result)); + } + return env.Undefined(); +} + +Napi::Value StopRecording(const Napi::CallbackInfo& info) +{ + Napi::Env env = info.Env(); + if (info.Length() < 1 || !info[0].IsString()) { + throw Napi::TypeError::New(env, "Expected session id."); + } + const auto session = + getSessionOrThrow(env, info[0].As().Utf8Value()); + const char* disabledValue = ""; + const uint64_t requestId = nextAsyncRequestId(); + { + std::lock_guard lock(session->mutex); + session->pendingRecordingStopRequestId = requestId; + session->pendingRecordingStopStartedAt = + session->snapshot.recordingStartedAt; + } + const int result = mpv_set_property_async( + session->handle, + requestId, + "stream-record", + MPV_FORMAT_STRING, + const_cast(&disabledValue) + ); + if (result < 0) { + std::lock_guard lock(session->mutex); + session->pendingRecordingStopRequestId = 0; + session->pendingRecordingStopStartedAt.clear(); + session->snapshot.recordingError = mpv_error_string(result); + throw Napi::Error::New(env, mpv_error_string(result)); + } + return env.Undefined(); +} + +void writeTracks( + Napi::Env env, + Napi::Object result, + const char* key, + const std::vector& tracks) +{ + auto output = Napi::Array::New(env, tracks.size()); + for (size_t index = 0; index < tracks.size(); index += 1) { + const AudioTrack& track = tracks[index]; + auto trackObject = Napi::Object::New(env); + trackObject.Set("id", Napi::Number::New(env, track.id)); + if (!track.title.empty()) { + trackObject.Set("title", Napi::String::New(env, track.title)); + } + if (!track.language.empty()) { + trackObject.Set("language", Napi::String::New(env, track.language)); + } + trackObject.Set("selected", Napi::Boolean::New(env, track.selected)); + trackObject.Set("defaultTrack", Napi::Boolean::New(env, track.defaultTrack)); + trackObject.Set("forced", Napi::Boolean::New(env, track.forced)); + output.Set(index, trackObject); + } + result.Set(key, output); +} + +Napi::Value GetSessionSnapshot(const Napi::CallbackInfo& info) +{ + Napi::Env env = info.Env(); + if (info.Length() < 1 || !info[0].IsString()) { + throw Napi::TypeError::New(env, "Expected session id."); + } + + const auto session = findSession(info[0].As().Utf8Value()); + if (!session) { + return env.Null(); + } + + SessionSnapshot snapshot; + { + std::lock_guard lock(session->mutex); + snapshot = session->snapshot; + } + + auto result = Napi::Object::New(env); + result.Set("status", toStatusString(snapshot.status)); + result.Set("positionSeconds", Napi::Number::New(env, snapshot.positionSeconds)); + if (snapshot.durationSeconds < 0) { + result.Set("durationSeconds", env.Null()); + } else { + result.Set("durationSeconds", Napi::Number::New(env, snapshot.durationSeconds)); + } + result.Set("volume", Napi::Number::New(env, snapshot.volumePercent / 100.0)); + result.Set("streamUrl", Napi::String::New(env, snapshot.streamUrl)); + if (snapshot.selectedAudioTrackId >= 0) { + result.Set( + "selectedAudioTrackId", + Napi::Number::New(env, snapshot.selectedAudioTrackId) + ); + } else { + result.Set("selectedAudioTrackId", env.Null()); + } + writeTracks(env, result, "audioTracks", snapshot.audioTracks); + if (snapshot.selectedSubtitleTrackId >= 0) { + result.Set( + "selectedSubtitleTrackId", + Napi::Number::New(env, snapshot.selectedSubtitleTrackId) + ); + } else { + result.Set("selectedSubtitleTrackId", env.Null()); + } + writeTracks(env, result, "subtitleTracks", snapshot.subtitleTracks); + result.Set("playbackSpeed", Napi::Number::New(env, snapshot.playbackSpeed)); + result.Set("aspectOverride", Napi::String::New(env, snapshot.aspectOverride)); + if ( + snapshot.recordingActive || + !snapshot.recordingTargetPath.empty() || + !snapshot.recordingError.empty() + ) { + auto recording = Napi::Object::New(env); + recording.Set("active", Napi::Boolean::New(env, snapshot.recordingActive)); + if (!snapshot.recordingTargetPath.empty()) { + recording.Set( + "targetPath", + Napi::String::New(env, snapshot.recordingTargetPath) + ); + } + if (!snapshot.recordingStartedAt.empty()) { + recording.Set( + "startedAt", + Napi::String::New(env, snapshot.recordingStartedAt) + ); + } + if (!snapshot.recordingError.empty()) { + recording.Set("error", Napi::String::New(env, snapshot.recordingError)); + } + result.Set("recording", recording); + } + if (!snapshot.error.empty()) { + result.Set("error", Napi::String::New(env, snapshot.error)); + } + return result; +} + +Napi::Value DisposeSession(const Napi::CallbackInfo& info) +{ + Napi::Env env = info.Env(); + if (info.Length() < 1 || !info[0].IsString()) { + throw Napi::TypeError::New(env, "Expected session id."); + } + + const std::string sessionId = info[0].As().Utf8Value(); + std::shared_ptr session; + { + std::lock_guard sessionsLock(gSessionsMutex); + const auto iterator = gSessions.find(sessionId); + if (iterator == gSessions.end()) { + return env.Undefined(); + } + session = iterator->second; + gSessions.erase(iterator); + } + + destroySession(session); + return env.Undefined(); +} + +Napi::Object Init(Napi::Env env, Napi::Object exports) +{ + exports.Set("isSupported", Napi::Function::New(env, IsSupported)); + exports.Set("createSession", Napi::Function::New(env, CreateSession)); + exports.Set("loadPlayback", Napi::Function::New(env, LoadPlayback)); + exports.Set("setBounds", Napi::Function::New(env, SetBounds)); + exports.Set("setPaused", Napi::Function::New(env, SetPaused)); + exports.Set("seek", Napi::Function::New(env, Seek)); + exports.Set("setVolume", Napi::Function::New(env, SetVolume)); + exports.Set("setAudioTrack", Napi::Function::New(env, SetAudioTrack)); + exports.Set("setSubtitleTrack", Napi::Function::New(env, SetSubtitleTrack)); + exports.Set("setSpeed", Napi::Function::New(env, SetSpeed)); + exports.Set("setAspect", Napi::Function::New(env, SetAspect)); + exports.Set("startRecording", Napi::Function::New(env, StartRecording)); + exports.Set("stopRecording", Napi::Function::New(env, StopRecording)); + exports.Set("getSessionSnapshot", Napi::Function::New(env, GetSessionSnapshot)); + exports.Set("disposeSession", Napi::Function::New(env, DisposeSession)); + return exports; +} + +} // namespace + +NODE_API_MODULE(embedded_mpv, Init) diff --git a/apps/electron-backend/native/src/embedded_mpv_wid_types.h b/apps/electron-backend/native/src/embedded_mpv_wid_types.h new file mode 100644 index 000000000..311488aa0 --- /dev/null +++ b/apps/electron-backend/native/src/embedded_mpv_wid_types.h @@ -0,0 +1,8 @@ +#pragma once + +struct Bounds { + double x = 0.0; + double y = 0.0; + double width = 1.0; + double height = 1.0; +}; diff --git a/apps/electron-backend/native/src/embedded_mpv_win32.cc b/apps/electron-backend/native/src/embedded_mpv_win32.cc new file mode 100644 index 000000000..5b419bd5b --- /dev/null +++ b/apps/electron-backend/native/src/embedded_mpv_win32.cc @@ -0,0 +1,203 @@ +#include + +#include "embedded_mpv_wid_types.h" + +#include +#include +#include +#include +#include + +namespace { + +class NativeVideoHost { +public: + static bool isAvailable() + { + return true; + } + + static std::string lastError() + { + if (!lastErrorMessage_.empty()) { + return lastErrorMessage_; + } + + return formatLastError( + "Failed to create embedded MPV child window." + ); + } + + static std::string formatLastError(const char* fallbackMessage) + { + DWORD error = GetLastError(); + if (error == 0) { + return fallbackMessage; + } + + LPSTR messageBuffer = nullptr; + const DWORD length = FormatMessageA( + FORMAT_MESSAGE_ALLOCATE_BUFFER | + FORMAT_MESSAGE_FROM_SYSTEM | + FORMAT_MESSAGE_IGNORE_INSERTS, + nullptr, + error, + MAKELANGID(LANG_NEUTRAL, SUBLANG_DEFAULT), + reinterpret_cast(&messageBuffer), + 0, + nullptr + ); + + std::string message = fallbackMessage; + if (length > 0 && messageBuffer) { + message += " "; + message += messageBuffer; + } + if (messageBuffer) { + LocalFree(messageBuffer); + } + return message; + } + + bool create(uintptr_t parentHandle, const Bounds& bounds) + { + lastErrorMessage_.clear(); + parentWindow_ = reinterpret_cast(parentHandle); + if (!parentWindow_ || !IsWindow(parentWindow_)) { + lastErrorMessage_ = + "Unable to resolve Electron native window handle."; + return false; + } + + if (!registerWindowClass()) { + return false; + } + window_ = CreateWindowExW( + WS_EX_TRANSPARENT, + windowClassName(), + L"IPTVnator Embedded MPV", + WS_CHILD | WS_VISIBLE | WS_CLIPSIBLINGS | WS_CLIPCHILDREN, + 0, + 0, + 1, + 1, + parentWindow_, + nullptr, + GetModuleHandleW(nullptr), + nullptr + ); + if (!window_) { + lastErrorMessage_ = formatLastError( + "Failed to create embedded MPV child window." + ); + return false; + } + + SetWindowLongPtrW( + window_, + GWLP_USERDATA, + reinterpret_cast(this) + ); + setBounds(bounds); + return true; + } + + void setBounds(const Bounds& bounds) + { + if (!window_) { + return; + } + + const int x = static_cast(std::lround(bounds.x)); + const int y = static_cast(std::lround(bounds.y)); + const int width = std::max(1, static_cast(std::lround(bounds.width))); + const int height = std::max(1, static_cast(std::lround(bounds.height))); + + SetWindowPos( + window_, + HWND_TOP, + x, + y, + width, + height, + SWP_NOACTIVATE | SWP_SHOWWINDOW + ); + } + + std::string wid() const + { + std::ostringstream stream; + stream << reinterpret_cast(window_); + return stream.str(); + } + + void destroy() + { + if (window_) { + DestroyWindow(window_); + window_ = nullptr; + } + parentWindow_ = nullptr; + } + +private: + static const wchar_t* windowClassName() + { + return L"IPTVnatorEmbeddedMpvHostWindow"; + } + + static bool registerWindowClass() + { + static bool registered = false; + if (registered) { + return true; + } + + WNDCLASSEXW windowClass{}; + windowClass.cbSize = sizeof(windowClass); + windowClass.lpfnWndProc = &NativeVideoHost::windowProc; + windowClass.hInstance = GetModuleHandleW(nullptr); + windowClass.lpszClassName = windowClassName(); + windowClass.hCursor = LoadCursorW(nullptr, IDC_ARROW); + windowClass.hbrBackground = + reinterpret_cast(GetStockObject(BLACK_BRUSH)); + const ATOM classAtom = RegisterClassExW(&windowClass); + if (classAtom == 0) { + const DWORD error = GetLastError(); + if (error != ERROR_CLASS_ALREADY_EXISTS) { + lastErrorMessage_ = formatLastError( + "Failed to register embedded MPV child window class." + ); + return false; + } + } + + registered = true; + return true; + } + + static LRESULT CALLBACK windowProc( + HWND window, + UINT message, + WPARAM wParam, + LPARAM lParam) + { + if (message == WM_NCHITTEST) { + return HTTRANSPARENT; + } + + if (message == WM_ERASEBKGND) { + return 1; + } + + return DefWindowProcW(window, message, wParam, lParam); + } + + HWND parentWindow_ = nullptr; + HWND window_ = nullptr; + static inline std::string lastErrorMessage_; +}; + +} // namespace + +#include "embedded_mpv_wid_common.h" diff --git a/apps/electron-backend/src/app/services/embedded-mpv-native-source.spec.ts b/apps/electron-backend/src/app/services/embedded-mpv-native-source.spec.ts index 89f14f60b..28c562b48 100644 --- a/apps/electron-backend/src/app/services/embedded-mpv-native-source.spec.ts +++ b/apps/electron-backend/src/app/services/embedded-mpv-native-source.spec.ts @@ -6,6 +6,29 @@ describe('Embedded MPV native source recording invariants', () => { path.resolve(__dirname, '../../../native/src/embedded_mpv.mm'), 'utf8' ); + const widCommonSource = readFileSync( + path.resolve( + __dirname, + '../../../native/src/embedded_mpv_wid_common.h' + ), + 'utf8' + ); + const win32Source = readFileSync( + path.resolve(__dirname, '../../../native/src/embedded_mpv_win32.cc'), + 'utf8' + ); + const linuxSource = readFileSync( + path.resolve(__dirname, '../../../native/src/embedded_mpv_linux.cc'), + 'utf8' + ); + const buildScriptSource = readFileSync( + path.resolve(__dirname, '../../../build-embedded-mpv.js'), + 'utf8' + ); + const buildAndMakeWorkflowSource = readFileSync( + path.resolve(__dirname, '../../../../../.github/workflows/build-and-make.yaml'), + 'utf8' + ); function functionBody(name: string): string { const start = nativeSource.indexOf(`Napi::Value ${name}(`); @@ -67,4 +90,117 @@ describe('Embedded MPV native source recording invariants', () => { expect(endFileCase).toContain('SessionStatus::Ended'); expect(endFileCase).toContain('MPV_END_FILE_REASON_EOF'); }); + + it('keeps Windows/Linux non-load async MPV command failures non-fatal', () => { + expect(widCommonSource).toContain('pendingPlaybackLoadRequestId'); + expect(widCommonSource).toContain('reconcilePlaybackLoadReply'); + expect(widCommonSource).toContain( + 'session->snapshot.error = mpv_error_string(event->error);' + ); + expect(widCommonSource).not.toContain( + 'if (event->error < 0) {\n' + + ' session->snapshot.status = SessionStatus::Error;' + ); + }); + + it('clears transient Windows/Linux MPV operation errors after healthy playback states', () => { + const fileLoadedCaseStart = widCommonSource.indexOf( + 'case MPV_EVENT_FILE_LOADED:' + ); + expect(fileLoadedCaseStart).toBeGreaterThanOrEqual(0); + const endFileCaseStart = widCommonSource.indexOf( + 'case MPV_EVENT_END_FILE:', + fileLoadedCaseStart + ); + expect(endFileCaseStart).toBeGreaterThan(fileLoadedCaseStart); + const fileLoadedCase = widCommonSource.slice( + fileLoadedCaseStart, + endFileCaseStart + ); + + expect(fileLoadedCase).toContain('session->snapshot.error.clear();'); + + const pausePropertyStart = widCommonSource.indexOf( + 'const bool paused = *static_cast(property->data) != 0;' + ); + expect(pausePropertyStart).toBeGreaterThanOrEqual(0); + const volumePropertyStart = widCommonSource.indexOf( + '} else if (name == "volume"', + pausePropertyStart + ); + expect(volumePropertyStart).toBeGreaterThan(pausePropertyStart); + const pausePropertyBranch = widCommonSource.slice( + pausePropertyStart, + volumePropertyStart + ); + + expect(pausePropertyBranch).toContain( + 'session->snapshot.error.clear();' + ); + }); + + it('copies Windows runtime DLLs next to the addon for Windows loader lookup', () => { + expect(buildScriptSource).toContain( + "for (const windowsDllName of ['mpv-2.dll', 'mpv.dll'])" + ); + expect(buildScriptSource).toContain( + 'path.join(outputDir, windowsDllName)' + ); + expect(buildScriptSource).toContain("fileName.endsWith('.dll')"); + expect(buildScriptSource).toContain('path.join(outputDir, fileName)'); + }); + + it('checks Win32 window class registration failures explicitly', () => { + expect(win32Source).toContain('const ATOM classAtom = RegisterClassExW'); + expect(win32Source).toContain('ERROR_CLASS_ALREADY_EXISTS'); + expect(win32Source).toContain( + 'Failed to register embedded MPV child window class.' + ); + }); + + it('drains Linux X11 events after resizing the embedded window', () => { + expect(linuxSource).toContain('void drainEvents()'); + expect(linuxSource).toContain('XPending(display_)'); + expect(linuxSource).toContain('XNextEvent(display_, &event)'); + expect(linuxSource).toContain('drainEvents();'); + }); + + it('uses platform-specific embedded MPV runtime cache key inputs in CI', () => { + expect(buildAndMakeWorkflowSource).toContain( + "const targetPlatform = '${{ matrix.embedded_mpv_platform }}';" + ); + expect(buildAndMakeWorkflowSource).toContain( + "if (targetPlatform === 'darwin')" + ); + expect(buildAndMakeWorkflowSource).toContain( + "'tools/embedded-mpv/build-macos-runtime.mjs'" + ); + expect(buildAndMakeWorkflowSource).toContain( + "'tools/embedded-mpv/stage-runtime.mjs'" + ); + expect(buildAndMakeWorkflowSource).not.toContain( + '`macos${safeDeploymentTarget}`,\n' + + ' `xcode${xcodeHash}`,' + ); + }); +}); + +describe('Embedded MPV native build configuration', () => { + const bindingGyp = JSON.parse( + readFileSync( + path.resolve(__dirname, '../../../native/binding.gyp'), + 'utf8' + ) + ); + const target = bindingGyp.targets.find( + (candidate: { target_name?: string }) => + candidate.target_name === 'embedded_mpv' + ); + + it('declares platform-specific native sources for macOS, Windows, and Linux', () => { + expect(target).toBeDefined(); + expect(JSON.stringify(target)).toContain('src/embedded_mpv.mm'); + expect(JSON.stringify(target)).toContain('src/embedded_mpv_win32.cc'); + expect(JSON.stringify(target)).toContain('src/embedded_mpv_linux.cc'); + }); }); diff --git a/apps/electron-backend/src/app/services/embedded-mpv-native.service.spec.ts b/apps/electron-backend/src/app/services/embedded-mpv-native.service.spec.ts index 5850ac657..7acbf57a2 100644 --- a/apps/electron-backend/src/app/services/embedded-mpv-native.service.spec.ts +++ b/apps/electron-backend/src/app/services/embedded-mpv-native.service.spec.ts @@ -3,7 +3,7 @@ import type { EmbeddedMpvSessionStatus, ResolvedPortalPlayback, } from '@iptvnator/shared/interfaces'; -import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'fs'; +import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'fs'; import { tmpdir } from 'os'; import path from 'path'; import type { EmbeddedMpvNativeService as EmbeddedMpvNativeServiceType } from './embedded-mpv-native.service'; @@ -259,6 +259,66 @@ describe('EmbeddedMpvNativeService power blocker', () => { expect(service.getSupport().capabilities?.recording).toBe(true); }); + it.each(['darwin', 'win32', 'linux'])( + 'reports embedded MPV support on %s when the addon is already loaded', + (platform) => { + Object.defineProperty(process, 'platform', { + value: platform, + }); + + expect(service.getSupport()).toEqual( + expect.objectContaining({ + supported: true, + platform, + }) + ); + } + ); + + it.each([ + { + platform: 'darwin' as NodeJS.Platform, + runtimeFile: path.join('lib', 'libmpv.2.dylib'), + }, + { + platform: 'win32' as NodeJS.Platform, + runtimeFile: path.join('lib', 'mpv-2.dll'), + }, + { + platform: 'linux' as NodeJS.Platform, + runtimeFile: path.join('lib', 'libmpv.so.2'), + }, + ])( + 'loads the addon after validating the $platform runtime file exists', + ({ platform, runtimeFile }) => { + Object.defineProperty(process, 'platform', { + value: platform, + }); + const nativeDir = createTempDir(); + const addonPath = path.join(nativeDir, 'embedded_mpv.node'); + const runtimePath = path.join(nativeDir, runtimeFile); + mkdirSync(path.dirname(runtimePath), { recursive: true }); + writeFileSync(addonPath, ''); + writeFileSync(runtimePath, ''); + const loadAddonModule = jest.fn().mockReturnValue(addon); + + Object.assign(service as unknown as Record, { + addon: null, + addonLoadError: null, + loadAddonModule, + getAddonCandidatePaths: () => [addonPath], + }); + + expect(service.getSupport()).toEqual( + expect.objectContaining({ + supported: true, + platform, + }) + ); + expect(loadAddonModule).toHaveBeenCalledWith(addonPath); + } + ); + it('loads the addon before reporting support capabilities', () => { const loadAddonModule = jest.fn().mockReturnValue(addon); Object.assign(service as unknown as Record, { diff --git a/apps/electron-backend/src/app/services/embedded-mpv-native.service.ts b/apps/electron-backend/src/app/services/embedded-mpv-native.service.ts index ccc315b28..a4d915eca 100644 --- a/apps/electron-backend/src/app/services/embedded-mpv-native.service.ts +++ b/apps/electron-backend/src/app/services/embedded-mpv-native.service.ts @@ -76,6 +76,11 @@ interface EmbeddedMpvRuntimeSession { } const EMBEDDED_MPV_EXPERIMENT_ENV = 'IPTVNATOR_ENABLE_EMBEDDED_MPV_EXPERIMENT'; +const SUPPORTED_EMBEDDED_MPV_PLATFORMS = new Set([ + 'darwin', + 'win32', + 'linux', +]); function dedupePaths(paths: Array): string[] { return [ @@ -105,11 +110,21 @@ export class EmbeddedMpvNativeService { } getSupport(): EmbeddedMpvSupport { - if (process.platform !== 'darwin') { + if (!SUPPORTED_EMBEDDED_MPV_PLATFORMS.has(process.platform)) { return { supported: false, platform: process.platform, - reason: 'Embedded MPV is currently available on macOS only.', + reason: + 'Embedded MPV is currently available on macOS, Windows, and Linux only.', + }; + } + + if (this.isUnsupportedLinuxDisplayServer()) { + return { + supported: false, + platform: process.platform, + reason: + 'Embedded MPV on Linux currently requires X11 or Xwayland. Native Wayland embedding is not supported yet.', }; } @@ -117,7 +132,7 @@ export class EmbeddedMpvNativeService { return { supported: false, platform: process.platform, - reason: `Embedded MPV is a macOS-only experimental player. Set ${EMBEDDED_MPV_EXPERIMENT_ENV}=1 to enable it for local development builds.`, + reason: `Embedded MPV is an experimental desktop player. Set ${EMBEDDED_MPV_EXPERIMENT_ENV}=1 to enable it for local development builds, or use a packaged build with the bundled runtime.`, }; } @@ -718,11 +733,19 @@ export class EmbeddedMpvNativeService { private assertEmbeddedMpvEnabled(): void { if (!this.isEmbeddedMpvEnabled()) { throw new Error( - `Embedded MPV is disabled. Set ${EMBEDDED_MPV_EXPERIMENT_ENV}=1 to enable the local macOS harness, or use a packaged macOS build with the bundled runtime.` + `Embedded MPV is disabled. Set ${EMBEDDED_MPV_EXPERIMENT_ENV}=1 to enable the local desktop harness, or use a packaged build with the bundled runtime.` ); } } + private isUnsupportedLinuxDisplayServer(): boolean { + return ( + process.platform === 'linux' && + Boolean(process.env.WAYLAND_DISPLAY) && + !process.env.DISPLAY + ); + } + private getAddon(): NativeEmbeddedMpvAddon { if (this.addon) { return this.addon; @@ -836,14 +859,42 @@ export class EmbeddedMpvNativeService { private getMissingRuntimeReason(addonPath: string): string | null { const nativeDir = path.dirname(addonPath); - const libMpvPath = path.join(nativeDir, 'lib', 'libmpv.2.dylib'); + const runtimeCandidates = this.getRuntimeLibraryCandidates(nativeDir); - if (!existsSync(libMpvPath)) { - return `Embedded MPV runtime is incomplete. Missing ${libMpvPath}.`; + if (!runtimeCandidates.some((candidate) => existsSync(candidate))) { + return [ + 'Embedded MPV runtime is incomplete. Missing one of:', + ...runtimeCandidates.map((candidate) => `- ${candidate}`), + ].join('\n'); } return null; } + + private getRuntimeLibraryCandidates(nativeDir: string): string[] { + switch (process.platform) { + case 'darwin': + return [ + path.join(nativeDir, 'lib', 'libmpv.2.dylib'), + path.join(nativeDir, 'lib', 'libmpv.dylib'), + ]; + case 'win32': + return [ + path.join(nativeDir, 'lib', 'mpv-2.dll'), + path.join(nativeDir, 'mpv-2.dll'), + path.join(nativeDir, 'lib', 'mpv.dll'), + path.join(nativeDir, 'mpv.dll'), + ]; + case 'linux': + return [ + path.join(nativeDir, 'lib', 'libmpv.so.2'), + path.join(nativeDir, 'lib', 'libmpv.so.1'), + path.join(nativeDir, 'lib', 'libmpv.so'), + ]; + default: + return []; + } + } } export const embeddedMpvNativeService = new EmbeddedMpvNativeService(); diff --git a/apps/web/src/app/settings/settings.component.spec.ts b/apps/web/src/app/settings/settings.component.spec.ts index 9e5419d51..4bfc30de3 100644 --- a/apps/web/src/app/settings/settings.component.spec.ts +++ b/apps/web/src/app/settings/settings.component.spec.ts @@ -471,24 +471,29 @@ describe('SettingsComponent', () => { ).toBe(false); }); - it('shows the embedded mpv option when the desktop support probe reports supported', async () => { - window.electron = { - ...window.electron, - getEmbeddedMpvSupport: jest.fn().mockResolvedValue({ - supported: true, - platform: 'darwin', - }), - } as unknown as typeof window.electron; + it.each(['darwin', 'win32', 'linux'] as const)( + 'shows the embedded mpv option when the %s desktop support probe reports supported', + async (platform) => { + window.electron = { + ...window.electron, + getEmbeddedMpvSupport: jest.fn().mockResolvedValue({ + supported: true, + platform, + }), + } as unknown as typeof window.electron; - await component.ngOnInit(); - await fixture.whenStable(); + await component.ngOnInit(); + await fixture.whenStable(); - expect( - component - .players() - .some((player) => player.id === VideoPlayer.EmbeddedMpv) - ).toBe(true); - }); + expect( + component + .players() + .some( + (player) => player.id === VideoPlayer.EmbeddedMpv + ) + ).toBe(true); + } + ); it('hides external player path settings when the Electron bridge is incomplete', async () => { fixture.destroy(); diff --git a/apps/web/src/assets/i18n/ar.json b/apps/web/src/assets/i18n/ar.json index 8a5f94d5b..a4fa83135 100644 --- a/apps/web/src/assets/i18n/ar.json +++ b/apps/web/src/assets/i18n/ar.json @@ -249,7 +249,7 @@ "PLAYER_HTML5": "مشغل فيديو HTML5", "PLAYER_VIDEOJS": "مشغل Video.js", "PLAYER_ARTPLAYER": "ArtPlayer", - "PLAYER_EMBEDDED_MPV": "MPV المضمّن (تجريبي، macOS)", + "PLAYER_EMBEDDED_MPV": "MPV المضمّن (تجريبي)", "PLAYER_MPV": "مشغل MPV", "PLAYER_VLC": "VLC", "VIDEO_PLAYER_PLACEHOLDER": "اختر خيارًا", @@ -296,7 +296,7 @@ "VERSION_DESCRIPTION": "الإصدار الحالي للتطبيق", "ABOUT_SUBTITLE": "الإصدار وروابط المشروع وطرق دعم IPTVnator.", "EMBEDDED_MPV_NOTICE_TITLE": "بيئة تشغيل MPV المضمّنة", - "EMBEDDED_MPV_NOTICE_DESCRIPTION": "يمكن أن تتضمن إصدارات macOS مكتبتي libmpv و FFmpeg المرفقتين لمشغل MPV المضمّن التجريبي. لا يحتاج المستخدمون إلى تثبيت تطبيق MPV بشكل منفصل؛ ويبقى MPV و VLC الخارجيان متاحين كمشغلين احتياطيين.", + "EMBEDDED_MPV_NOTICE_DESCRIPTION": "يمكن أن تتضمن إصدارات سطح المكتب مكتبتي libmpv و FFmpeg المرفقتين لمشغل MPV المضمّن التجريبي. لا يحتاج المستخدمون إلى تثبيت تطبيق MPV بشكل منفصل؛ ويبقى MPV و VLC الخارجيان متاحين كمشغلين احتياطيين.", "COMMUNITY": "المشروع والمجتمع", "COMMUNITY_DESCRIPTION": "ضع نجمة على المستودع، أو اقرأ المدونة، أو انضم إلى مجموعة Telegram للحصول على التحديثات والمساعدة.", "LINK_BLOG": "المدونة", diff --git a/apps/web/src/assets/i18n/ary.json b/apps/web/src/assets/i18n/ary.json index 8b3eb0d20..c1381c693 100644 --- a/apps/web/src/assets/i18n/ary.json +++ b/apps/web/src/assets/i18n/ary.json @@ -249,7 +249,7 @@ "PLAYER_HTML5": "مشغل فيديو HTML5", "PLAYER_VIDEOJS": "مشغل Video.js", "PLAYER_ARTPLAYER": "ArtPlayer", - "PLAYER_EMBEDDED_MPV": "MPV مدمج (تجريبي، macOS)", + "PLAYER_EMBEDDED_MPV": "MPV مدمج (تجريبي)", "PLAYER_MPV": "مشغل MPV", "PLAYER_VLC": "VLC", "VIDEO_PLAYER_PLACEHOLDER": "اختار خيار", @@ -296,7 +296,7 @@ "VERSION_DESCRIPTION": "الإصدار الحالي ديال التطبيق", "ABOUT_SUBTITLE": "الإصدار، روابط المشروع، وطرق دعم IPTVnator.", "EMBEDDED_MPV_NOTICE_TITLE": "وقت تشغيل MPV المدمج", - "EMBEDDED_MPV_NOTICE_DESCRIPTION": "إصدارات macOS تقدر تتضمن مكتبات libmpv و FFmpeg المدمجة لمشغل MPV المدمج التجريبي. المستخدمين ما محتاجينش يثبتو تطبيق MPV بشكل منفصل؛ MPV الخارجي و VLC مازالو متوفرين كمشغلات احتياطية.", + "EMBEDDED_MPV_NOTICE_DESCRIPTION": "إصدارات سطح المكتب تقدر تتضمن مكتبات libmpv و FFmpeg المدمجة لمشغل MPV المدمج التجريبي. المستخدمين ما محتاجينش يثبتو تطبيق MPV بشكل منفصل؛ MPV الخارجي و VLC مازالو متوفرين كمشغلات احتياطية.", "COMMUNITY": "المشروع والمجتمع", "COMMUNITY_DESCRIPTION": "حط نجمة على المستودع، قرا المدونة، ولا انضم لجروب Telegram للتحديثات والمساعدة.", "LINK_BLOG": "المدونة", diff --git a/apps/web/src/assets/i18n/by.json b/apps/web/src/assets/i18n/by.json index c007f27bc..1a8f0889c 100644 --- a/apps/web/src/assets/i18n/by.json +++ b/apps/web/src/assets/i18n/by.json @@ -249,7 +249,7 @@ "PLAYER_HTML5": "HTML5 відэапрайгравальнік", "PLAYER_VIDEOJS": "Прайгравальнік Video.js", "PLAYER_ARTPLAYER": "ArtPlayer", - "PLAYER_EMBEDDED_MPV": "Убудаваны MPV (эксперыментальна, macOS)", + "PLAYER_EMBEDDED_MPV": "Убудаваны MPV (эксперыментальна)", "PLAYER_MPV": "Прайгравальнік MPV", "PLAYER_VLC": "VLC", "VIDEO_PLAYER_PLACEHOLDER": "Выберыце са спісу", @@ -296,7 +296,7 @@ "VERSION_DESCRIPTION": "Бягучая версія праграмы", "ABOUT_SUBTITLE": "Версія, спасылкі праекта і спосабы падтрымаць IPTVnator.", "EMBEDDED_MPV_NOTICE_TITLE": "Асяроддзе ўбудаванага MPV", - "EMBEDDED_MPV_NOTICE_DESCRIPTION": "Зборкі для macOS могуць уключаць убудаваныя бібліятэкі libmpv і FFmpeg для эксперыментальнага ўбудаванага прайгравальніка MPV. Карыстальнікам не трэба ўсталёўваць праграму MPV асобна; знешнія MPV і VLC застаюцца даступнымі ў якасці запасных прайгравальнікаў.", + "EMBEDDED_MPV_NOTICE_DESCRIPTION": "Зборкі для настольных платформаў могуць уключаць убудаваныя бібліятэкі libmpv і FFmpeg для эксперыментальнага ўбудаванага прайгравальніка MPV. Карыстальнікам не трэба ўсталёўваць праграму MPV асобна; знешнія MPV і VLC застаюцца даступнымі ў якасці запасных прайгравальнікаў.", "COMMUNITY": "Праект і супольнасць", "COMMUNITY_DESCRIPTION": "Пастаўце зорку рэпазіторыю, чытайце блог або далучайцеся да Telegram-групы для абнаўленняў і дапамогі.", "LINK_BLOG": "Блог", diff --git a/apps/web/src/assets/i18n/de.json b/apps/web/src/assets/i18n/de.json index 7622e95a0..886cdd49e 100644 --- a/apps/web/src/assets/i18n/de.json +++ b/apps/web/src/assets/i18n/de.json @@ -249,7 +249,7 @@ "PLAYER_HTML5": "HTML5-Videoplayer", "PLAYER_VIDEOJS": "Video.js-Player", "PLAYER_ARTPLAYER": "ArtPlayer", - "PLAYER_EMBEDDED_MPV": "Eingebettetes MPV (experimentell, macOS)", + "PLAYER_EMBEDDED_MPV": "Eingebettetes MPV (experimentell)", "PLAYER_MPV": "MPV-Player", "PLAYER_VLC": "VLC", "VIDEO_PLAYER_PLACEHOLDER": "Option auswählen", @@ -296,7 +296,7 @@ "VERSION_DESCRIPTION": "Aktuelle Version der Anwendung", "ABOUT_SUBTITLE": "Version, Projekt-Links und Möglichkeiten, IPTVnator zu unterstützen.", "EMBEDDED_MPV_NOTICE_TITLE": "Eingebettete MPV-Laufzeit", - "EMBEDDED_MPV_NOTICE_DESCRIPTION": "macOS-Builds können gebündelte libmpv- und FFmpeg-Bibliotheken für den experimentellen eingebetteten MPV-Player enthalten. Eine separate Installation der MPV-App ist nicht erforderlich; externes MPV und VLC bleiben weiterhin als Fallback-Player verfügbar.", + "EMBEDDED_MPV_NOTICE_DESCRIPTION": "Desktop-Builds können gebündelte libmpv- und FFmpeg-Bibliotheken für den experimentellen eingebetteten MPV-Player enthalten. Eine separate Installation der MPV-App ist nicht erforderlich; externes MPV und VLC bleiben weiterhin als Fallback-Player verfügbar.", "COMMUNITY": "Projekt & Community", "COMMUNITY_DESCRIPTION": "Gib dem Repository einen Stern, lies den Blog oder tritt der Telegram-Gruppe für Updates und Hilfe bei.", "LINK_BLOG": "Blog", diff --git a/apps/web/src/assets/i18n/el.json b/apps/web/src/assets/i18n/el.json index 0ffdabd73..befbf9e13 100644 --- a/apps/web/src/assets/i18n/el.json +++ b/apps/web/src/assets/i18n/el.json @@ -249,7 +249,7 @@ "PLAYER_HTML5": "Πρόγραμμα αναπαραγωγής HTML5", "PLAYER_VIDEOJS": "Πρόγραμμα αναπαραγωγής Video.js", "PLAYER_ARTPLAYER": "ArtPlayer", - "PLAYER_EMBEDDED_MPV": "Ενσωματωμένο MPV (πειραματικό, macOS)", + "PLAYER_EMBEDDED_MPV": "Ενσωματωμένο MPV (πειραματικό)", "PLAYER_MPV": "MPV player", "PLAYER_VLC": "VLC", "VIDEO_PLAYER_PLACEHOLDER": "Επιλέξτε μια επιλογή", @@ -296,7 +296,7 @@ "VERSION_DESCRIPTION": "Τρέχουσα έκδοση της εφαρμογής", "ABOUT_SUBTITLE": "Έκδοση, σύνδεσμοι έργου και τρόποι υποστήριξης του IPTVnator.", "EMBEDDED_MPV_NOTICE_TITLE": "Ενσωματωμένος χρόνος εκτέλεσης MPV", - "EMBEDDED_MPV_NOTICE_DESCRIPTION": "Οι εκδόσεις macOS μπορούν να περιλαμβάνουν τις ενσωματωμένες βιβλιοθήκες libmpv και FFmpeg για το πειραματικό ενσωματωμένο πρόγραμμα αναπαραγωγής MPV. Οι χρήστες δεν χρειάζεται να εγκαταστήσουν ξεχωριστά την εφαρμογή MPV· τα εξωτερικά MPV και VLC παραμένουν διαθέσιμα ως εναλλακτικά προγράμματα αναπαραγωγής.", + "EMBEDDED_MPV_NOTICE_DESCRIPTION": "Οι εκδόσεις desktop μπορούν να περιλαμβάνουν τις ενσωματωμένες βιβλιοθήκες libmpv και FFmpeg για το πειραματικό ενσωματωμένο πρόγραμμα αναπαραγωγής MPV. Οι χρήστες δεν χρειάζεται να εγκαταστήσουν ξεχωριστά την εφαρμογή MPV· τα εξωτερικά MPV και VLC παραμένουν διαθέσιμα ως εναλλακτικά προγράμματα αναπαραγωγής.", "COMMUNITY": "Έργο και κοινότητα", "COMMUNITY_DESCRIPTION": "Δώστε αστέρι στο αποθετήριο, διαβάστε το blog ή συμμετέχετε στην ομάδα Telegram για ενημερώσεις και βοήθεια.", "LINK_BLOG": "Blog", diff --git a/apps/web/src/assets/i18n/en.json b/apps/web/src/assets/i18n/en.json index ef090ecc2..1f5c4a7d6 100644 --- a/apps/web/src/assets/i18n/en.json +++ b/apps/web/src/assets/i18n/en.json @@ -249,7 +249,7 @@ "PLAYER_HTML5": "HTML5 video player", "PLAYER_VIDEOJS": "Video.js player", "PLAYER_ARTPLAYER": "ArtPlayer", - "PLAYER_EMBEDDED_MPV": "Embedded MPV (Experimental, macOS)", + "PLAYER_EMBEDDED_MPV": "Embedded MPV (Experimental)", "PLAYER_MPV": "MPV player", "PLAYER_VLC": "VLC", "VIDEO_PLAYER_PLACEHOLDER": "Select an option", @@ -296,7 +296,7 @@ "VERSION_DESCRIPTION": "Current version of the application", "ABOUT_SUBTITLE": "Version, project links and ways to support IPTVnator.", "EMBEDDED_MPV_NOTICE_TITLE": "Embedded MPV runtime", - "EMBEDDED_MPV_NOTICE_DESCRIPTION": "macOS builds can include bundled libmpv and FFmpeg libraries for the experimental Embedded MPV player. Users do not need to install the MPV app separately; external MPV and VLC remain available as fallback players.", + "EMBEDDED_MPV_NOTICE_DESCRIPTION": "Desktop builds can include bundled libmpv and FFmpeg libraries for the experimental Embedded MPV player. Users do not need to install the MPV app separately; external MPV and VLC remain available as fallback players.", "COMMUNITY": "Project & community", "COMMUNITY_DESCRIPTION": "Star the repository, read the blog or join the Telegram group for updates and help.", "LINK_BLOG": "Blog", diff --git a/apps/web/src/assets/i18n/es.json b/apps/web/src/assets/i18n/es.json index 7147f48dd..018f429b0 100644 --- a/apps/web/src/assets/i18n/es.json +++ b/apps/web/src/assets/i18n/es.json @@ -249,7 +249,7 @@ "PLAYER_HTML5": "Reproductor HTML5", "PLAYER_VIDEOJS": "Reproductor Video.js", "PLAYER_ARTPLAYER": "ArtPlayer", - "PLAYER_EMBEDDED_MPV": "MPV integrado (experimental, macOS)", + "PLAYER_EMBEDDED_MPV": "MPV integrado (experimental)", "PLAYER_MPV": "Reproductor MPV", "PLAYER_VLC": "VLC", "VIDEO_PLAYER_PLACEHOLDER": "Selecciona una opción", @@ -296,7 +296,7 @@ "VERSION_DESCRIPTION": "Versión actual de la aplicación.", "ABOUT_SUBTITLE": "Versión, enlaces del proyecto y formas de apoyar a IPTVnator.", "EMBEDDED_MPV_NOTICE_TITLE": "Runtime de MPV integrado", - "EMBEDDED_MPV_NOTICE_DESCRIPTION": "Las compilaciones de macOS pueden incluir las bibliotecas libmpv y FFmpeg integradas para el reproductor MPV integrado experimental. Los usuarios no necesitan instalar la app MPV por separado; MPV externo y VLC siguen disponibles como reproductores alternativos.", + "EMBEDDED_MPV_NOTICE_DESCRIPTION": "Las compilaciones de escritorio pueden incluir las bibliotecas libmpv y FFmpeg integradas para el reproductor MPV integrado experimental. Los usuarios no necesitan instalar la app MPV por separado; MPV externo y VLC siguen disponibles como reproductores alternativos.", "COMMUNITY": "Proyecto y comunidad", "COMMUNITY_DESCRIPTION": "Da una estrella al repositorio, lee el blog o únete al grupo de Telegram para novedades y ayuda.", "LINK_BLOG": "Blog", diff --git a/apps/web/src/assets/i18n/fr.json b/apps/web/src/assets/i18n/fr.json index 965fad288..d0e2129af 100644 --- a/apps/web/src/assets/i18n/fr.json +++ b/apps/web/src/assets/i18n/fr.json @@ -249,7 +249,7 @@ "PLAYER_HTML5": "Lecteur vidéo HTML5", "PLAYER_VIDEOJS": "Lecteur Video.js", "PLAYER_ARTPLAYER": "ArtPlayer", - "PLAYER_EMBEDDED_MPV": "MPV intégré (expérimental, macOS)", + "PLAYER_EMBEDDED_MPV": "MPV intégré (expérimental)", "PLAYER_MPV": "Lecteur MPV", "PLAYER_VLC": "VLC", "VIDEO_PLAYER_PLACEHOLDER": "Choisir une option", @@ -296,7 +296,7 @@ "VERSION_DESCRIPTION": "Version actuelle de l'application", "ABOUT_SUBTITLE": "Version, liens du projet et façons de soutenir IPTVnator.", "EMBEDDED_MPV_NOTICE_TITLE": "Runtime MPV intégré", - "EMBEDDED_MPV_NOTICE_DESCRIPTION": "Les versions macOS peuvent inclure les bibliothèques libmpv et FFmpeg empaquetées pour le lecteur MPV intégré expérimental. Les utilisateurs n'ont pas besoin d'installer l'application MPV séparément ; les lecteurs externes MPV et VLC restent disponibles comme lecteurs de secours.", + "EMBEDDED_MPV_NOTICE_DESCRIPTION": "Les versions desktop peuvent inclure les bibliothèques libmpv et FFmpeg empaquetées pour le lecteur MPV intégré expérimental. Les utilisateurs n'ont pas besoin d'installer l'application MPV séparément ; les lecteurs externes MPV et VLC restent disponibles comme lecteurs de secours.", "COMMUNITY": "Projet et communauté", "COMMUNITY_DESCRIPTION": "Mettez une étoile au dépôt, lisez le blog ou rejoignez le groupe Telegram pour les actualités et l'aide.", "LINK_BLOG": "Blog", diff --git a/apps/web/src/assets/i18n/it.json b/apps/web/src/assets/i18n/it.json index 2cdc39636..8da29c87b 100644 --- a/apps/web/src/assets/i18n/it.json +++ b/apps/web/src/assets/i18n/it.json @@ -249,7 +249,7 @@ "PLAYER_HTML5": "Lettore HTML5", "PLAYER_VIDEOJS": "Lettore Video.js", "PLAYER_ARTPLAYER": "ArtPlayer", - "PLAYER_EMBEDDED_MPV": "MPV integrato (sperimentale, macOS)", + "PLAYER_EMBEDDED_MPV": "MPV integrato (sperimentale)", "PLAYER_MPV": "Lettore MPV", "PLAYER_VLC": "VLC", "VIDEO_PLAYER_PLACEHOLDER": "Seleziona una opzione", @@ -296,7 +296,7 @@ "VERSION_DESCRIPTION": "Versione attuale dell'applicazione", "ABOUT_SUBTITLE": "Versione, link al progetto e modi per supportare IPTVnator.", "EMBEDDED_MPV_NOTICE_TITLE": "Runtime MPV integrato", - "EMBEDDED_MPV_NOTICE_DESCRIPTION": "Le build per macOS possono includere le librerie libmpv e FFmpeg in bundle per il lettore MPV integrato sperimentale. Gli utenti non devono installare l'app MPV separatamente; MPV e VLC esterni restano disponibili come lettori di riserva.", + "EMBEDDED_MPV_NOTICE_DESCRIPTION": "Le build desktop possono includere le librerie libmpv e FFmpeg in bundle per il lettore MPV integrato sperimentale. Gli utenti non devono installare l'app MPV separatamente; MPV e VLC esterni restano disponibili come lettori di riserva.", "COMMUNITY": "Progetto e community", "COMMUNITY_DESCRIPTION": "Metti una stella al repository, leggi il blog o unisciti al gruppo Telegram per aggiornamenti e supporto.", "LINK_BLOG": "Blog", diff --git a/apps/web/src/assets/i18n/ja.json b/apps/web/src/assets/i18n/ja.json index 30db48e09..27889de4d 100644 --- a/apps/web/src/assets/i18n/ja.json +++ b/apps/web/src/assets/i18n/ja.json @@ -249,7 +249,7 @@ "PLAYER_HTML5": "HTML5ビデオプレーヤー", "PLAYER_VIDEOJS": "Video.jsプレーヤー", "PLAYER_ARTPLAYER": "ArtPlayer", - "PLAYER_EMBEDDED_MPV": "組み込みMPV(実験的、macOS)", + "PLAYER_EMBEDDED_MPV": "組み込みMPV(実験的)", "PLAYER_MPV": "MPVプレーヤー", "PLAYER_VLC": "VLC", "VIDEO_PLAYER_PLACEHOLDER": "オプションを選択", @@ -296,7 +296,7 @@ "VERSION_DESCRIPTION": "アプリケーションの現在のバージョン", "ABOUT_SUBTITLE": "バージョン、プロジェクトのリンク、IPTVnatorをサポートする方法。", "EMBEDDED_MPV_NOTICE_TITLE": "組み込みMPVランタイム", - "EMBEDDED_MPV_NOTICE_DESCRIPTION": "macOS版のビルドには、実験的な組み込みMPVプレーヤー用のlibmpvおよびFFmpegライブラリを同梱できます。MPVアプリを別途インストールする必要はありません。フォールバックとして外部MPVおよびVLCも引き続き利用できます。", + "EMBEDDED_MPV_NOTICE_DESCRIPTION": "デスクトップ版のビルドには、実験的な組み込みMPVプレーヤー用のlibmpvおよびFFmpegライブラリを同梱できます。MPVアプリを別途インストールする必要はありません。フォールバックとして外部MPVおよびVLCも引き続き利用できます。", "COMMUNITY": "プロジェクトとコミュニティ", "COMMUNITY_DESCRIPTION": "リポジトリにスターを付けたり、ブログを読んだり、Telegramグループに参加して最新情報やサポートを受け取れます。", "LINK_BLOG": "ブログ", diff --git a/apps/web/src/assets/i18n/ko.json b/apps/web/src/assets/i18n/ko.json index cd288bd82..0c6e45542 100644 --- a/apps/web/src/assets/i18n/ko.json +++ b/apps/web/src/assets/i18n/ko.json @@ -249,7 +249,7 @@ "PLAYER_HTML5": "HTML5 비디오 플레이어", "PLAYER_VIDEOJS": "Video.js 플레이어", "PLAYER_ARTPLAYER": "ArtPlayer", - "PLAYER_EMBEDDED_MPV": "내장 MPV (실험적, macOS)", + "PLAYER_EMBEDDED_MPV": "내장 MPV (실험적)", "PLAYER_MPV": "MPV 플레이어", "PLAYER_VLC": "VLC", "VIDEO_PLAYER_PLACEHOLDER": "옵션을 선택하세요", @@ -296,7 +296,7 @@ "VERSION_DESCRIPTION": "애플리케이션의 현재 버전", "ABOUT_SUBTITLE": "버전, 프로젝트 링크 및 IPTVnator를 후원하는 방법.", "EMBEDDED_MPV_NOTICE_TITLE": "내장 MPV 런타임", - "EMBEDDED_MPV_NOTICE_DESCRIPTION": "macOS 빌드에는 실험적인 내장 MPV 플레이어를 위해 번들된 libmpv 및 FFmpeg 라이브러리가 포함될 수 있습니다. 사용자는 MPV 앱을 별도로 설치할 필요가 없으며, 외부 MPV와 VLC는 폴백 플레이어로 계속 사용할 수 있습니다.", + "EMBEDDED_MPV_NOTICE_DESCRIPTION": "데스크톱 빌드에는 실험적인 내장 MPV 플레이어를 위해 번들된 libmpv 및 FFmpeg 라이브러리가 포함될 수 있습니다. 사용자는 MPV 앱을 별도로 설치할 필요가 없으며, 외부 MPV와 VLC는 폴백 플레이어로 계속 사용할 수 있습니다.", "COMMUNITY": "프로젝트 및 커뮤니티", "COMMUNITY_DESCRIPTION": "저장소에 별표를 누르거나, 블로그를 읽거나, Telegram 그룹에 참여하여 업데이트와 도움을 받으세요.", "LINK_BLOG": "블로그", diff --git a/apps/web/src/assets/i18n/nl.json b/apps/web/src/assets/i18n/nl.json index 82713e78f..2bc801996 100644 --- a/apps/web/src/assets/i18n/nl.json +++ b/apps/web/src/assets/i18n/nl.json @@ -249,7 +249,7 @@ "PLAYER_HTML5": "HTML5-videospeler", "PLAYER_VIDEOJS": "Video.js-speler", "PLAYER_ARTPLAYER": "ArtPlayer", - "PLAYER_EMBEDDED_MPV": "Ingebedde MPV (experimenteel, macOS)", + "PLAYER_EMBEDDED_MPV": "Ingebedde MPV (experimenteel)", "PLAYER_MPV": "MPV-speler", "PLAYER_VLC": "VLC", "VIDEO_PLAYER_PLACEHOLDER": "Selecteer een optie", @@ -296,7 +296,7 @@ "VERSION_DESCRIPTION": "Huidige versie van de applicatie", "ABOUT_SUBTITLE": "Versie, projectlinks en manieren om IPTVnator te ondersteunen.", "EMBEDDED_MPV_NOTICE_TITLE": "Ingebedde MPV-runtime", - "EMBEDDED_MPV_NOTICE_DESCRIPTION": "macOS-builds kunnen meegeleverde libmpv- en FFmpeg-bibliotheken bevatten voor de experimentele Embedded MPV-speler. Gebruikers hoeven de MPV-app niet apart te installeren; externe MPV en VLC blijven beschikbaar als terugvalspelers.", + "EMBEDDED_MPV_NOTICE_DESCRIPTION": "Desktop-builds kunnen meegeleverde libmpv- en FFmpeg-bibliotheken bevatten voor de experimentele Embedded MPV-speler. Gebruikers hoeven de MPV-app niet apart te installeren; externe MPV en VLC blijven beschikbaar als terugvalspelers.", "COMMUNITY": "Project & community", "COMMUNITY_DESCRIPTION": "Geef de repository een ster, lees de blog of word lid van de Telegram-groep voor updates en hulp.", "LINK_BLOG": "Blog", diff --git a/apps/web/src/assets/i18n/pl.json b/apps/web/src/assets/i18n/pl.json index 69e3909d5..e62cea92b 100644 --- a/apps/web/src/assets/i18n/pl.json +++ b/apps/web/src/assets/i18n/pl.json @@ -249,7 +249,7 @@ "PLAYER_HTML5": "Odtwarzacz wideo HTML5", "PLAYER_VIDEOJS": "Odtwarzacz Video.js", "PLAYER_ARTPLAYER": "ArtPlayer", - "PLAYER_EMBEDDED_MPV": "Wbudowany MPV (eksperymentalny, macOS)", + "PLAYER_EMBEDDED_MPV": "Wbudowany MPV (eksperymentalny)", "PLAYER_MPV": "Odtwarzacz MPV", "PLAYER_VLC": "VLC", "VIDEO_PLAYER_PLACEHOLDER": "Wybierz opcję", @@ -296,7 +296,7 @@ "VERSION_DESCRIPTION": "Aktualna wersja aplikacji", "ABOUT_SUBTITLE": "Wersja, linki do projektu i sposoby wsparcia IPTVnator.", "EMBEDDED_MPV_NOTICE_TITLE": "Środowisko wbudowanego MPV", - "EMBEDDED_MPV_NOTICE_DESCRIPTION": "Wersje dla macOS mogą zawierać dołączone biblioteki libmpv i FFmpeg dla eksperymentalnego wbudowanego odtwarzacza MPV. Użytkownicy nie muszą oddzielnie instalować aplikacji MPV; zewnętrzne MPV i VLC pozostają dostępne jako odtwarzacze zapasowe.", + "EMBEDDED_MPV_NOTICE_DESCRIPTION": "Wersje desktopowe mogą zawierać dołączone biblioteki libmpv i FFmpeg dla eksperymentalnego wbudowanego odtwarzacza MPV. Użytkownicy nie muszą oddzielnie instalować aplikacji MPV; zewnętrzne MPV i VLC pozostają dostępne jako odtwarzacze zapasowe.", "COMMUNITY": "Projekt i społeczność", "COMMUNITY_DESCRIPTION": "Daj gwiazdkę repozytorium, czytaj bloga lub dołącz do grupy Telegram, aby otrzymywać aktualizacje i pomoc.", "LINK_BLOG": "Blog", diff --git a/apps/web/src/assets/i18n/pt.json b/apps/web/src/assets/i18n/pt.json index 23dee464d..90bfab72e 100644 --- a/apps/web/src/assets/i18n/pt.json +++ b/apps/web/src/assets/i18n/pt.json @@ -249,7 +249,7 @@ "PLAYER_HTML5": "Reprodutor de vídeo HTML5", "PLAYER_VIDEOJS": "Reprodutor Video.js", "PLAYER_ARTPLAYER": "ArtPlayer", - "PLAYER_EMBEDDED_MPV": "MPV integrado (experimental, macOS)", + "PLAYER_EMBEDDED_MPV": "MPV integrado (experimental)", "PLAYER_MPV": "Reprodutor MPV", "PLAYER_VLC": "VLC", "VIDEO_PLAYER_PLACEHOLDER": "Selecione uma opção", @@ -296,7 +296,7 @@ "VERSION_DESCRIPTION": "Versão atual do aplicativo", "ABOUT_SUBTITLE": "Versão, links do projeto e formas de apoiar o IPTVnator.", "EMBEDDED_MPV_NOTICE_TITLE": "Runtime MPV integrado", - "EMBEDDED_MPV_NOTICE_DESCRIPTION": "Builds para macOS podem incluir as bibliotecas libmpv e FFmpeg empacotadas para o reprodutor MPV integrado experimental. Os usuários não precisam instalar o aplicativo MPV separadamente; MPV externo e VLC permanecem disponíveis como reprodutores alternativos.", + "EMBEDDED_MPV_NOTICE_DESCRIPTION": "Builds desktop podem incluir as bibliotecas libmpv e FFmpeg empacotadas para o reprodutor MPV integrado experimental. Os usuários não precisam instalar o aplicativo MPV separadamente; MPV externo e VLC permanecem disponíveis como reprodutores alternativos.", "COMMUNITY": "Projeto e comunidade", "COMMUNITY_DESCRIPTION": "Marque o repositório com estrela, leia o blog ou junte-se ao grupo do Telegram para atualizações e ajuda.", "LINK_BLOG": "Blog", diff --git a/apps/web/src/assets/i18n/ru.json b/apps/web/src/assets/i18n/ru.json index 6b2154d49..f690bcea7 100644 --- a/apps/web/src/assets/i18n/ru.json +++ b/apps/web/src/assets/i18n/ru.json @@ -249,7 +249,7 @@ "PLAYER_HTML5": "HTML5-плеер", "PLAYER_VIDEOJS": "Video.js-плеер", "PLAYER_ARTPLAYER": "ArtPlayer", - "PLAYER_EMBEDDED_MPV": "Встроенный MPV (экспериментальный, macOS)", + "PLAYER_EMBEDDED_MPV": "Встроенный MPV (экспериментальный)", "PLAYER_MPV": "MPV-плеер", "PLAYER_VLC": "VLC", "VIDEO_PLAYER_PLACEHOLDER": "Выберите из списка", @@ -296,7 +296,7 @@ "VERSION_DESCRIPTION": "Текущая версия приложения", "ABOUT_SUBTITLE": "Версия, ссылки на проект и способы поддержать IPTVnator.", "EMBEDDED_MPV_NOTICE_TITLE": "Среда выполнения встроенного MPV", - "EMBEDDED_MPV_NOTICE_DESCRIPTION": "Сборки для macOS могут включать в себя библиотеки libmpv и FFmpeg для экспериментального встроенного MPV-проигрывателя. Установка отдельного приложения MPV не требуется; внешние MPV и VLC по-прежнему доступны как запасные проигрыватели.", + "EMBEDDED_MPV_NOTICE_DESCRIPTION": "Сборки для настольных платформ могут включать библиотеки libmpv и FFmpeg для экспериментального встроенного MPV-проигрывателя. Установка отдельного приложения MPV не требуется; внешние MPV и VLC по-прежнему доступны как запасные проигрыватели.", "COMMUNITY": "Проект и сообщество", "COMMUNITY_DESCRIPTION": "Поставьте звезду репозиторию, читайте блог или присоединяйтесь к Telegram-группе для новостей и поддержки.", "LINK_BLOG": "Блог", diff --git a/apps/web/src/assets/i18n/tr.json b/apps/web/src/assets/i18n/tr.json index 09de557b1..8a44cc88a 100644 --- a/apps/web/src/assets/i18n/tr.json +++ b/apps/web/src/assets/i18n/tr.json @@ -249,7 +249,7 @@ "PLAYER_HTML5": "HTML5 video oynatıcı", "PLAYER_VIDEOJS": "Video.js oynatıcı", "PLAYER_ARTPLAYER": "ArtPlayer", - "PLAYER_EMBEDDED_MPV": "Gömülü MPV (Deneysel, macOS)", + "PLAYER_EMBEDDED_MPV": "Gömülü MPV (Deneysel)", "PLAYER_MPV": "MPV oynatıcı", "PLAYER_VLC": "VLC", "VIDEO_PLAYER_PLACEHOLDER": "Bir seçenek belirleyin", @@ -296,7 +296,7 @@ "VERSION_DESCRIPTION": "Uygulamanın geçerli sürümü", "ABOUT_SUBTITLE": "Sürüm, proje bağlantıları ve IPTVnator'u desteklemenin yolları.", "EMBEDDED_MPV_NOTICE_TITLE": "Gömülü MPV çalışma zamanı", - "EMBEDDED_MPV_NOTICE_DESCRIPTION": "macOS sürümleri, deneysel Gömülü MPV oynatıcı için paketlenmiş libmpv ve FFmpeg kitaplıklarını içerebilir. Kullanıcıların MPV uygulamasını ayrıca kurmasına gerek yoktur; harici MPV ve VLC, yedek oynatıcı olarak kullanılabilir kalır.", + "EMBEDDED_MPV_NOTICE_DESCRIPTION": "Masaüstü sürümleri, deneysel Gömülü MPV oynatıcı için paketlenmiş libmpv ve FFmpeg kitaplıklarını içerebilir. Kullanıcıların MPV uygulamasını ayrıca kurmasına gerek yoktur; harici MPV ve VLC, yedek oynatıcı olarak kullanılabilir kalır.", "COMMUNITY": "Proje ve topluluk", "COMMUNITY_DESCRIPTION": "Depoyu yıldızlayın, blogu okuyun veya güncellemeler ve yardım için Telegram grubuna katılın.", "LINK_BLOG": "Blog", diff --git a/apps/web/src/assets/i18n/zh.json b/apps/web/src/assets/i18n/zh.json index 0106a2665..bbb4a10fc 100644 --- a/apps/web/src/assets/i18n/zh.json +++ b/apps/web/src/assets/i18n/zh.json @@ -249,7 +249,7 @@ "PLAYER_HTML5": "HTML5 视频播放器", "PLAYER_VIDEOJS": "Video.js 播放器", "PLAYER_ARTPLAYER": "ArtPlayer", - "PLAYER_EMBEDDED_MPV": "嵌入式 MPV(实验性,macOS)", + "PLAYER_EMBEDDED_MPV": "嵌入式 MPV(实验性)", "PLAYER_MPV": "MPV 播放器", "PLAYER_VLC": "VLC", "VIDEO_PLAYER_PLACEHOLDER": "选择一个选项", @@ -296,7 +296,7 @@ "VERSION_DESCRIPTION": "应用程序的当前版本", "ABOUT_SUBTITLE": "版本、项目链接以及支持 IPTVnator 的方式。", "EMBEDDED_MPV_NOTICE_TITLE": "嵌入式 MPV 运行时", - "EMBEDDED_MPV_NOTICE_DESCRIPTION": "macOS 构建版本可包含捆绑的 libmpv 和 FFmpeg 库,用于实验性的嵌入式 MPV 播放器。用户无需单独安装 MPV 应用;外部 MPV 和 VLC 仍可作为备用播放器使用。", + "EMBEDDED_MPV_NOTICE_DESCRIPTION": "桌面构建版本可包含捆绑的 libmpv 和 FFmpeg 库,用于实验性的嵌入式 MPV 播放器。用户无需单独安装 MPV 应用;外部 MPV 和 VLC 仍可作为备用播放器使用。", "COMMUNITY": "项目与社区", "COMMUNITY_DESCRIPTION": "为仓库点星、阅读博客或加入 Telegram 群组,以获取更新和帮助。", "LINK_BLOG": "博客", diff --git a/apps/web/src/assets/i18n/zhtw.json b/apps/web/src/assets/i18n/zhtw.json index d16b14a9a..524ecf1ea 100644 --- a/apps/web/src/assets/i18n/zhtw.json +++ b/apps/web/src/assets/i18n/zhtw.json @@ -249,7 +249,7 @@ "PLAYER_HTML5": "HTML5 影片播放器", "PLAYER_VIDEOJS": "Video.js 播放器", "PLAYER_ARTPLAYER": "ArtPlayer", - "PLAYER_EMBEDDED_MPV": "內嵌 MPV(實驗性,macOS)", + "PLAYER_EMBEDDED_MPV": "內嵌 MPV(實驗性)", "PLAYER_MPV": "MPV 播放器", "PLAYER_VLC": "VLC", "VIDEO_PLAYER_PLACEHOLDER": "選擇一個選項", @@ -296,7 +296,7 @@ "VERSION_DESCRIPTION": "應用程式目前版本", "ABOUT_SUBTITLE": "版本、專案連結,以及支持 IPTVnator 的方式。", "EMBEDDED_MPV_NOTICE_TITLE": "內嵌 MPV 執行環境", - "EMBEDDED_MPV_NOTICE_DESCRIPTION": "macOS 版本可內含 libmpv 和 FFmpeg 程式庫,用於實驗性的內嵌 MPV 播放器。使用者無需另行安裝 MPV 應用程式;外部 MPV 與 VLC 仍可作為備援播放器使用。", + "EMBEDDED_MPV_NOTICE_DESCRIPTION": "桌面版本可內含 libmpv 和 FFmpeg 程式庫,用於實驗性的內嵌 MPV 播放器。使用者無需另行安裝 MPV 應用程式;外部 MPV 與 VLC 仍可作為備援播放器使用。", "COMMUNITY": "專案與社群", "COMMUNITY_DESCRIPTION": "為儲存庫加上星標、閱讀部落格,或加入 Telegram 群組以取得最新資訊與協助。", "LINK_BLOG": "部落格", diff --git a/docs/architecture/embedded-inline-playback.md b/docs/architecture/embedded-inline-playback.md index 309c6873c..e4866419d 100644 --- a/docs/architecture/embedded-inline-playback.md +++ b/docs/architecture/embedded-inline-playback.md @@ -5,7 +5,7 @@ This document records the current contract for embedded playback in portal detai ## Summary - Embedded web players are `videojs`, `html5`, and `artplayer`. -- `embedded-mpv` exists as a hidden macOS-only feasibility harness backed by a native `libmpv` addon. +- `embedded-mpv` exists as a hidden desktop experimental harness backed by a native `libmpv` addon. - Controlled external players are `mpv` and `vlc`. - macOS `.app` bundle paths are resolved only for real MPV/VLC apps. IINA may launch through the MPV path field when the user supplies an executable path @@ -50,12 +50,13 @@ The repository now contains a first-pass native embedded MPV harness for Electro Current contract: -- macOS only +- desktop only: macOS, Windows x64, and Linux x64 under X11/Xwayland - experimental opt-in - enabled in local development only when `IPTVNATOR_ENABLE_EMBEDDED_MPV_EXPERIMENT=1` -- enabled in packaged macOS builds only when the bundled native addon and `vendored-lgpl` libmpv runtime load successfully +- enabled in packaged desktop builds only when the bundled native addon and `vendored-lgpl` libmpv runtime load successfully - uses IPTVnator-owned controls and `ResolvedPortalPlayback` payloads - uses the libmpv render API on macOS and renders through an IPTVnator-owned native `NSView` +- uses mpv `wid` embedding on Windows and Linux through IPTVnator-owned native child windows - defaults to libmpv's OpenGL render backend with `hwdec=auto-safe` - keeps the previous software renderer as a debug fallback via `IPTVNATOR_EMBEDDED_MPV_RENDERER=sw` - emits lightweight render diagnostics when `IPTVNATOR_TRACE_EMBEDDED_MPV=1` is set @@ -68,8 +69,10 @@ Current contract: Current limitation: -- the current feasibility harness is still experimental and macOS-specific +- the current feasibility harness is still experimental and platform-specific - the original macOS `wid` embedding path produced audio with a black video surface inside Electron, so the harness now avoids foreign-window embedding on macOS +- Windows and Linux use the mpv `wid` path, require staged LGPL runtime files, and still need OS-native smoke coverage before public exposure +- Linux native Wayland is not supported in this implementation; the Electron process must have `DISPLAY` through X11 or Xwayland - the OpenGL render path avoids the old per-frame `CGImage` copy path, but it still needs broader interaction, resize, and packaging coverage - startup deadlocks seen during early macOS playback bring-up are mitigated, but the feature is still kept behind the explicit experiment flag until more interaction and packaging coverage is proven - because of that, the setting is auto-sanitized back to the default inline player unless support detection reports that the experimental runtime is available @@ -145,7 +148,7 @@ The diagnostics remain client-only: - no ffprobe or server-side probing - no extra manifest fetch beyond the active player - no automatic failover to an external player -- no embedded MPV macOS diagnostics +- no embedded MPV diagnostics Supported diagnostic codes are: diff --git a/docs/architecture/embedded-mpv-native.md b/docs/architecture/embedded-mpv-native.md index df9353af7..0a1536426 100644 --- a/docs/architecture/embedded-mpv-native.md +++ b/docs/architecture/embedded-mpv-native.md @@ -6,9 +6,12 @@ This document explains how IPTVnator embeds MPV inside the Electron app, which f Source files for the embedded MPV integration: -- `apps/electron-backend/build-embedded-mpv.js` builds the native addon for the current Electron runtime. +- `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 libmpv session surface. - `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. @@ -23,19 +26,19 @@ The build directory contains files such as `Makefile`, `binding.Makefile`, `conf ## How It Is Embedded -The embedded player does not spawn the normal `mpv` application and does not use `--wid` window reparenting. IPTVnator loads `libmpv` through a native Node addon and renders MPV frames into an app-owned native macOS view. +The embedded player does not spawn the normal `mpv` application. IPTVnator loads `libmpv` through a native Node addon and renders MPV frames into an app-owned native video surface. macOS uses the libmpv render API in an `NSOpenGLView` because the mpv `wid` path produced a black video surface inside Electron. Windows and Linux use mpv's `wid` option against IPTVnator-owned child windows (`HWND` on Windows, X11 `Window` on Linux/Xwayland). The 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 dylibs, but does not create a native view or MPV playback session. +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. The native addon creates an `mpv_handle`, configures `vo=libmpv`, disables MPV's own OSC/input handling, and creates an app-owned `NSOpenGLView` inside the Electron window content view. -8. The addon creates a `mpv_render_context` and uses MPV's render API to draw frames into the OpenGL surface. -9. Resize, scroll, and fullscreen changes are measured in Angular and sent back to the addon as native bounds so the `NSView` stays aligned with the Angular layout. +7. The native addon creates an `mpv_handle`, disables MPV's own OSC/input handling, and creates an app-owned platform video host inside the Electron window. +8. On macOS the addon configures `vo=libmpv`, creates a `mpv_render_context`, and draws into the OpenGL surface. On Windows/Linux it passes the child-window id to mpv through `wid` and uses `vo=gpu`. +9. Resize, scroll, and fullscreen changes are measured in Angular and sent back to the addon as native bounds so the platform video host stays aligned with the Angular layout. 10. Playback controls remain IPTVnator-owned Angular UI. MPV receives commands only through the controlled IPC surface. The renderer never gets direct native-module access. It can only call the preload contract: @@ -53,11 +56,11 @@ The renderer never gets direct native-module access. It can only call the preloa - dispose session - subscribe to session updates -Settings uses the preload support API only as a lightweight availability check. That check verifies platform, experiment gating, addon presence, and bundled `libmpv.2.dylib` presence without `require()`-loading `embedded_mpv.node`. This avoids blocking Settings navigation on macOS `dlopen` and code-signing work. +Settings uses the preload support API only as a lightweight availability check. That check verifies platform, experiment gating, addon presence, and bundled platform runtime presence without `require()`-loading `embedded_mpv.node`. This avoids blocking Settings navigation on synchronous native-addon loading and code-signing or dynamic-linker work. When `embedded-mpv` is the saved player, the settings store schedules an idle `prepareEmbeddedMpv()` call. This intentionally moves the first native addon load away from the click-to-play path. It can still block the Electron main process briefly because Node native addon loading is synchronous, but doing it during idle is less visible than doing it when the user clicks a video. Actual MPV session creation still happens on playback because it needs the current Electron window handle and viewport bounds. -The MPV video surface is a native AppKit/OpenGL view, not a normal DOM element. Do not place critical Angular overlays on top of the video viewport and expect CSS `z-index` to win. The embedded MPV controls use a compositor-safe control dock below the native viewport instead of a true overlay on top of the OpenGL surface. +The MPV video surface is a native platform view/window, not a normal DOM element. Do not place critical Angular overlays on top of the video viewport and expect CSS `z-index` to win. The embedded MPV controls use a compositor-safe control dock below the native viewport instead of a true overlay on top of the native video surface. The dock has a stable reserved height while embedded controls are enabled. Controls fade in and out inside that fixed dock, so normal show/hide behavior does not resize the native MPV viewport or make the video jump. Volume and audio-track panels replace the default transport controls inside the same dock and provide a back button to return to the default controls. Popovers and menus must stay inside that dock unless the native layering strategy changes. The native MPV view deliberately ignores hit testing so mouse movement passes through to Chromium and can reveal Angular controls even when the pointer moves quickly across the video area. @@ -110,16 +113,16 @@ The Angular side of the embedded MPV player is intentionally split so the player - `embedded-mpv-format.utils.ts` — pure helpers (`formatTime`, `audioTrackLabel`, `subtitleTrackLabel`, `speedLabel`, `aspectLabel`, `volumeIcon`, `volumeLabel`, `readStoredVolume`, `persistVolume`, `measureBounds`) and preset constants (`SPEED_PRESETS`, `ASPECT_PRESETS`, `HIDDEN_BOUNDS`, `MENU_OPEN_BOTTOM_CUTOUT_PX`). - `embedded-mpv-shortcuts.ts` — `EmbeddedMpvShortcuts` class with `attach(handlers)` / `detach()`. Owns the document keydown listener and routes through a callback interface; the component supplies the callbacks. Listens for Space/K (toggle), F (fullscreen), arrow keys (seek/volume), M (mute), Escape (close popovers). -- `embedded-mpv-overlay-visibility.service.ts` — singleton service that exposes `overlayActive: signal`. Tracks `MatDialog.afterOpened`/`afterAllClosed` for dialog-shaped overlays and falls back to a `MutationObserver` on the CDK overlay container for any remaining backdrop-bearing CDK overlays. The MPV NSView is hidden off-screen while a modal is open so DOM dialogs can paint above it. +- `embedded-mpv-overlay-visibility.service.ts` — singleton service that exposes `overlayActive: signal`. Tracks `MatDialog.afterOpened`/`afterAllClosed` for dialog-shaped overlays and falls back to a `MutationObserver` on the CDK overlay container for any remaining backdrop-bearing CDK overlays. The native MPV video host is hidden off-screen while a modal is open so DOM dialogs can paint above it. - `embedded-mpv-ui-state.ts` — `EmbeddedMpvMenuState` (single-open popover state machine with `volumeOpen`, `audioOpen`, `subtitleOpen`, `speedOpen`, `aspectOpen` signals plus `anyOpen` computed; `toggle`/`open`/`close`/`closeAll` helpers) and `EmbeddedMpvFeedback` (transient overlay that auto-clears after a configurable delay; used for keypress feedback). - `embedded-mpv-session-controller.ts` — component-scoped `Injectable` service that owns the `support`, `session`, `sessionId`, `stalled`, and `retryToken` signals. Subscribes to `onEmbeddedMpvSessionUpdate`, runs the polling-driven `stalled` timer, owns bounds-sync (resize, scroll, overlay state), and exposes the imperative IPC surface (`startSession`, `togglePaused`, `seekBy`/`seekTo`, `applyVolume`, `setAudioTrack`, `setSubtitleTrack`, `setSpeed`, `setAspect`, `startRecording`, `stopRecording`, `retry`). - `embedded-mpv-player.component.ts` — view-only shell. Holds view children, derived `computed` signals, DOM event listeners (pointermove, pointerdown, fullscreenchange, dblclick), and three `effect()`s. ### Bounds compositing strategy -The native NSView paints over the WebContents layer, so any DOM region it covers cannot receive pointer events and any CSS `z-index` competition is unwinnable. The component compensates with a single `boundsProvider(host)` closure on the controller that returns one of three bound shapes, evaluated each time the active bounds-sync runs: +The native video host paints outside the normal DOM stacking model, so any DOM region it covers cannot reliably receive pointer events and any CSS `z-index` competition is unwinnable. The component compensates with a single `boundsProvider(host)` closure on the controller that returns one of three bound shapes, evaluated each time the active bounds-sync runs: -- **Modal overlay open** (any MatDialog, including the command palette) → `HIDDEN_BOUNDS`. The MPV view moves off-screen so the dialog has the full window. +- **Modal overlay open** (any MatDialog, including the command palette) → `HIDDEN_BOUNDS`. The MPV video host moves off-screen so the dialog has the full window. - **Control popover open** (any of the menu states above) → host bounds with `MENU_OPEN_BOTTOM_CUTOUT_PX` (300 px) removed from the bottom. The popover region becomes DOM-receiving while video keeps playing in the upper region. - **Idle** → full host bounds. @@ -156,26 +159,28 @@ The Electron main process holds an `electron.powerSaveBlocker` of type `prevent- Current development behavior: -- The addon build is macOS-only. -- The build script first looks for a staged runtime at `vendor/embedded-mpv/darwin-/`. -- The staged runtime must contain `include/mpv/client.h`, `lib/*.dylib`, and `runtime-manifest.json`. +- The addon build supports `darwin`, `win32`, and `linux`; Windows and Linux builds require running on that target OS. +- The build script first looks for a staged runtime at `vendor/embedded-mpv/-/`. +- The staged runtime must contain `include/mpv/client.h`, platform runtime files, and `runtime-manifest.json`. - The 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/`. Most are `.dylib` files, but some Homebrew-linked runtimes expose non-`.dylib` Mach-O files such as a framework `Python` binary. -- macOS `afterPack` copies `dist/apps/electron-backend/native/` into `app.asar.unpacked/electron-backend/native/` so the addon, manifest, dylibs, and non-`.dylib` Mach-O runtime files are filesystem-addressable. -- Linux and Windows packaging do not include the Embedded MPV native directory. +- Bundled runtime files are copied into `dist/apps/electron-backend/native/lib/`. macOS copies `.dylib` and non-`.dylib` Mach-O dependencies, Windows copies `mpv-2.dll`/`mpv.dll` and import libraries, and Linux copies `libmpv.so*`. +- `afterPack` copies `dist/apps/electron-backend/native/` into `app.asar.unpacked/electron-backend/native/` on macOS, Windows, and Linux so the addon, manifest, and runtime libraries are filesystem-addressable. Current release caveat: - Release packaging requires a `vendored-lgpl` runtime manifest. -- Release packaging rejects embedded MPV binaries linked to `/opt/homebrew` or `/usr/local`. +- macOS release packaging rejects embedded MPV binaries linked to `/opt/homebrew` or `/usr/local`. +- Windows and Linux release packaging verifies that the platform runtime file is present when Embedded MPV is required. - Local development can opt into Homebrew `libmpv` only by setting `IPTVNATOR_EMBEDDED_MPV_ALLOW_HOMEBREW=1`; packaged release validation rejects that runtime origin. Before public release, packaging must: -- stage an LGPL-compatible `libmpv` and required dylibs for `darwin-arm64` and `darwin-x64` -- collect indirect dependencies expressed as absolute paths, `@loader_path`, or `@rpath` -- rewrite install names and dependency paths to app-relative paths such as `@loader_path` -- code-sign and notarize the full dependency set +- stage an LGPL-compatible `libmpv` runtime for each release platform/architecture +- collect indirect macOS dependencies expressed as absolute paths, `@loader_path`, or `@rpath` +- rewrite macOS install names and dependency paths to app-relative paths such as `@loader_path` +- code-sign and notarize the full macOS dependency set +- ensure Windows runtime staging includes both the DLL and the import library used by `node-gyp` +- ensure Linux runtime staging includes a runtime SONAME (`libmpv.so.2`, `libmpv.so.1`, or `libmpv.so`) and an unversioned `libmpv.so` linker name for local native builds - publish the corresponding FFmpeg/libmpv source and build metadata Users do not need the MPV GUI application for this architecture. IPTVnator bundles `libmpv` for release builds. If the bundled runtime is missing or fails to load, embedded MPV is hidden/unsupported and the existing inline/external players remain available. @@ -193,25 +198,27 @@ Release runtime policy: - 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 an architecture: +After building an LGPL-compatible prefix for a platform/architecture: ```bash -node tools/embedded-mpv/stage-macos-runtime.mjs arm64 /path/to/lgpl-prefix -node tools/embedded-mpv/stage-macos-runtime.mjs x64 /path/to/lgpl-prefix +pnpm embedded-mpv:stage-runtime -- darwin arm64 /path/to/lgpl-prefix +pnpm embedded-mpv:stage-runtime -- darwin x64 /path/to/lgpl-prefix +pnpm embedded-mpv:stage-runtime -- win32 x64 /path/to/lgpl-prefix +pnpm embedded-mpv:stage-runtime -- linux x64 /path/to/lgpl-prefix ``` Tagged macOS release CI builds that prefix from pinned source archives first. The workflow can temporarily run the same path for macOS PR artifacts while the bundled runtime is being tested: ```bash pnpm embedded-mpv:build-runtime -- arm64 /tmp/embedded-mpv-prefix -pnpm embedded-mpv:stage-runtime -- arm64 /tmp/embedded-mpv-prefix +pnpm embedded-mpv:stage-runtime -- darwin arm64 /tmp/embedded-mpv-prefix ``` -During temporary PR and `master` artifact testing, CI can restore an exact-keyed GitHub Actions cache for the staged `vendor/embedded-mpv/darwin-/` runtime and skip the expensive source build. The cache only contains `include/`, `lib/`, and `runtime-manifest.json`; it never contains the compiled `embedded_mpv.node` addon because that target depends on Electron headers, ABI, architecture, and build environment. Runtime cache entries are saved only from trusted repository refs, and tagged public release builds continue to rebuild from pinned sources until a dedicated signed and attested runtime artifact flow exists. +During temporary PR and `master` artifact testing, CI can restore an exact-keyed GitHub Actions cache for the staged `vendor/embedded-mpv/-/` runtime and skip the expensive source build where a source builder exists. The cache only contains `include/`, `lib/`, and `runtime-manifest.json`; it never contains the compiled `embedded_mpv.node` addon because that target depends on Electron headers, ABI, architecture, and build environment. Runtime cache entries are saved only from trusted repository refs, and tagged public macOS release builds continue to rebuild from pinned sources until a dedicated signed and attested runtime artifact flow exists. Windows and Linux currently use staged runtime cache inputs only; adding pinned source builders for those platforms is a separate release-hardening task. The CI builder pins FFmpeg `8.1`, mpv `0.41.0`, libplacebo `7.360.1`, libass `0.17.3`, FreeType `2.13.3`, FriBidi `1.0.16`, and HarfBuzz `8.5.0`. FFmpeg disables autodetected external libraries so Homebrew libraries cannot silently enter the runtime. Libplacebo is checked out from git with the submodules required by its Meson build because the generated GitHub archive does not include submodule contents. Even with Vulkan disabled, libplacebo still compiles Vulkan stubs and needs `3rdparty/Vulkan-Headers`. The generated manifest records source URLs, archive SHA-256 values where applicable, libplacebo git commit/submodule metadata, FFmpeg configure flags, and mpv Meson flags. The staging step normalizes that manifest to `origin: vendored-lgpl`, which release package validation requires. -The Electron backend build consumes the staged runtime, copies Mach-O runtime files into the native build output, and rewrites Mach-O paths so `embedded_mpv.node` loads `@loader_path/lib/libmpv.2.dylib` instead of a machine-local Homebrew path. After `install_name_tool` rewrites any addon or runtime binary, the build re-signs that binary with an ad-hoc signature for local development. Release packaging still performs the normal app signing and notarization later. +The Electron backend build consumes the staged runtime and copies platform runtime files into the native build output. macOS additionally rewrites Mach-O paths so `embedded_mpv.node` loads `@loader_path/lib/libmpv.2.dylib` instead of a machine-local Homebrew path. After `install_name_tool` rewrites any addon or runtime binary, the build re-signs that binary with an ad-hoc signature for local development. Release packaging still performs the normal app signing and notarization later. For local development before the vendored runtime exists, Homebrew can be used explicitly: @@ -219,9 +226,9 @@ For local development before the vendored runtime exists, Homebrew can be used e 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 development-only. Packaged macOS builds reject `homebrew-dev` manifests and any `/opt/homebrew` or `/usr/local` embedded MPV links. +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, macOS)` after starting with those flags, check the native build output: +If the settings page does not show `Embedded MPV (Experimental)` after starting with those flags, check the native build output: ```bash ls apps/electron-backend/native/build/Release/embedded_mpv.node @@ -237,40 +244,46 @@ codesign --verify --verbose=2 apps/electron-backend/native/build/Release/embedde codesign --verify --verbose=2 apps/electron-backend/native/build/Release/lib/libmpv.2.dylib ``` -If 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/`, and include non-`.dylib` Mach-O files in the asset copy glob. +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/`, and include non-`.dylib` Mach-O files in the asset copy glob. -## Same-Version macOS Release Gate +## Same-Version Desktop Release Gate -The normal release tag can produce Linux, Windows, and macOS artifacts from the same source version while only macOS carries the bundled Embedded MPV runtime. +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 -During the temporary macOS artifact tests, CI also sets `IPTVNATOR_REQUIRE_EMBEDDED_MPV=1` for macOS PR and `master` push backend build, packaging, and package-layout verification. After the artifacts are manually validated, remove the workflow's temporary `pull_request` and `refs/heads/master` conditions so PR, development, Linux, and Windows packaging leave `IPTVNATOR_REQUIRE_EMBEDDED_MPV` unset or `0`. In that normal mode the package validators still reject a present but invalid Embedded MPV runtime, but they do not require the addon to exist. This keeps the native feature in-tree without making non-macOS or non-release builds depend on macOS runtime artifacts. +For Windows and Linux builds, CI must set `IPTVNATOR_EMBEDDED_MPV_PLATFORM`/`IPTVNATOR_EMBEDDED_MPV_ARCH` for native build and package verification. When an exact-keyed staged runtime cache is restored, the job can set `IPTVNATOR_REQUIRE_EMBEDDED_MPV=1` and verify the packaged `.dll` or `.so` runtime. Without a staged runtime, the build script writes an unavailable marker and Settings hides Embedded MPV. + +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 dylib dependencies is missing, unsigned, or linked to the wrong runtime path +- 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 Wayland is unsupported until a dedicated Wayland embedding path exists - 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 macOS-only experimental if support detection is strict, the UI clearly labels it experimental, and fallback to Video.js or external MPV/VLC stays available. +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 macOS smoke testing and packaged-app testing are required before broad release. +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 both Apple Silicon and Intel macOS: +Do not expose embedded MPV broadly until these pass on every supported target: -- packaged `.app` starts without Homebrew installed -- bundled `libmpv` and dependent dylibs pass code signing and notarization +- packaged app starts without system `mpv` installed +- bundled `libmpv` and dependent runtime files pass platform package validation +- 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 diff --git a/electron-builder.json b/electron-builder.json index 22af70f51..f6bc40041 100644 --- a/electron-builder.json +++ b/electron-builder.json @@ -21,7 +21,11 @@ "**/bindings/**", "**/file-uri-to-path/**", "**/*.node", - "**/*.dylib" + "**/*.dylib", + "**/*.dll", + "**/*.so", + "**/*.so.*", + "**/embedded-mpv-runtime.json" ], "extraMetadata": { "name": "iptvnator", diff --git a/package.json b/package.json index 3c9b4b191..9f52486b2 100644 --- a/package.json +++ b/package.json @@ -45,7 +45,8 @@ "verify:package-layout": "node tools/packaging/verify-electron-package-layout.mjs", "embedded-mpv:build-native:homebrew": "IPTVNATOR_EMBEDDED_MPV_ALLOW_HOMEBREW=1 nx run electron-backend:build-embedded-mpv", "embedded-mpv:build-runtime": "node tools/embedded-mpv/build-macos-runtime.mjs", - "embedded-mpv:stage-runtime": "node tools/embedded-mpv/stage-macos-runtime.mjs", + "embedded-mpv:stage-runtime": "node tools/embedded-mpv/stage-runtime.mjs", + "embedded-mpv:stage-runtime:macos": "node tools/embedded-mpv/stage-macos-runtime.mjs", "serve:backend:embedded-mpv": "pnpm embedded-mpv:build-native:homebrew && IPTVNATOR_EMBEDDED_MPV_ALLOW_HOMEBREW=1 IPTVNATOR_ENABLE_EMBEDDED_MPV_EXPERIMENT=1 pnpm serve:backend", "package:app": "nx run electron-backend:make --prepackageOnly", "make:app": "nx run electron-backend:make", diff --git a/tools/embedded-mpv/README.md b/tools/embedded-mpv/README.md index 0098db7a1..8ae5e714b 100644 --- a/tools/embedded-mpv/README.md +++ b/tools/embedded-mpv/README.md @@ -1,6 +1,6 @@ -# Embedded MPV macOS Runtime +# Embedded MPV Runtime -This folder contains tooling for preparing the macOS `libmpv` runtime that is bundled with IPTVnator's experimental embedded MPV player. +This folder contains tooling for preparing the `libmpv` runtime that is bundled with IPTVnator's experimental embedded MPV player. ## Runtime Policy @@ -27,20 +27,43 @@ vendor/embedded-mpv/ include/mpv/client.h lib/*.dylib runtime-manifest.json + win32-x64/ + include/mpv/client.h + lib/mpv-2.dll + lib/mpv.lib + runtime-manifest.json + linux-x64/ + include/mpv/client.h + lib/libmpv.so.2 + lib/libmpv.so + runtime-manifest.json ``` The generated `lib/` and `include/` directories are release inputs, not source files. They are ignored by git by default. ## Staging A Built Runtime -After building an LGPL-compatible prefix for one architecture, stage it with: +After building an LGPL-compatible prefix for one platform/architecture, stage it with: ```bash -node tools/embedded-mpv/stage-macos-runtime.mjs arm64 /path/to/lgpl-prefix -node tools/embedded-mpv/stage-macos-runtime.mjs x64 /path/to/lgpl-prefix +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 ``` -The prefix must contain `include/mpv/client.h`, `lib/libmpv.2.dylib` or `lib/libmpv.dylib`, and all non-system dylib dependencies required by `libmpv`. +For compatibility, the legacy macOS-only staging command is still available: + +```bash +pnpm embedded-mpv:stage-runtime:macos -- arm64 /path/to/lgpl-prefix +pnpm embedded-mpv:stage-runtime:macos -- x64 /path/to/lgpl-prefix +``` + +The prefix must contain `include/mpv/client.h` and the platform runtime files: + +- macOS: `lib/libmpv.2.dylib` or `lib/libmpv.dylib` plus all non-system dylib dependencies +- Windows: `lib/mpv.lib` or `lib/mpv-2.lib`, and `bin/mpv-2.dll` or `lib/mpv-2.dll` +- Linux: `lib/libmpv.so.2`, `lib/libmpv.so.1`, or `lib/libmpv.so`; include a `libmpv.so` linker name when building locally If the prefix contains `runtime-manifest.json`, the staging script copies its build metadata into the vendored manifest. At minimum, record: @@ -54,10 +77,10 @@ Tagged macOS release builds build the runtime from pinned source archives before ```bash pnpm embedded-mpv:build-runtime -- arm64 /tmp/embedded-mpv-prefix -pnpm embedded-mpv:stage-runtime -- arm64 /tmp/embedded-mpv-prefix +pnpm embedded-mpv:stage-runtime -- darwin arm64 /tmp/embedded-mpv-prefix ``` -During temporary PR and `master` artifact testing, CI restores an exact-keyed GitHub Actions cache for the staged `vendor/embedded-mpv/darwin-/` runtime before falling back to the source build. The cache key includes the target architecture, macOS deployment target, Xcode version, and hashes of the runtime build/staging scripts. Cache entries are saved only from trusted repository refs and are treated strictly as a speed optimization; tagged release builds continue to rebuild from pinned sources unless a future signed and attested runtime artifact flow is introduced. +During temporary PR and `master` artifact testing, CI restores an exact-keyed GitHub Actions cache for the staged `vendor/embedded-mpv/-/` runtime before falling back to the macOS source build where available. The cache key includes the target platform, architecture, macOS deployment target, Xcode version when available, and hashes of the runtime build/staging scripts. Cache entries are saved only from trusted repository refs and are treated strictly as a speed optimization; tagged macOS release builds continue to rebuild from pinned sources unless a future signed and attested runtime artifact flow is introduced. The builder currently pins: @@ -70,7 +93,7 @@ The build manifest records source URLs, downloaded archive SHA-256 values where ## Build Integration -`apps/electron-backend/build-embedded-mpv.js` links the native addon against the staged runtime, copies dylibs into `apps/electron-backend/native/build/Release/lib/`, rewrites Mach-O paths to `@loader_path`, and writes `embedded-mpv-runtime.json`. +`apps/electron-backend/build-embedded-mpv.js` links the native addon against the staged runtime, copies runtime libraries into `apps/electron-backend/native/build/Release/lib/`, rewrites macOS Mach-O paths to `@loader_path`, and writes `embedded-mpv-runtime.json`. For local macOS development with Homebrew `mpv`, use: @@ -80,8 +103,14 @@ pnpm run serve:backend:embedded-mpv The script rebuilds the native addon with `IPTVNATOR_EMBEDDED_MPV_ALLOW_HOMEBREW=1` before starting Electron with the experimental player enabled. Use this only for local testing; release packaging rejects the resulting `homebrew-dev` runtime manifest. -The macOS `afterPack` hook copies `dist/apps/electron-backend/native/` into `app.asar.unpacked/electron-backend/native/` so the addon, runtime manifest, dylibs, and non-`.dylib` Mach-O runtime files are available as real files. Linux and Windows artifacts do not include that native directory. +The `afterPack` hook copies `dist/apps/electron-backend/native/` into `app.asar.unpacked/electron-backend/native/` so the addon, runtime manifest, and runtime libraries are available as real files on macOS, Windows, and Linux. -During release packaging, `tools/packaging/electron-after-pack.cjs` verifies that the packaged app uses a `vendored-lgpl` runtime and has no `/opt/homebrew` or `/usr/local` dynamic links for embedded MPV. +During release packaging, `tools/packaging/electron-after-pack.cjs` verifies that the packaged app uses a `vendored-lgpl` runtime. macOS artifacts additionally verify that Mach-O dependencies have no `/opt/homebrew` or `/usr/local` dynamic links for embedded MPV. -Set `IPTVNATOR_REQUIRE_EMBEDDED_MPV=1` when packaging a macOS release artifact that must include Embedded MPV. The same variable is temporarily enabled for macOS PR and `master` push artifacts while the bundled runtime is being tested. After manual artifact validation, remove the workflow's PR and `refs/heads/master` conditions so PR and non-tag development builds leave the variable unset or `0` and can package without a staged runtime while Settings keeps Embedded MPV hidden. +Set `IPTVNATOR_REQUIRE_EMBEDDED_MPV=1` when packaging a release artifact that must include Embedded MPV. The same variable is temporarily enabled for macOS PR and `master` push artifacts while the bundled runtime is being tested. Windows and Linux CI packaging requires Embedded MPV when an exact-keyed staged runtime cache is restored; otherwise those jobs build without the native addon and Settings keeps Embedded MPV hidden. + +## Platform Notes + +- macOS keeps the existing libmpv render-context backend because mpv `wid` stays black inside Electron on macOS. +- Windows uses an embedded child `HWND` and passes it to mpv through `wid`. +- Linux uses an X11 child window and passes it to mpv through `wid`. Native Wayland is not supported in v1; run under X11/Xwayland so `DISPLAY` is set. diff --git a/tools/embedded-mpv/stage-runtime.mjs b/tools/embedded-mpv/stage-runtime.mjs new file mode 100644 index 000000000..ab946dbca --- /dev/null +++ b/tools/embedded-mpv/stage-runtime.mjs @@ -0,0 +1,228 @@ +import fs from 'fs'; +import path from 'path'; + +const rawArgs = process.argv.slice(2); +const args = rawArgs[0] === '--' ? rawArgs.slice(1) : rawArgs; +const [platform, arch, sourcePrefix] = args; +const workspaceRoot = process.cwd(); +const validTargets = new Set([ + 'darwin-arm64', + 'darwin-x64', + 'win32-x64', + 'linux-x64', +]); + +if (!validTargets.has(`${platform}-${arch}`) || !sourcePrefix) { + console.error( + [ + 'Usage: node tools/embedded-mpv/stage-runtime.mjs ', + '', + 'Supported targets:', + '- darwin arm64', + '- darwin x64', + '- win32 x64', + '- linux x64', + '', + 'The prefix must contain include/mpv/client.h and dynamic libmpv runtime files.', + ].join('\n') + ); + process.exit(1); +} + +const normalizedPrefix = path.resolve(sourcePrefix); +const destinationRoot = path.join( + workspaceRoot, + 'vendor', + 'embedded-mpv', + `${platform}-${arch}` +); +const destinationIncludeDir = path.join(destinationRoot, 'include'); +const destinationLibDir = path.join(destinationRoot, 'lib'); +const sourceIncludeDir = path.join(normalizedPrefix, 'include'); +const sourceLibDir = path.join(normalizedPrefix, 'lib'); +const sourceBinDir = path.join(normalizedPrefix, 'bin'); + +function assertExists(filePath, message) { + if (!fs.existsSync(filePath)) { + throw new Error(`${message}: ${filePath}`); + } +} + +function isFileLike(sourcePath, entry) { + if (entry.isFile()) { + return true; + } + + if (!entry.isSymbolicLink()) { + return false; + } + + try { + return fs.statSync(sourcePath).isFile(); + } catch { + return false; + } +} + +function copyDirectory(sourceDir, destinationDir, filter) { + if (!fs.existsSync(sourceDir)) { + return; + } + + fs.mkdirSync(destinationDir, { recursive: true }); + + for (const entry of fs.readdirSync(sourceDir, { withFileTypes: true })) { + const sourcePath = path.join(sourceDir, entry.name); + const destinationPath = path.join(destinationDir, entry.name); + + if (filter && !filter(sourcePath, entry)) { + continue; + } + + if (entry.isDirectory()) { + copyDirectory(sourcePath, destinationPath, filter); + continue; + } + + if (isFileLike(sourcePath, entry)) { + fs.copyFileSync(sourcePath, destinationPath); + fs.chmodSync(destinationPath, 0o755); + } + } +} + +function findRuntimeFile(libDir) { + const candidatesByPlatform = { + darwin: ['libmpv.2.dylib', 'libmpv.dylib'], + win32: ['mpv-2.dll', 'mpv.dll'], + linux: ['libmpv.so.2', 'libmpv.so.1', 'libmpv.so'], + }; + const candidates = candidatesByPlatform[platform] ?? []; + + for (const candidate of candidates) { + for (const directory of [libDir, sourceBinDir]) { + const candidatePath = path.join(directory, candidate); + if (fs.existsSync(candidatePath)) { + return candidatePath; + } + } + } + + return null; +} + +function hasWindowsImportLibrary(libDir) { + return ['mpv.lib', 'mpv-2.lib', 'libmpv.dll.a'].some((candidate) => + fs.existsSync(path.join(libDir, candidate)) + ); +} + +function runtimeFileFilter(_sourcePath, entry) { + if (entry.isDirectory()) { + return true; + } + + if (platform === 'darwin') { + return entry.name.endsWith('.dylib'); + } + + if (platform === 'win32') { + return entry.name.endsWith('.dll') || entry.name.endsWith('.lib'); + } + + if (platform === 'linux') { + return /\.so(?:\.\d+)*$/.test(entry.name); + } + + return false; +} + +function listRuntimeFiles(libDir) { + if (!fs.existsSync(libDir)) { + return []; + } + + return fs + .readdirSync(libDir, { withFileTypes: true }) + .filter((entry) => entry.isFile() || entry.isSymbolicLink()) + .map((entry) => entry.name) + .sort(); +} + +function readJsonIfExists(filePath) { + if (!fs.existsSync(filePath)) { + return null; + } + + return JSON.parse(fs.readFileSync(filePath, 'utf8')); +} + +try { + assertExists( + path.join(sourceIncludeDir, 'mpv', 'client.h'), + 'Missing libmpv header' + ); + if (!findRuntimeFile(sourceLibDir)) { + throw new Error(`Missing libmpv runtime for ${platform} in ${sourceLibDir}`); + } + if (platform === 'win32' && !hasWindowsImportLibrary(sourceLibDir)) { + throw new Error(`Missing Windows libmpv import library in ${sourceLibDir}`); + } + + fs.rmSync(destinationIncludeDir, { recursive: true, force: true }); + fs.rmSync(destinationLibDir, { recursive: true, force: true }); + fs.mkdirSync(destinationRoot, { recursive: true }); + + copyDirectory( + path.join(sourceIncludeDir, 'mpv'), + path.join(destinationIncludeDir, 'mpv') + ); + copyDirectory(sourceLibDir, destinationLibDir, runtimeFileFilter); + if (platform === 'win32') { + copyDirectory(sourceBinDir, destinationLibDir, runtimeFileFilter); + } + + const externalManifest = + readJsonIfExists(path.join(normalizedPrefix, 'runtime-manifest.json')) ?? + {}; + const manifest = { + ...externalManifest, + origin: 'vendored-lgpl', + platform, + arch, + stagedAt: new Date().toISOString(), + runtimeFiles: listRuntimeFiles(destinationLibDir), + ffmpeg: { + licensePolicy: 'LGPL, built without --enable-gpl and --enable-nonfree', + ...externalManifest.ffmpeg, + configureFlags: + externalManifest.ffmpeg?.configureFlags ?? + 'Record the exact FFmpeg configure flags used to build this runtime.', + }, + mpv: { + licensePolicy: 'LGPL-compatible libmpv, built with -Dlibmpv=true -Dgpl=false', + ...externalManifest.mpv, + mesonFlags: + externalManifest.mpv?.mesonFlags ?? + 'Record the exact mpv Meson flags used to build this runtime.', + }, + sourceDistribution: + externalManifest.sourceDistribution ?? + `Publish exact source archives and local patches with the ${platform}-${arch} binary release.`, + }; + + fs.writeFileSync( + path.join(destinationRoot, 'runtime-manifest.json'), + `${JSON.stringify(manifest, null, 2)}\n` + ); + + console.log( + `Staged embedded MPV runtime for ${platform}-${arch} at ${path.relative( + workspaceRoot, + destinationRoot + )}` + ); +} catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); +} diff --git a/tools/packaging/electron-after-pack.cjs b/tools/packaging/electron-after-pack.cjs index 496f1291e..24bbcd040 100644 --- a/tools/packaging/electron-after-pack.cjs +++ b/tools/packaging/electron-after-pack.cjs @@ -1,7 +1,7 @@ const linuxAfterPack = require('./linux-after-pack.cjs'); const { validatePackagedEmbeddedMpv, -} = require('./embedded-mpv-macos.cjs'); +} = require('./embedded-mpv-packaging.cjs'); const fs = require('fs'); const path = require('path'); @@ -44,24 +44,50 @@ function copyEmbeddedMpvNativeOutput(resourceDir, projectDir) { async function afterPackHook(params) { await linuxAfterPack(params); - if (params.electronPlatformName !== 'darwin') { - return; - } - const requireEmbeddedMpv = isTruthy( process.env.IPTVNATOR_REQUIRE_EMBEDDED_MPV ); log( requireEmbeddedMpv - ? 'validating required embedded MPV macOS runtime links' - : 'validating optional embedded MPV macOS runtime links' + ? `validating required embedded MPV ${params.electronPlatformName} runtime` + : `validating optional embedded MPV ${params.electronPlatformName} runtime` ); + const resourceDir = getResourceDir(params); + + copyEmbeddedMpvNativeOutput( + resourceDir, + params.packager.projectDir ?? process.cwd() + ); + + const errors = validatePackagedEmbeddedMpv(resourceDir, { + platform: params.electronPlatformName, + required: requireEmbeddedMpv, + }); + + if (errors.length > 0) { + throw new Error( + [ + `Embedded MPV ${params.electronPlatformName} package validation failed.`, + ...errors.map((error) => `- ${error}`), + ].join('\n') + ); + } + + log(`embedded MPV ${params.electronPlatformName} runtime validated`); +} + +function getResourceDir(params) { + if (params.electronPlatformName !== 'darwin') { + return path.join(params.appOutDir, 'resources'); + } + const appPath = params.appOutDir.endsWith('.app') ? params.appOutDir : fs .readdirSync(params.appOutDir) .find((entry) => entry.endsWith('.app')); - const resourceDir = appPath + + return appPath ? path.join( params.appOutDir.endsWith('.app') ? params.appOutDir @@ -70,26 +96,6 @@ async function afterPackHook(params) { 'Resources' ) : params.appOutDir; - - copyEmbeddedMpvNativeOutput( - resourceDir, - params.packager.projectDir ?? process.cwd() - ); - - const errors = validatePackagedEmbeddedMpv(resourceDir, { - required: requireEmbeddedMpv, - }); - - if (errors.length > 0) { - throw new Error( - [ - 'Embedded MPV macOS package validation failed.', - ...errors.map((error) => `- ${error}`), - ].join('\n') - ); - } - - log('embedded MPV macOS runtime links validated'); } module.exports = afterPackHook; diff --git a/tools/packaging/electron-package-identity.test.mjs b/tools/packaging/electron-package-identity.test.mjs index e68d54c1d..0bbf5e6d9 100644 --- a/tools/packaging/electron-package-identity.test.mjs +++ b/tools/packaging/electron-package-identity.test.mjs @@ -1,6 +1,7 @@ import assert from 'node:assert/strict'; import fs from 'node:fs'; import { createRequire } from 'node:module'; +import os from 'node:os'; import { dirname, join } from 'node:path'; import test from 'node:test'; import { fileURLToPath } from 'node:url'; @@ -44,6 +45,13 @@ const packageLayoutVerifier = fs.readFileSync( join(currentDir, 'verify-electron-package-layout.mjs'), 'utf8' ); +const electronAfterPackSource = fs.readFileSync( + join(currentDir, 'electron-after-pack.cjs'), + 'utf8' +); +const { + validatePackagedEmbeddedMpv, +} = require('./embedded-mpv-packaging.cjs'); test('Linux package identity does not expose the internal Electron backend project name', () => { assert.equal(electronBuilderConfig.productName, 'IPTVnator'); @@ -162,3 +170,97 @@ test('nx-electron packaging does not copy duplicate root package metadata', () = /filter:\s*\[['"]index\.js['"],\s*['"]package\.json['"]\]/ ); }); + +test('embedded MPV runtime binaries are unpacked on every supported desktop platform', () => { + const asarUnpack = electronBuilderConfig.asarUnpack ?? []; + + for (const requiredPattern of [ + '**/*.node', + '**/*.dylib', + '**/*.dll', + '**/*.so', + '**/*.so.*', + '**/embedded-mpv-runtime.json', + ]) { + assert.ok( + asarUnpack.includes(requiredPattern), + `electron-builder asarUnpack must include ${requiredPattern}` + ); + } +}); + +test('embedded MPV package validation accepts Windows and Linux runtime files', () => { + const tempDir = fs.mkdtempSync(join(os.tmpdir(), 'iptvnator-mpv-package-')); + + try { + for (const [platform, runtimeFile] of [ + ['windows', 'mpv-2.dll'], + ['windows', join('lib', 'mpv.dll')], + ['linux', join('lib', 'libmpv.so.2')], + ]) { + const resourceDir = join(tempDir, platform); + const nativeDir = join( + resourceDir, + 'app.asar.unpacked', + 'electron-backend', + 'native' + ); + fs.mkdirSync(join(nativeDir, 'lib'), { recursive: true }); + fs.writeFileSync(join(nativeDir, 'embedded_mpv.node'), ''); + fs.writeFileSync( + join(nativeDir, 'embedded-mpv-runtime.json'), + JSON.stringify({ origin: 'vendored-lgpl' }) + ); + fs.writeFileSync(join(nativeDir, runtimeFile), ''); + + assert.deepEqual( + validatePackagedEmbeddedMpv(resourceDir, { + platform, + required: true, + }), + [] + ); + } + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } +}); + +test('embedded MPV packaging helpers use a cross-platform module name', () => { + assert.match( + electronAfterPackSource, + /require\(['"]\.\/embedded-mpv-packaging\.cjs['"]\)/ + ); + assert.match( + packageLayoutVerifier, + /require\(['"]\.\/embedded-mpv-packaging\.cjs['"]\)/ + ); +}); + +test('embedded MPV package validation rejects missing required Windows runtime', () => { + const tempDir = fs.mkdtempSync(join(os.tmpdir(), 'iptvnator-mpv-package-')); + + try { + const nativeDir = join( + tempDir, + 'app.asar.unpacked', + 'electron-backend', + 'native' + ); + fs.mkdirSync(join(nativeDir, 'lib'), { recursive: true }); + fs.writeFileSync(join(nativeDir, 'embedded_mpv.node'), ''); + fs.writeFileSync( + join(nativeDir, 'embedded-mpv-runtime.json'), + JSON.stringify({ origin: 'vendored-lgpl' }) + ); + + const errors = validatePackagedEmbeddedMpv(tempDir, { + platform: 'windows', + required: true, + }); + + assert.match(errors.join('\n'), /Missing bundled embedded MPV runtime/); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } +}); diff --git a/tools/packaging/embedded-mpv-macos.cjs b/tools/packaging/embedded-mpv-packaging.cjs similarity index 85% rename from tools/packaging/embedded-mpv-macos.cjs rename to tools/packaging/embedded-mpv-packaging.cjs index 9dc0a87dd..29a150c87 100644 --- a/tools/packaging/embedded-mpv-macos.cjs +++ b/tools/packaging/embedded-mpv-packaging.cjs @@ -399,15 +399,43 @@ function validateNoForbiddenRuntimeLinks(binaryPaths) { return errors; } +function getPackagedRuntimeCandidates(libDir, platform, nativeDir) { + switch (platform) { + case 'darwin': + return [ + path.join(libDir, 'libmpv.2.dylib'), + path.join(libDir, 'libmpv.dylib'), + ]; + case 'win32': + return [ + nativeDir ? path.join(nativeDir, 'mpv-2.dll') : null, + nativeDir ? path.join(nativeDir, 'mpv.dll') : null, + path.join(libDir, 'mpv-2.dll'), + path.join(libDir, 'mpv.dll'), + ].filter(Boolean); + case 'linux': + return [ + path.join(libDir, 'libmpv.so.2'), + path.join(libDir, 'libmpv.so.1'), + path.join(libDir, 'libmpv.so'), + ]; + default: + return []; + } +} + +function normalizeEmbeddedMpvPlatform(value) { + if (value === 'macos') { + return 'darwin'; + } + if (value === 'windows') { + return 'win32'; + } + return value ?? process.platform; +} + function validatePackagedEmbeddedMpv(resourceDir, options = {}) { - if (process.platform !== 'darwin') { - return []; - } - - if (!commandExists('otool')) { - return ['otool is required to validate embedded MPV macOS packaging.']; - } - + const platform = normalizeEmbeddedMpvPlatform(options.platform); const unpackedNativeDir = path.join( resourceDir, 'app.asar.unpacked', @@ -440,12 +468,38 @@ function validatePackagedEmbeddedMpv(resourceDir, options = {}) { } } - if (!fs.existsSync(path.join(libDir, 'libmpv.2.dylib'))) { - errors.push(`Missing bundled libmpv.2.dylib in ${libDir}`); + const runtimeCandidates = getPackagedRuntimeCandidates( + libDir, + platform, + unpackedNativeDir + ); + if ( + runtimeCandidates.length > 0 && + !runtimeCandidates.some((candidate) => fs.existsSync(candidate)) + ) { + errors.push( + [ + `Missing bundled embedded MPV runtime for ${platform} in ${libDir}.`, + 'Expected one of:', + ...runtimeCandidates.map((candidate) => `- ${candidate}`), + ].join('\n') + ); } - const binaries = [addonPath, ...listRuntimeFiles(libDir)]; - errors.push(...validateNoForbiddenRuntimeLinks(binaries)); + if (platform === 'darwin') { + if (process.platform !== 'darwin') { + errors.push( + 'macOS embedded MPV link validation must run on a macOS host.' + ); + } else if (!commandExists('otool')) { + errors.push( + 'otool is required to validate embedded MPV macOS packaging.' + ); + } else { + const binaries = [addonPath, ...listRuntimeFiles(libDir)]; + errors.push(...validateNoForbiddenRuntimeLinks(binaries)); + } + } return errors; } @@ -461,5 +515,6 @@ module.exports = { parseOtoolRpaths, patchAddonForBundledRuntime, validateNoForbiddenRuntimeLinks, + getPackagedRuntimeCandidates, validatePackagedEmbeddedMpv, }; diff --git a/tools/packaging/verify-electron-package-layout.mjs b/tools/packaging/verify-electron-package-layout.mjs index 070b4d16a..0e7607567 100644 --- a/tools/packaging/verify-electron-package-layout.mjs +++ b/tools/packaging/verify-electron-package-layout.mjs @@ -6,7 +6,7 @@ import { buildElectronBuilderMetadata } from './generate-electron-builder-metada const require = createRequire(import.meta.url); const { extractFile } = require('@electron/asar'); -const { validatePackagedEmbeddedMpv } = require('./embedded-mpv-macos.cjs'); +const { validatePackagedEmbeddedMpv } = require('./embedded-mpv-packaging.cjs'); const args = process.argv.slice(2); const normalizedArgs = args[0] === '--' ? args.slice(1) : args; const [platform, arch = ''] = normalizedArgs; @@ -566,13 +566,12 @@ function verifyResourceDir(resourceDir) { verifyLinuxLauncher(resourceDir, errors); } - if (platform === 'macos') { - errors.push( - ...validatePackagedEmbeddedMpv(resourceDir, { - required: embeddedMpvRequired, - }) - ); - } + errors.push( + ...validatePackagedEmbeddedMpv(resourceDir, { + platform, + required: embeddedMpvRequired, + }) + ); return { resourceDir,