mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 09:01:03 -08:00
feat(player): add embedded-mpv player for macOS as experimental feature
- Introduced tooling for building and staging the macOS `libmpv` runtime for IPTVnator's embedded MPV player. - Added `build-macos-runtime.mjs` for building an LGPL-compatible runtime from source. - Created `stage-macos-runtime.mjs` for staging the built runtime artifacts. - Implemented validation for the packaged embedded MPV runtime in `electron-after-pack.cjs` and `embedded-mpv-macos.cjs`. - Updated packaging scripts to ensure the embedded MPV runtime is correctly integrated and validated during the build process. - Added README files to document the expected layout and usage for the embedded MPV runtime artifacts. Entire-Checkpoint: c6e522b4276c
This commit is contained in:
1 parent
10139955e7
commit
9f873e6bed
53 files changed
+6099
-49
No files matched your search
@@ -15,7 +15,7 @@ jobs:
|
||||
build:
|
||||
name: Build on ${{ matrix.os }} ${{ matrix.arch }}
|
||||
runs-on: ${{ matrix.runner }}
|
||||
timeout-minutes: 60
|
||||
timeout-minutes: 120
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
@@ -88,7 +88,21 @@ jobs:
|
||||
- name: Build frontend
|
||||
run: pnpm nx build web --skip-nx-cache
|
||||
|
||||
- name: Build embedded MPV runtime (macOS release)
|
||||
if: matrix.os == 'macos' && startsWith(github.ref, 'refs/tags/v')
|
||||
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}"
|
||||
|
||||
- name: Build backend
|
||||
env:
|
||||
IPTVNATOR_EMBEDDED_MPV_ARCH: ${{ matrix.arch || '' }}
|
||||
run: pnpm run build:backend
|
||||
|
||||
- name: Validate AppStream metadata
|
||||
@@ -210,6 +224,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_REQUIRE_EMBEDDED_MPV: ${{ startsWith(github.ref, 'refs/tags/v') && '1' || '0' }}
|
||||
run: pnpm run make:app
|
||||
|
||||
- name: Verify signed macOS app
|
||||
@@ -263,6 +279,9 @@ jobs:
|
||||
|
||||
- name: Make Electron app
|
||||
if: matrix.os != 'macos' || github.event_name == 'pull_request'
|
||||
env:
|
||||
IPTVNATOR_EMBEDDED_MPV_ARCH: ${{ matrix.arch || '' }}
|
||||
IPTVNATOR_REQUIRE_EMBEDDED_MPV: '0'
|
||||
run: pnpm run make:app
|
||||
|
||||
- name: Verify packaged worker layout
|
||||
@@ -270,6 +289,7 @@ jobs:
|
||||
env:
|
||||
PACKAGE_OS: ${{ matrix.os }}
|
||||
PACKAGE_ARCH: ${{ matrix.arch || '' }}
|
||||
IPTVNATOR_REQUIRE_EMBEDDED_MPV: ${{ matrix.os == 'macos' && startsWith(github.ref, 'refs/tags/v') && '1' || '0' }}
|
||||
run: pnpm run verify:package-layout -- "$PACKAGE_OS" "$PACKAGE_ARCH"
|
||||
|
||||
- name: Smoke test packaged Flatpak launcher
|
||||
|
||||
+16
@@ -23,6 +23,9 @@ shared/*.js
|
||||
playwright-report
|
||||
.nx
|
||||
server.js
|
||||
.github/skills
|
||||
.github/prompts
|
||||
.agents/skills
|
||||
|
||||
# dependencies
|
||||
node_modules
|
||||
@@ -65,3 +68,16 @@ testem.log
|
||||
Thumbs.db
|
||||
.angular
|
||||
.astro
|
||||
|
||||
.gemini
|
||||
.cursor
|
||||
.agent
|
||||
.claude
|
||||
.codex
|
||||
.plans
|
||||
|
||||
# Native Electron addons
|
||||
apps/electron-backend/native/build/
|
||||
vendor/embedded-mpv/darwin-*/include/
|
||||
vendor/embedded-mpv/darwin-*/lib/
|
||||
vendor/embedded-mpv/darwin-*/runtime-manifest.json
|
||||
@@ -0,0 +1,228 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { spawnSync } = require('child_process');
|
||||
const {
|
||||
copyRuntimeToNativeBuild,
|
||||
findLibMpv,
|
||||
patchAddonForBundledRuntime,
|
||||
validateNoForbiddenRuntimeLinks,
|
||||
} = require('../../tools/packaging/embedded-mpv-macos.cjs');
|
||||
|
||||
const workspaceRoot = process.cwd();
|
||||
const addonRoot = path.join(workspaceRoot, 'apps', 'electron-backend', 'native');
|
||||
const outputDir = path.join(addonRoot, 'build', 'Release');
|
||||
const outputFile = path.join(outputDir, 'embedded_mpv.node');
|
||||
const outputLibDir = path.join(outputDir, 'lib');
|
||||
const unavailableMarkerFile = path.join(outputDir, 'embedded-mpv-unavailable.txt');
|
||||
const homebrewIncludeDir = '/opt/homebrew/include';
|
||||
const homebrewLibDir = '/opt/homebrew/lib';
|
||||
const targetArch =
|
||||
process.env.IPTVNATOR_EMBEDDED_MPV_ARCH ||
|
||||
process.env.npm_config_arch ||
|
||||
process.arch;
|
||||
const vendoredRuntimeRoot = path.join(
|
||||
workspaceRoot,
|
||||
'vendor',
|
||||
'embedded-mpv',
|
||||
`darwin-${targetArch}`
|
||||
);
|
||||
const vendoredIncludeDir = path.join(vendoredRuntimeRoot, 'include');
|
||||
const vendoredLibDir = path.join(vendoredRuntimeRoot, 'lib');
|
||||
const homebrewFallbackEnabled =
|
||||
process.env.IPTVNATOR_EMBEDDED_MPV_ALLOW_HOMEBREW === '1';
|
||||
|
||||
function log(message) {
|
||||
process.stdout.write(`[embedded-mpv] ${message}\n`);
|
||||
}
|
||||
|
||||
function cleanOutput() {
|
||||
fs.rmSync(outputFile, { force: true });
|
||||
fs.rmSync(outputLibDir, { recursive: true, force: true });
|
||||
fs.rmSync(path.join(outputDir, 'embedded-mpv-runtime.json'), {
|
||||
force: true,
|
||||
});
|
||||
fs.writeFileSync(
|
||||
unavailableMarkerFile,
|
||||
'Embedded MPV native runtime is not available for this build.\n'
|
||||
);
|
||||
}
|
||||
|
||||
function readRuntimeManifest(runtimeRoot) {
|
||||
const manifestPath = path.join(runtimeRoot, 'runtime-manifest.json');
|
||||
if (!fs.existsSync(manifestPath)) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
|
||||
}
|
||||
|
||||
function resolveRuntime() {
|
||||
const vendoredLibMpv = findLibMpv(vendoredLibDir);
|
||||
const vendoredHeader = path.join(vendoredIncludeDir, 'mpv', 'client.h');
|
||||
|
||||
if (vendoredLibMpv && fs.existsSync(vendoredHeader)) {
|
||||
return {
|
||||
origin: 'vendored-lgpl',
|
||||
includeDir: vendoredIncludeDir,
|
||||
libDir: vendoredLibDir,
|
||||
manifest: readRuntimeManifest(vendoredRuntimeRoot),
|
||||
};
|
||||
}
|
||||
|
||||
if (homebrewFallbackEnabled) {
|
||||
const homebrewLibMpv = findLibMpv(homebrewLibDir);
|
||||
const homebrewHeader = path.join(homebrewIncludeDir, 'mpv', 'client.h');
|
||||
if (homebrewLibMpv && fs.existsSync(homebrewHeader)) {
|
||||
log(
|
||||
'Using Homebrew libmpv as a development-only fallback. Release packaging will reject this runtime.'
|
||||
);
|
||||
return {
|
||||
origin: 'homebrew-dev',
|
||||
includeDir: homebrewIncludeDir,
|
||||
libDir: homebrewLibDir,
|
||||
manifest: {
|
||||
warning:
|
||||
'Development-only runtime. Do not ship this in release artifacts.',
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolveElectronNodeGypBin() {
|
||||
const pnpmRoot = path.join(workspaceRoot, 'node_modules', '.pnpm');
|
||||
|
||||
if (!fs.existsSync(pnpmRoot)) {
|
||||
throw new Error('Unable to find node_modules/.pnpm.');
|
||||
}
|
||||
|
||||
const packageDirs = fs
|
||||
.readdirSync(pnpmRoot, { withFileTypes: true })
|
||||
.filter(
|
||||
(entry) =>
|
||||
entry.isDirectory() &&
|
||||
entry.name.startsWith('@electron+node-gyp@')
|
||||
)
|
||||
.map((entry) => entry.name)
|
||||
.sort();
|
||||
|
||||
for (const packageDir of packageDirs) {
|
||||
const candidate = path.join(
|
||||
pnpmRoot,
|
||||
packageDir,
|
||||
'node_modules',
|
||||
'@electron',
|
||||
'node-gyp',
|
||||
'bin',
|
||||
'node-gyp.js'
|
||||
);
|
||||
|
||||
if (fs.existsSync(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error('Unable to resolve @electron/node-gyp.');
|
||||
}
|
||||
|
||||
function runNodeGyp(command, env) {
|
||||
const nodeGypBin = resolveElectronNodeGypBin();
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
[nodeGypBin, command, '--directory', addonRoot],
|
||||
{
|
||||
cwd: workspaceRoot,
|
||||
env,
|
||||
stdio: 'inherit',
|
||||
}
|
||||
);
|
||||
|
||||
if (result.status !== 0) {
|
||||
throw new Error(`node-gyp ${command} failed with status ${result.status ?? 1}.`);
|
||||
}
|
||||
}
|
||||
|
||||
function main() {
|
||||
fs.mkdirSync(outputDir, { recursive: true });
|
||||
|
||||
if (process.platform !== 'darwin') {
|
||||
cleanOutput();
|
||||
log('Skipping build on non-macOS host.');
|
||||
return;
|
||||
}
|
||||
|
||||
const runtime = resolveRuntime();
|
||||
if (!runtime) {
|
||||
cleanOutput();
|
||||
log(
|
||||
[
|
||||
`Skipping build because no embedded MPV runtime was found for darwin-${targetArch}.`,
|
||||
`Expected vendored runtime at ${vendoredRuntimeRoot}.`,
|
||||
'For local development only, set IPTVNATOR_EMBEDDED_MPV_ALLOW_HOMEBREW=1 to use Homebrew libmpv.',
|
||||
].join('\n')
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const runtimeManifest = copyRuntimeToNativeBuild({
|
||||
runtimeLibDir: runtime.libDir,
|
||||
outputLibDir,
|
||||
runtimeOrigin: runtime.origin,
|
||||
runtimeManifest: {
|
||||
targetArch,
|
||||
...runtime.manifest,
|
||||
},
|
||||
});
|
||||
fs.rmSync(unavailableMarkerFile, { force: true });
|
||||
|
||||
const electronPackageJson = require(path.join(
|
||||
workspaceRoot,
|
||||
'node_modules',
|
||||
'electron',
|
||||
'package.json'
|
||||
));
|
||||
const electronVersion = electronPackageJson.version;
|
||||
const env = {
|
||||
...process.env,
|
||||
npm_config_runtime: 'electron',
|
||||
npm_config_target: electronVersion,
|
||||
npm_config_arch: targetArch,
|
||||
npm_config_disturl: 'https://electronjs.org/headers',
|
||||
npm_config_build_from_source: 'true',
|
||||
npm_config_update_binary: 'false',
|
||||
LIBMPV_INCLUDE_DIR: runtime.includeDir,
|
||||
LIBMPV_LIBRARY_DIR: outputLibDir,
|
||||
};
|
||||
|
||||
log(
|
||||
`Building native addon against Electron ${electronVersion} using ${runtime.origin} runtime for darwin-${targetArch}...`
|
||||
);
|
||||
runNodeGyp('configure', env);
|
||||
runNodeGyp('build', env);
|
||||
|
||||
if (!fs.existsSync(outputFile)) {
|
||||
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'));
|
||||
}
|
||||
|
||||
log(`Built ${path.relative(workspaceRoot, outputFile)}.`);
|
||||
}
|
||||
|
||||
try {
|
||||
main();
|
||||
} catch (error) {
|
||||
process.stderr.write(
|
||||
`[embedded-mpv] ${error instanceof Error ? error.message : String(error)}\n`
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"targets": [
|
||||
{
|
||||
"target_name": "embedded_mpv",
|
||||
"sources": [
|
||||
"src/embedded_mpv.mm"
|
||||
],
|
||||
"include_dirs": [
|
||||
"<(module_root_dir)/../../../node_modules/.pnpm/node_modules/node-addon-api",
|
||||
"<!(node -p \"process.env.LIBMPV_INCLUDE_DIR || '/opt/homebrew/include'\")"
|
||||
],
|
||||
"defines": [
|
||||
"NAPI_CPP_EXCEPTIONS"
|
||||
],
|
||||
"cflags_cc": [
|
||||
"-fexceptions",
|
||||
"-std=c++20"
|
||||
],
|
||||
"xcode_settings": {
|
||||
"CLANG_CXX_LANGUAGE_STANDARD": "c++20",
|
||||
"CLANG_ENABLE_OBJC_ARC": "YES",
|
||||
"GCC_ENABLE_CPP_EXCEPTIONS": "YES",
|
||||
"MACOSX_DEPLOYMENT_TARGET": "10.15",
|
||||
"OTHER_LDFLAGS": [
|
||||
"-Wl,-rpath,@loader_path/lib"
|
||||
]
|
||||
},
|
||||
"libraries": [
|
||||
"-framework AppKit",
|
||||
"-framework CoreVideo",
|
||||
"-framework Foundation",
|
||||
"-framework OpenGL",
|
||||
"<!(node -e \"const path = require('path'); const dir = process.env.LIBMPV_LIBRARY_DIR || '/opt/homebrew/lib'; process.stdout.write(path.join(dir, 'libmpv.2.dylib'))\")"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -11,9 +11,20 @@
|
||||
"command": "node apps/electron-backend/build-worker.js"
|
||||
}
|
||||
},
|
||||
"build-embedded-mpv": {
|
||||
"executor": "nx:run-commands",
|
||||
"cache": false,
|
||||
"outputs": [
|
||||
"{workspaceRoot}/apps/electron-backend/native/build/Release"
|
||||
],
|
||||
"options": {
|
||||
"command": "node apps/electron-backend/build-embedded-mpv.js"
|
||||
}
|
||||
},
|
||||
"build": {
|
||||
"dependsOn": [
|
||||
"electron-backend:build-worker",
|
||||
"electron-backend:build-embedded-mpv",
|
||||
{
|
||||
"projects": ["web", "remote-control-web"],
|
||||
"target": "build"
|
||||
@@ -21,11 +32,23 @@
|
||||
],
|
||||
"executor": "nx-electron:build",
|
||||
"outputs": ["{options.outputPath}"],
|
||||
"inputs": [
|
||||
"production",
|
||||
"^production",
|
||||
"{workspaceRoot}/apps/electron-backend/native/build/Release/**"
|
||||
],
|
||||
"options": {
|
||||
"outputPath": "dist/apps/electron-backend",
|
||||
"main": "apps/electron-backend/src/main.ts",
|
||||
"tsConfig": "apps/electron-backend/tsconfig.app.json",
|
||||
"assets": ["apps/electron-backend/src/assets"]
|
||||
"assets": [
|
||||
"apps/electron-backend/src/assets",
|
||||
{
|
||||
"glob": "{embedded_mpv.node,embedded-mpv-runtime.json,embedded-mpv-unavailable.txt,lib/*}",
|
||||
"input": "apps/electron-backend/native/build/Release",
|
||||
"output": "native"
|
||||
}
|
||||
]
|
||||
},
|
||||
"configurations": {
|
||||
"production": {
|
||||
@@ -44,6 +67,7 @@
|
||||
"build-e2e": {
|
||||
"dependsOn": [
|
||||
"electron-backend:build-worker",
|
||||
"electron-backend:build-embedded-mpv",
|
||||
{
|
||||
"projects": ["web"],
|
||||
"target": "build-e2e"
|
||||
@@ -55,11 +79,23 @@
|
||||
],
|
||||
"executor": "nx-electron:build",
|
||||
"outputs": ["{options.outputPath}"],
|
||||
"inputs": [
|
||||
"production",
|
||||
"^production",
|
||||
"{workspaceRoot}/apps/electron-backend/native/build/Release/**"
|
||||
],
|
||||
"options": {
|
||||
"outputPath": "dist/apps/electron-backend",
|
||||
"main": "apps/electron-backend/src/main.ts",
|
||||
"tsConfig": "apps/electron-backend/tsconfig.app.json",
|
||||
"assets": ["apps/electron-backend/src/assets"]
|
||||
"assets": [
|
||||
"apps/electron-backend/src/assets",
|
||||
{
|
||||
"glob": "{embedded_mpv.node,embedded-mpv-runtime.json,embedded-mpv-unavailable.txt,lib/*}",
|
||||
"input": "apps/electron-backend/native/build/Release",
|
||||
"output": "native"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"serve": {
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import { contextBridge, ipcRenderer } from 'electron';
|
||||
import type {
|
||||
EmbeddedMpvBounds,
|
||||
EmbeddedMpvSession,
|
||||
EmbeddedMpvSupport,
|
||||
ExternalPlayerSession,
|
||||
PlaylistRefreshEvent,
|
||||
PlaylistRefreshPayload,
|
||||
ResolvedPortalPlayback,
|
||||
XtreamCategory,
|
||||
} from 'shared-interfaces';
|
||||
import {
|
||||
@@ -14,6 +18,7 @@ import {
|
||||
|
||||
const PORTAL_DEBUG_EVENT = 'PORTAL_DEBUG_EVENT';
|
||||
const EXTERNAL_PLAYER_SESSION_UPDATE = 'EXTERNAL_PLAYER_SESSION_UPDATE';
|
||||
const EMBEDDED_MPV_SESSION_UPDATE = 'EMBEDDED_MPV_SESSION_UPDATE';
|
||||
const DB_OPERATION_EVENT = 'DB_OPERATION_EVENT';
|
||||
const PLAYLIST_REFRESH_EVENT = 'PLAYLIST:REFRESH_EVENT';
|
||||
|
||||
@@ -229,6 +234,16 @@ const electronApi = {
|
||||
ipcRenderer.on(EXTERNAL_PLAYER_SESSION_UPDATE, handler);
|
||||
return () => ipcRenderer.off(EXTERNAL_PLAYER_SESSION_UPDATE, handler);
|
||||
},
|
||||
onEmbeddedMpvSessionUpdate: (
|
||||
callback: (data: EmbeddedMpvSession) => void
|
||||
) => {
|
||||
const handler = (
|
||||
_event: Electron.IpcRendererEvent,
|
||||
data: EmbeddedMpvSession
|
||||
) => callback(data);
|
||||
ipcRenderer.on(EMBEDDED_MPV_SESSION_UPDATE, handler);
|
||||
return () => ipcRenderer.off(EMBEDDED_MPV_SESSION_UPDATE, handler);
|
||||
},
|
||||
onDbOperationEvent: (callback: (data: DbOperationEvent) => void) => {
|
||||
const handler = (_event: Electron.IpcRendererEvent, data: any) =>
|
||||
callback(data as DbOperationEvent);
|
||||
@@ -332,6 +347,55 @@ const electronApi = {
|
||||
),
|
||||
closeExternalPlayerSession: (sessionId: string) =>
|
||||
ipcRenderer.invoke('CLOSE_EXTERNAL_PLAYER_SESSION', sessionId),
|
||||
getEmbeddedMpvSupport: (): Promise<EmbeddedMpvSupport> =>
|
||||
ipcRenderer.invoke('EMBEDDED_MPV_SUPPORT'),
|
||||
prepareEmbeddedMpv: (): Promise<EmbeddedMpvSupport> =>
|
||||
ipcRenderer.invoke('EMBEDDED_MPV_PREPARE'),
|
||||
createEmbeddedMpvSession: (
|
||||
bounds: EmbeddedMpvBounds,
|
||||
title?: string,
|
||||
initialVolume?: number
|
||||
): Promise<EmbeddedMpvSession> =>
|
||||
ipcRenderer.invoke(
|
||||
'EMBEDDED_MPV_CREATE_SESSION',
|
||||
bounds,
|
||||
title,
|
||||
initialVolume
|
||||
),
|
||||
loadEmbeddedMpvPlayback: (
|
||||
sessionId: string,
|
||||
playback: ResolvedPortalPlayback
|
||||
): Promise<void> =>
|
||||
ipcRenderer.invoke('EMBEDDED_MPV_LOAD_PLAYBACK', sessionId, playback),
|
||||
setEmbeddedMpvBounds: (
|
||||
sessionId: string,
|
||||
bounds: EmbeddedMpvBounds
|
||||
): Promise<void> =>
|
||||
ipcRenderer.invoke('EMBEDDED_MPV_SET_BOUNDS', sessionId, bounds),
|
||||
setEmbeddedMpvPaused: (
|
||||
sessionId: string,
|
||||
paused: boolean
|
||||
): Promise<EmbeddedMpvSession | null> =>
|
||||
ipcRenderer.invoke('EMBEDDED_MPV_SET_PAUSED', sessionId, paused),
|
||||
seekEmbeddedMpv: (
|
||||
sessionId: string,
|
||||
seconds: number
|
||||
): Promise<EmbeddedMpvSession | null> =>
|
||||
ipcRenderer.invoke('EMBEDDED_MPV_SEEK', sessionId, seconds),
|
||||
setEmbeddedMpvVolume: (
|
||||
sessionId: string,
|
||||
volume: number
|
||||
): Promise<EmbeddedMpvSession | null> =>
|
||||
ipcRenderer.invoke('EMBEDDED_MPV_SET_VOLUME', sessionId, volume),
|
||||
setEmbeddedMpvAudioTrack: (
|
||||
sessionId: string,
|
||||
trackId: number
|
||||
): Promise<EmbeddedMpvSession | null> =>
|
||||
ipcRenderer.invoke('EMBEDDED_MPV_SET_AUDIO_TRACK', sessionId, trackId),
|
||||
disposeEmbeddedMpvSession: (
|
||||
sessionId: string
|
||||
): Promise<EmbeddedMpvSession | null> =>
|
||||
ipcRenderer.invoke('EMBEDDED_MPV_DISPOSE_SESSION', sessionId),
|
||||
autoUpdatePlaylists: (playlists) =>
|
||||
ipcRenderer.invoke('AUTO_UPDATE', playlists),
|
||||
fetchEpg: (urls: string[]) =>
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import { ipcMain } from 'electron';
|
||||
import {
|
||||
EMBEDDED_MPV_CREATE_SESSION,
|
||||
EMBEDDED_MPV_DISPOSE_SESSION,
|
||||
EMBEDDED_MPV_LOAD_PLAYBACK,
|
||||
EMBEDDED_MPV_PREPARE,
|
||||
EMBEDDED_MPV_SEEK,
|
||||
EMBEDDED_MPV_SET_AUDIO_TRACK,
|
||||
EMBEDDED_MPV_SET_BOUNDS,
|
||||
EMBEDDED_MPV_SET_PAUSED,
|
||||
EMBEDDED_MPV_SET_VOLUME,
|
||||
EMBEDDED_MPV_SUPPORT,
|
||||
EmbeddedMpvBounds,
|
||||
ResolvedPortalPlayback,
|
||||
} from 'shared-interfaces';
|
||||
import {
|
||||
EmbeddedMpvNativeService,
|
||||
embeddedMpvNativeService,
|
||||
} from '../services/embedded-mpv-native.service';
|
||||
|
||||
export default class EmbeddedMpvEvents {
|
||||
static bootstrapEmbeddedMpvEvents(): Electron.IpcMain {
|
||||
return ipcMain;
|
||||
}
|
||||
}
|
||||
|
||||
function getService(): EmbeddedMpvNativeService {
|
||||
return embeddedMpvNativeService;
|
||||
}
|
||||
|
||||
ipcMain.handle(EMBEDDED_MPV_SUPPORT, () => getService().getSupport());
|
||||
|
||||
ipcMain.handle(EMBEDDED_MPV_PREPARE, () => getService().prepareAddon());
|
||||
|
||||
ipcMain.handle(
|
||||
EMBEDDED_MPV_CREATE_SESSION,
|
||||
(
|
||||
_event,
|
||||
bounds: EmbeddedMpvBounds,
|
||||
title?: string,
|
||||
initialVolume?: number
|
||||
) => getService().createSession(bounds, title, initialVolume)
|
||||
);
|
||||
|
||||
ipcMain.handle(
|
||||
EMBEDDED_MPV_LOAD_PLAYBACK,
|
||||
(_event, sessionId: string, playback: ResolvedPortalPlayback) =>
|
||||
getService().loadPlayback(sessionId, playback)
|
||||
);
|
||||
|
||||
ipcMain.handle(
|
||||
EMBEDDED_MPV_SET_BOUNDS,
|
||||
(_event, sessionId: string, bounds: EmbeddedMpvBounds) =>
|
||||
getService().setBounds(sessionId, bounds)
|
||||
);
|
||||
|
||||
ipcMain.handle(
|
||||
EMBEDDED_MPV_SET_PAUSED,
|
||||
(_event, sessionId: string, paused: boolean) =>
|
||||
getService().setPaused(sessionId, paused)
|
||||
);
|
||||
|
||||
ipcMain.handle(EMBEDDED_MPV_SEEK, (_event, sessionId: string, seconds: number) =>
|
||||
getService().seek(sessionId, seconds)
|
||||
);
|
||||
|
||||
ipcMain.handle(
|
||||
EMBEDDED_MPV_SET_VOLUME,
|
||||
(_event, sessionId: string, volume: number) =>
|
||||
getService().setVolume(sessionId, volume)
|
||||
);
|
||||
|
||||
ipcMain.handle(
|
||||
EMBEDDED_MPV_SET_AUDIO_TRACK,
|
||||
(_event, sessionId: string, trackId: number) =>
|
||||
getService().setAudioTrack(sessionId, trackId)
|
||||
);
|
||||
|
||||
ipcMain.handle(EMBEDDED_MPV_DISPOSE_SESSION, (_event, sessionId: string) =>
|
||||
getService().disposeSession(sessionId)
|
||||
);
|
||||
|
||||
export function shutdownEmbeddedMpv(): void {
|
||||
getService().shutdown();
|
||||
}
|
||||
@@ -0,0 +1,533 @@
|
||||
import { app } from 'electron';
|
||||
import { existsSync, readFileSync } from 'fs';
|
||||
import { createRequire } from 'module';
|
||||
import path from 'path';
|
||||
import App from '../app';
|
||||
import {
|
||||
EmbeddedMpvAudioTrack,
|
||||
EmbeddedMpvBounds,
|
||||
EmbeddedMpvSession,
|
||||
EmbeddedMpvSessionStatus,
|
||||
EmbeddedMpvSupport,
|
||||
EMBEDDED_MPV_SESSION_UPDATE,
|
||||
ResolvedPortalPlayback,
|
||||
} from 'shared-interfaces';
|
||||
|
||||
interface NativeEmbeddedMpvSessionSnapshot {
|
||||
status: EmbeddedMpvSessionStatus;
|
||||
positionSeconds: number;
|
||||
durationSeconds: number | null;
|
||||
volume: number;
|
||||
streamUrl: string;
|
||||
audioTracks?: EmbeddedMpvAudioTrack[];
|
||||
selectedAudioTrackId?: number | null;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface NativeEmbeddedMpvAddon {
|
||||
isSupported(): boolean;
|
||||
createSession(
|
||||
windowHandle: Buffer,
|
||||
bounds: EmbeddedMpvBounds,
|
||||
title?: string,
|
||||
initialVolume?: number
|
||||
): string;
|
||||
loadPlayback(sessionId: string, playback: ResolvedPortalPlayback): void;
|
||||
setBounds(sessionId: string, bounds: EmbeddedMpvBounds): void;
|
||||
setPaused(sessionId: string, paused: boolean): void;
|
||||
seek(sessionId: string, seconds: number): void;
|
||||
setVolume(sessionId: string, volume: number): void;
|
||||
setAudioTrack(sessionId: string, trackId: number): void;
|
||||
getSessionSnapshot(
|
||||
sessionId: string
|
||||
): NativeEmbeddedMpvSessionSnapshot | null;
|
||||
disposeSession(sessionId: string): void;
|
||||
}
|
||||
|
||||
interface EmbeddedMpvRuntimeSession {
|
||||
id: string;
|
||||
title: string;
|
||||
streamUrl: string;
|
||||
startedAt: string;
|
||||
updatedAt: string;
|
||||
lastPayloadKey: string;
|
||||
}
|
||||
|
||||
const EMBEDDED_MPV_EXPERIMENT_ENV =
|
||||
'IPTVNATOR_ENABLE_EMBEDDED_MPV_EXPERIMENT';
|
||||
|
||||
function dedupePaths(paths: Array<string | undefined>): string[] {
|
||||
return [...new Set(paths.filter((value): value is string => Boolean(value)))];
|
||||
}
|
||||
|
||||
export class EmbeddedMpvNativeService {
|
||||
private addon: NativeEmbeddedMpvAddon | null = null;
|
||||
private addonLoadError: Error | null = null;
|
||||
private readonly sessions = new Map<string, EmbeddedMpvRuntimeSession>();
|
||||
private pollingTimer: NodeJS.Timeout | null = null;
|
||||
private readonly loadAddonModule = createRequire(__filename);
|
||||
|
||||
getSupport(): EmbeddedMpvSupport {
|
||||
if (process.platform !== 'darwin') {
|
||||
return {
|
||||
supported: false,
|
||||
platform: process.platform,
|
||||
reason: 'Embedded MPV is currently available on macOS only.',
|
||||
};
|
||||
}
|
||||
|
||||
if (!this.isEmbeddedMpvEnabled()) {
|
||||
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.`,
|
||||
};
|
||||
}
|
||||
|
||||
if (this.addon) {
|
||||
try {
|
||||
if (!this.addon.isSupported()) {
|
||||
return {
|
||||
supported: false,
|
||||
platform: process.platform,
|
||||
reason: 'The native embedded MPV addon reported itself as unsupported.',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
supported: true,
|
||||
platform: process.platform,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
supported: false,
|
||||
platform: process.platform,
|
||||
reason:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: String(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (this.addonLoadError) {
|
||||
return {
|
||||
supported: false,
|
||||
platform: process.platform,
|
||||
reason: this.addonLoadError.message,
|
||||
};
|
||||
}
|
||||
|
||||
const candidatePaths = this.getAddonCandidatePaths();
|
||||
const existingCandidatePath = candidatePaths.find((candidatePath) =>
|
||||
existsSync(candidatePath)
|
||||
);
|
||||
|
||||
if (!existingCandidatePath) {
|
||||
return {
|
||||
supported: false,
|
||||
platform: process.platform,
|
||||
reason:
|
||||
this.readUnavailableReason(candidatePaths) ??
|
||||
[
|
||||
'Unable to locate the embedded MPV native addon.',
|
||||
...candidatePaths.map(
|
||||
(candidatePath) => `- ${candidatePath}`
|
||||
),
|
||||
].join('\n'),
|
||||
};
|
||||
}
|
||||
|
||||
const missingRuntimeReason =
|
||||
this.getMissingRuntimeReason(existingCandidatePath);
|
||||
if (missingRuntimeReason) {
|
||||
return {
|
||||
supported: false,
|
||||
platform: process.platform,
|
||||
reason: missingRuntimeReason,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
supported: true,
|
||||
platform: process.platform,
|
||||
};
|
||||
}
|
||||
|
||||
prepareAddon(): EmbeddedMpvSupport {
|
||||
const support = this.getSupport();
|
||||
if (!support.supported) {
|
||||
return support;
|
||||
}
|
||||
|
||||
try {
|
||||
const addon = this.getAddon();
|
||||
if (!addon.isSupported()) {
|
||||
return {
|
||||
supported: false,
|
||||
platform: process.platform,
|
||||
reason: 'The native embedded MPV addon reported itself as unsupported.',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
supported: true,
|
||||
platform: process.platform,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
supported: false,
|
||||
platform: process.platform,
|
||||
reason:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: String(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
createSession(
|
||||
bounds: EmbeddedMpvBounds,
|
||||
title = '',
|
||||
initialVolume = 1
|
||||
): EmbeddedMpvSession {
|
||||
this.assertEmbeddedMpvEnabled();
|
||||
const addon = this.getAddon();
|
||||
const windowHandle = this.getMainWindowHandle();
|
||||
const startedAt = new Date().toISOString();
|
||||
const sessionId = addon.createSession(
|
||||
windowHandle,
|
||||
bounds,
|
||||
title,
|
||||
initialVolume
|
||||
);
|
||||
|
||||
this.sessions.set(sessionId, {
|
||||
id: sessionId,
|
||||
title,
|
||||
streamUrl: '',
|
||||
startedAt,
|
||||
updatedAt: startedAt,
|
||||
lastPayloadKey: '',
|
||||
});
|
||||
|
||||
this.ensurePolling();
|
||||
return this.refreshSession(sessionId) ?? {
|
||||
id: sessionId,
|
||||
title,
|
||||
streamUrl: '',
|
||||
status: 'idle',
|
||||
positionSeconds: 0,
|
||||
durationSeconds: null,
|
||||
volume: 1,
|
||||
audioTracks: [],
|
||||
selectedAudioTrackId: null,
|
||||
startedAt,
|
||||
updatedAt: startedAt,
|
||||
};
|
||||
}
|
||||
|
||||
loadPlayback(sessionId: string, playback: ResolvedPortalPlayback): void {
|
||||
this.assertEmbeddedMpvEnabled();
|
||||
const addon = this.getAddon();
|
||||
const session = this.getRuntimeSession(sessionId);
|
||||
session.title = playback.title ?? session.title;
|
||||
session.streamUrl = playback.streamUrl ?? session.streamUrl;
|
||||
session.updatedAt = new Date().toISOString();
|
||||
addon.loadPlayback(sessionId, playback);
|
||||
this.refreshSession(sessionId);
|
||||
}
|
||||
|
||||
setBounds(sessionId: string, bounds: EmbeddedMpvBounds): void {
|
||||
this.assertEmbeddedMpvEnabled();
|
||||
this.getAddon().setBounds(sessionId, bounds);
|
||||
}
|
||||
|
||||
setPaused(sessionId: string, paused: boolean): EmbeddedMpvSession | null {
|
||||
this.assertEmbeddedMpvEnabled();
|
||||
this.getAddon().setPaused(sessionId, paused);
|
||||
return this.refreshSession(sessionId);
|
||||
}
|
||||
|
||||
seek(sessionId: string, seconds: number): EmbeddedMpvSession | null {
|
||||
this.assertEmbeddedMpvEnabled();
|
||||
this.getAddon().seek(sessionId, seconds);
|
||||
return this.refreshSession(sessionId);
|
||||
}
|
||||
|
||||
setVolume(sessionId: string, volume: number): EmbeddedMpvSession | null {
|
||||
this.assertEmbeddedMpvEnabled();
|
||||
this.getAddon().setVolume(sessionId, volume);
|
||||
return this.refreshSession(sessionId);
|
||||
}
|
||||
|
||||
setAudioTrack(sessionId: string, trackId: number): EmbeddedMpvSession | null {
|
||||
this.assertEmbeddedMpvEnabled();
|
||||
this.getAddon().setAudioTrack(sessionId, trackId);
|
||||
return this.refreshSession(sessionId);
|
||||
}
|
||||
|
||||
disposeSession(sessionId: string): EmbeddedMpvSession | null {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
this.getAddon().disposeSession(sessionId);
|
||||
} finally {
|
||||
this.sessions.delete(sessionId);
|
||||
const payload: EmbeddedMpvSession = {
|
||||
id: session.id,
|
||||
title: session.title,
|
||||
streamUrl: session.streamUrl,
|
||||
status: 'closed',
|
||||
positionSeconds: 0,
|
||||
durationSeconds: null,
|
||||
volume: 1,
|
||||
audioTracks: [],
|
||||
selectedAudioTrackId: null,
|
||||
startedAt: session.startedAt,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
this.sendSessionUpdate(payload);
|
||||
this.stopPollingIfIdle();
|
||||
return payload;
|
||||
}
|
||||
}
|
||||
|
||||
shutdown(): void {
|
||||
const sessionIds = [...this.sessions.keys()];
|
||||
sessionIds.forEach((sessionId) => this.disposeSession(sessionId));
|
||||
if (this.pollingTimer) {
|
||||
clearInterval(this.pollingTimer);
|
||||
this.pollingTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
private ensurePolling(): void {
|
||||
if (this.pollingTimer) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.pollingTimer = setInterval(() => {
|
||||
[...this.sessions.keys()].forEach((sessionId) => {
|
||||
this.refreshSession(sessionId);
|
||||
});
|
||||
}, 500);
|
||||
}
|
||||
|
||||
private stopPollingIfIdle(): void {
|
||||
if (this.sessions.size > 0 || !this.pollingTimer) {
|
||||
return;
|
||||
}
|
||||
|
||||
clearInterval(this.pollingTimer);
|
||||
this.pollingTimer = null;
|
||||
}
|
||||
|
||||
private refreshSession(sessionId: string): EmbeddedMpvSession | null {
|
||||
const addon = this.getAddon();
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const snapshot = addon.getSessionSnapshot(sessionId);
|
||||
if (!snapshot) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const payload: EmbeddedMpvSession = {
|
||||
id: session.id,
|
||||
title: session.title,
|
||||
streamUrl: snapshot.streamUrl || session.streamUrl,
|
||||
status: snapshot.status,
|
||||
positionSeconds: Math.max(0, Math.floor(snapshot.positionSeconds)),
|
||||
durationSeconds:
|
||||
typeof snapshot.durationSeconds === 'number'
|
||||
? Math.max(0, Math.floor(snapshot.durationSeconds))
|
||||
: null,
|
||||
volume: typeof snapshot.volume === 'number' ? snapshot.volume : 1,
|
||||
audioTracks: Array.isArray(snapshot.audioTracks)
|
||||
? snapshot.audioTracks
|
||||
: [],
|
||||
selectedAudioTrackId:
|
||||
typeof snapshot.selectedAudioTrackId === 'number'
|
||||
? snapshot.selectedAudioTrackId
|
||||
: null,
|
||||
startedAt: session.startedAt,
|
||||
updatedAt: new Date().toISOString(),
|
||||
...(snapshot.error ? { error: snapshot.error } : {}),
|
||||
};
|
||||
|
||||
session.streamUrl = payload.streamUrl;
|
||||
session.updatedAt = payload.updatedAt;
|
||||
const nextPayloadKey = JSON.stringify(payload);
|
||||
if (session.lastPayloadKey !== nextPayloadKey) {
|
||||
session.lastPayloadKey = nextPayloadKey;
|
||||
this.sendSessionUpdate(payload);
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
private sendSessionUpdate(session: EmbeddedMpvSession): void {
|
||||
if (!App.mainWindow || App.mainWindow.isDestroyed()) {
|
||||
return;
|
||||
}
|
||||
|
||||
App.mainWindow.webContents.send(EMBEDDED_MPV_SESSION_UPDATE, session);
|
||||
}
|
||||
|
||||
private getRuntimeSession(sessionId: string): EmbeddedMpvRuntimeSession {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) {
|
||||
throw new Error(`Embedded MPV session "${sessionId}" was not found.`);
|
||||
}
|
||||
|
||||
return session;
|
||||
}
|
||||
|
||||
private getMainWindowHandle(): Buffer {
|
||||
if (!App.mainWindow || App.mainWindow.isDestroyed()) {
|
||||
throw new Error('The Electron main window is not available.');
|
||||
}
|
||||
|
||||
return App.mainWindow.getNativeWindowHandle();
|
||||
}
|
||||
|
||||
private isExperimentEnabled(): boolean {
|
||||
return ['1', 'true', 'yes', 'on'].includes(
|
||||
(process.env[EMBEDDED_MPV_EXPERIMENT_ENV] ?? '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
);
|
||||
}
|
||||
|
||||
private isEmbeddedMpvEnabled(): boolean {
|
||||
return app.isPackaged || this.isExperimentEnabled();
|
||||
}
|
||||
|
||||
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.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private getAddon(): NativeEmbeddedMpvAddon {
|
||||
if (this.addon) {
|
||||
return this.addon;
|
||||
}
|
||||
|
||||
if (this.addonLoadError) {
|
||||
throw this.addonLoadError;
|
||||
}
|
||||
|
||||
const candidatePaths = this.getAddonCandidatePaths();
|
||||
|
||||
const existingCandidatePaths = candidatePaths.filter((candidatePath) =>
|
||||
existsSync(candidatePath)
|
||||
);
|
||||
|
||||
if (existingCandidatePaths.length === 0) {
|
||||
this.addonLoadError = new Error(
|
||||
[
|
||||
'Unable to locate the embedded MPV native addon.',
|
||||
...candidatePaths.map((candidatePath) => `- ${candidatePath}`),
|
||||
].join('\n')
|
||||
);
|
||||
throw this.addonLoadError;
|
||||
}
|
||||
|
||||
const loadErrors: string[] = [];
|
||||
for (const candidatePath of existingCandidatePaths) {
|
||||
try {
|
||||
this.addon = this.loadAddonModule(
|
||||
candidatePath
|
||||
) as NativeEmbeddedMpvAddon;
|
||||
return this.addon;
|
||||
} catch (error) {
|
||||
loadErrors.push(
|
||||
`${candidatePath}: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
this.addonLoadError = new Error(
|
||||
[
|
||||
'Unable to load the embedded MPV native addon.',
|
||||
...loadErrors.map((error) => `- ${error}`),
|
||||
].join('\n')
|
||||
);
|
||||
throw this.addonLoadError;
|
||||
}
|
||||
|
||||
private getAddonCandidatePaths(): string[] {
|
||||
const localBuildAddonPath = path.resolve(
|
||||
process.cwd(),
|
||||
'apps/electron-backend/native/build/Release/embedded_mpv.node'
|
||||
);
|
||||
const distAddonPaths = [
|
||||
path.resolve(__dirname, 'native/embedded_mpv.node'),
|
||||
path.resolve(__dirname, '../../native/embedded_mpv.node'),
|
||||
];
|
||||
const packagedAddonPaths = [
|
||||
path.resolve(
|
||||
(process as NodeJS.Process & { resourcesPath?: string })
|
||||
.resourcesPath ?? '',
|
||||
'app.asar.unpacked',
|
||||
'electron-backend',
|
||||
'native',
|
||||
'embedded_mpv.node'
|
||||
),
|
||||
app.getAppPath()
|
||||
? path.join(
|
||||
path.dirname(app.getAppPath()),
|
||||
'app.asar.unpacked',
|
||||
'electron-backend',
|
||||
'native',
|
||||
'embedded_mpv.node'
|
||||
)
|
||||
: undefined,
|
||||
];
|
||||
|
||||
return dedupePaths(
|
||||
app.isPackaged
|
||||
? [...packagedAddonPaths, ...distAddonPaths, localBuildAddonPath]
|
||||
: [localBuildAddonPath, ...distAddonPaths, ...packagedAddonPaths]
|
||||
);
|
||||
}
|
||||
|
||||
private readUnavailableReason(candidatePaths: string[]): string | null {
|
||||
for (const candidatePath of candidatePaths) {
|
||||
const unavailablePath = path.join(
|
||||
path.dirname(candidatePath),
|
||||
'embedded-mpv-unavailable.txt'
|
||||
);
|
||||
|
||||
if (existsSync(unavailablePath)) {
|
||||
return readFileSync(unavailablePath, 'utf8').trim();
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private getMissingRuntimeReason(addonPath: string): string | null {
|
||||
const nativeDir = path.dirname(addonPath);
|
||||
const libMpvPath = path.join(nativeDir, 'lib', 'libmpv.2.dylib');
|
||||
|
||||
if (!existsSync(libMpvPath)) {
|
||||
return `Embedded MPV runtime is incomplete. Missing ${libMpvPath}.`;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export const embeddedMpvNativeService = new EmbeddedMpvNativeService();
|
||||
@@ -9,6 +9,9 @@ import {
|
||||
setMainWindow as setDownloadsMainWindow,
|
||||
} from './app/events/database/downloads.events';
|
||||
import ElectronEvents from './app/events/electron.events';
|
||||
import EmbeddedMpvEvents, {
|
||||
shutdownEmbeddedMpv,
|
||||
} from './app/events/embedded-mpv.events';
|
||||
import EpgEvents from './app/events/epg.events';
|
||||
import PlayerEvents from './app/events/player.events';
|
||||
import PlaylistEvents from './app/events/playlist.events';
|
||||
@@ -57,6 +60,7 @@ export default class Main {
|
||||
}
|
||||
|
||||
ElectronEvents.bootstrapElectronEvents();
|
||||
EmbeddedMpvEvents.bootstrapEmbeddedMpvEvents();
|
||||
PlaylistEvents.bootstrapPlaylistEvents();
|
||||
SharedEvents.bootstrapSharedEvents();
|
||||
PlayerEvents.bootstrapPlayerEvents();
|
||||
@@ -105,5 +109,6 @@ app.whenReady().then(async () => {
|
||||
});
|
||||
|
||||
app.on('before-quit', () => {
|
||||
shutdownEmbeddedMpv();
|
||||
void databaseWorkerClient.shutdown();
|
||||
});
|
||||
@@ -47,6 +47,7 @@ describe('PlayerService', () => {
|
||||
expect(service.isEmbeddedPlayer(VideoPlayer.VideoJs)).toBe(true);
|
||||
expect(service.isEmbeddedPlayer(VideoPlayer.Html5Player)).toBe(true);
|
||||
expect(service.isEmbeddedPlayer(VideoPlayer.ArtPlayer)).toBe(true);
|
||||
expect(service.isEmbeddedPlayer(VideoPlayer.EmbeddedMpv)).toBe(true);
|
||||
expect(service.isEmbeddedPlayer(VideoPlayer.MPV)).toBe(false);
|
||||
expect(service.isEmbeddedPlayer(VideoPlayer.VLC)).toBe(false);
|
||||
});
|
||||
@@ -57,7 +58,19 @@ describe('PlayerService', () => {
|
||||
title: 'Example Video',
|
||||
});
|
||||
|
||||
expect(dialog.open).toHaveBeenCalled();
|
||||
expect(dialog.open).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
streamUrl: 'https://example.com/video.mp4',
|
||||
title: 'Example Video',
|
||||
playback: expect.objectContaining({
|
||||
streamUrl: 'https://example.com/video.mp4',
|
||||
title: 'Example Video',
|
||||
}),
|
||||
}),
|
||||
})
|
||||
);
|
||||
expect(dataService.sendIpcEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
||||
@@ -26,7 +26,8 @@ export class PlayerService {
|
||||
return (
|
||||
player === VideoPlayer.VideoJs ||
|
||||
player === VideoPlayer.Html5Player ||
|
||||
player === VideoPlayer.ArtPlayer
|
||||
player === VideoPlayer.ArtPlayer ||
|
||||
player === VideoPlayer.EmbeddedMpv
|
||||
);
|
||||
}
|
||||
|
||||
@@ -118,7 +119,13 @@ export class PlayerService {
|
||||
return import('@iptvnator/portal/xtream/feature').then(
|
||||
({ PlayerDialogComponent }) => {
|
||||
this.dialog.open(PlayerDialogComponent, {
|
||||
data: { streamUrl, title, contentInfo, startTime },
|
||||
data: {
|
||||
streamUrl,
|
||||
title,
|
||||
contentInfo,
|
||||
startTime,
|
||||
playback,
|
||||
},
|
||||
width: '80%',
|
||||
maxWidth: '1200px',
|
||||
maxHeight: '90vh',
|
||||
|
||||
@@ -402,7 +402,7 @@ describe('SettingsComponent', () => {
|
||||
});
|
||||
});
|
||||
|
||||
/* it('hides the embedded mpv option when the desktop support probe reports unsupported', async () => {
|
||||
it('hides the embedded mpv option when the desktop support probe reports unsupported', async () => {
|
||||
window.electron = {
|
||||
...window.electron,
|
||||
getEmbeddedMpvSupport: jest.fn().mockResolvedValue({
|
||||
@@ -416,9 +416,9 @@ describe('SettingsComponent', () => {
|
||||
await fixture.whenStable();
|
||||
|
||||
expect(
|
||||
component.players().some(
|
||||
(player) => player.id === VideoPlayer.EmbeddedMpv
|
||||
)
|
||||
component
|
||||
.players()
|
||||
.some((player) => player.id === VideoPlayer.EmbeddedMpv)
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
@@ -435,9 +435,9 @@ describe('SettingsComponent', () => {
|
||||
await fixture.whenStable();
|
||||
|
||||
expect(
|
||||
component.players().some(
|
||||
(player) => player.id === VideoPlayer.EmbeddedMpv
|
||||
)
|
||||
component
|
||||
.players()
|
||||
.some((player) => player.id === VideoPlayer.EmbeddedMpv)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
@@ -458,9 +458,9 @@ describe('SettingsComponent', () => {
|
||||
expect(component.settingsForm.value).toEqual(DEFAULT_SETTINGS);
|
||||
expect(window.electron.getEmbeddedMpvSupport).toHaveBeenCalled();
|
||||
expect(
|
||||
component.players().some(
|
||||
(player) => player.id === VideoPlayer.EmbeddedMpv
|
||||
)
|
||||
component
|
||||
.players()
|
||||
.some((player) => player.id === VideoPlayer.EmbeddedMpv)
|
||||
).toBe(false);
|
||||
|
||||
resolveSupport!({
|
||||
@@ -470,11 +470,11 @@ describe('SettingsComponent', () => {
|
||||
await fixture.whenStable();
|
||||
|
||||
expect(
|
||||
component.players().some(
|
||||
(player) => player.id === VideoPlayer.EmbeddedMpv
|
||||
)
|
||||
component
|
||||
.players()
|
||||
.some((player) => player.id === VideoPlayer.EmbeddedMpv)
|
||||
).toBe(true);
|
||||
}); */
|
||||
});
|
||||
});
|
||||
|
||||
describe('Version check', () => {
|
||||
|
||||
@@ -56,7 +56,7 @@ import {
|
||||
PlaylistsService,
|
||||
} from 'services';
|
||||
import {
|
||||
/* EmbeddedMpvSupport, */
|
||||
EmbeddedMpvSupport,
|
||||
Language,
|
||||
StartupBehavior,
|
||||
StreamFormat,
|
||||
@@ -151,10 +151,10 @@ export class SettingsComponent implements OnInit, OnDestroy {
|
||||
|
||||
/** Flag that indicates whether the app runs in electron environment */
|
||||
readonly isDesktop = !!window.electron;
|
||||
/* readonly embeddedMpvSupport = signal<EmbeddedMpvSupport | null>(null);
|
||||
readonly embeddedMpvSupport = signal<EmbeddedMpvSupport | null>(null);
|
||||
readonly supportsEmbeddedMpv = computed(
|
||||
() => this.isDesktop && !!this.embeddedMpvSupport()?.supported
|
||||
); */
|
||||
);
|
||||
|
||||
isPwa = this.dataService.getAppEnvironment() === 'pwa';
|
||||
|
||||
@@ -162,14 +162,14 @@ export class SettingsComponent implements OnInit, OnDestroy {
|
||||
readonly activeSection = this.settingsCtx.activeSection;
|
||||
|
||||
readonly osPlayers = computed(() => [
|
||||
/* ...(this.supportsEmbeddedMpv()
|
||||
...(this.supportsEmbeddedMpv()
|
||||
? [
|
||||
{
|
||||
id: VideoPlayer.EmbeddedMpv,
|
||||
labelKey: 'SETTINGS.PLAYER_EMBEDDED_MPV',
|
||||
},
|
||||
]
|
||||
: []), */
|
||||
: []),
|
||||
{
|
||||
id: VideoPlayer.MPV,
|
||||
labelKey: 'SETTINGS.PLAYER_MPV',
|
||||
@@ -437,7 +437,7 @@ export class SettingsComponent implements OnInit, OnDestroy {
|
||||
// Wait for settings to load before setting the form
|
||||
await this.settingsStore.loadSettings();
|
||||
this.setSettings();
|
||||
/* void this.loadEmbeddedMpvSupport(); */
|
||||
void this.loadEmbeddedMpvSupport();
|
||||
this.checkAppVersion();
|
||||
void this.fetchLocalIpAddresses();
|
||||
|
||||
@@ -448,7 +448,7 @@ export class SettingsComponent implements OnInit, OnDestroy {
|
||||
requestAnimationFrame(() => this.setupSectionObserver());
|
||||
}
|
||||
|
||||
/* private async loadEmbeddedMpvSupport(): Promise<void> {
|
||||
private async loadEmbeddedMpvSupport(): Promise<void> {
|
||||
if (!this.isDesktop) {
|
||||
this.embeddedMpvSupport.set({
|
||||
supported: false,
|
||||
@@ -475,13 +475,10 @@ export class SettingsComponent implements OnInit, OnDestroy {
|
||||
this.embeddedMpvSupport.set({
|
||||
supported: false,
|
||||
platform: window.electron.platform,
|
||||
reason:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: String(error),
|
||||
reason: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
} */
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.cancelPendingScrollTargetClear();
|
||||
|
||||
Vendored
+41
@@ -5,6 +5,9 @@ interface NodeModule {
|
||||
}
|
||||
|
||||
import {
|
||||
EmbeddedMpvBounds,
|
||||
EmbeddedMpvSession,
|
||||
EmbeddedMpvSupport,
|
||||
EpgChannel,
|
||||
EpgChannelMetadata,
|
||||
EpgProgram,
|
||||
@@ -14,6 +17,7 @@ import {
|
||||
PlaylistRefreshEvent,
|
||||
PlaylistRefreshPayload,
|
||||
PortalDebugEvent,
|
||||
ResolvedPortalPlayback,
|
||||
XtreamBackupFavoriteItem,
|
||||
XtreamBackupHiddenCategory,
|
||||
XtreamBackupRecentlyViewedItem,
|
||||
@@ -321,9 +325,46 @@ declare global {
|
||||
originalError: string;
|
||||
}) => void
|
||||
) => void;
|
||||
onEmbeddedMpvSessionUpdate?: (
|
||||
callback: (data: EmbeddedMpvSession) => void
|
||||
) => () => void;
|
||||
onExternalPlayerSessionUpdate?: (
|
||||
callback: (data: ExternalPlayerSession) => void
|
||||
) => () => void;
|
||||
getEmbeddedMpvSupport: () => Promise<EmbeddedMpvSupport>;
|
||||
prepareEmbeddedMpv?: () => Promise<EmbeddedMpvSupport>;
|
||||
createEmbeddedMpvSession: (
|
||||
bounds: EmbeddedMpvBounds,
|
||||
title?: string,
|
||||
initialVolume?: number
|
||||
) => Promise<EmbeddedMpvSession>;
|
||||
loadEmbeddedMpvPlayback: (
|
||||
sessionId: string,
|
||||
playback: ResolvedPortalPlayback
|
||||
) => Promise<void>;
|
||||
setEmbeddedMpvBounds: (
|
||||
sessionId: string,
|
||||
bounds: EmbeddedMpvBounds
|
||||
) => Promise<void>;
|
||||
setEmbeddedMpvPaused: (
|
||||
sessionId: string,
|
||||
paused: boolean
|
||||
) => Promise<EmbeddedMpvSession | null>;
|
||||
seekEmbeddedMpv: (
|
||||
sessionId: string,
|
||||
seconds: number
|
||||
) => Promise<EmbeddedMpvSession | null>;
|
||||
setEmbeddedMpvVolume: (
|
||||
sessionId: string,
|
||||
volume: number
|
||||
) => Promise<EmbeddedMpvSession | null>;
|
||||
setEmbeddedMpvAudioTrack: (
|
||||
sessionId: string,
|
||||
trackId: number
|
||||
) => Promise<EmbeddedMpvSession | null>;
|
||||
disposeEmbeddedMpvSession: (
|
||||
sessionId: string
|
||||
) => Promise<EmbeddedMpvSession | null>;
|
||||
getLocalIpAddresses: () => Promise<string[]>;
|
||||
// EPG progress listener
|
||||
onEpgProgress?: (
|
||||
|
||||
@@ -5,6 +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.
|
||||
- External players are `mpv` and `vlc`.
|
||||
- Flatpak launches external players on the host via `flatpak-spawn --host`.
|
||||
- Live playback stays inline in dedicated live layouts.
|
||||
@@ -25,6 +26,43 @@ Not migrated in this pass:
|
||||
- Generic non-detail playback entry points that still call `PlayerService.openPlayer(...)`
|
||||
- Any collection/search surface that does not host a canonical detail surface of its own
|
||||
|
||||
## Embedded MPV Harness
|
||||
|
||||
The repository now contains a first-pass native embedded MPV harness for Electron:
|
||||
|
||||
- shared setting id: `embedded-mpv`
|
||||
- native addon owner: `/Users/4gray/Code/iptvnator/apps/electron-backend/src/app/services/embedded-mpv-native.service.ts`
|
||||
- IPC bridge: `/Users/4gray/Code/iptvnator/apps/electron-backend/src/app/events/embedded-mpv.events.ts`
|
||||
- renderer host: `/Users/4gray/Code/iptvnator/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.ts`
|
||||
- native architecture and release-readiness details: `/Users/4gray/Code/iptvnator/docs/architecture/embedded-mpv-native.md`
|
||||
|
||||
Current contract:
|
||||
|
||||
- macOS only
|
||||
- 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
|
||||
- uses IPTVnator-owned controls and `ResolvedPortalPlayback` payloads
|
||||
- uses the libmpv render API on macOS and renders through an IPTVnator-owned native `NSView`
|
||||
- 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
|
||||
- exposes an IPTVnator-owned fullscreen button that uses the renderer fullscreen API and resyncs the native MPV view bounds after fullscreen transitions
|
||||
- auto-hides IPTVnator-owned controls while playback is active and restores them on pointer/focus interaction
|
||||
- exposes audio-track metadata from MPV and switches tracks through the `aid` property without reloading the stream
|
||||
- passes VOD/episode resume offsets to MPV through the `loadfile` options map; live catchup URLs are treated as already-positioned streams
|
||||
- applies the initial volume during session creation and uses async libmpv control calls after startup
|
||||
- VLC remains external-only
|
||||
|
||||
Current limitation:
|
||||
|
||||
- the current feasibility harness is still experimental and macOS-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
|
||||
- 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
|
||||
- this follows the rollout gate: keep the native work in-tree, but do not leave it user-facing until playback, resize, focus, and packaging are stable
|
||||
|
||||
## Components
|
||||
|
||||
Shared inline player shell:
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
# Embedded MPV Native Integration
|
||||
|
||||
This document explains how IPTVnator embeds MPV inside the Electron app, which files are source versus generated build output, and what must be true before the feature is safe to expose to users.
|
||||
|
||||
## What To Commit
|
||||
|
||||
Source files for the embedded MPV integration:
|
||||
|
||||
- `apps/electron-backend/build-embedded-mpv.js` builds the native addon for the current 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/src/app/services/embedded-mpv-native.service.ts` owns Electron main-process session lifecycle and support detection.
|
||||
- `apps/electron-backend/src/app/events/embedded-mpv.events.ts` registers the IPC contract.
|
||||
- `apps/electron-backend/src/app/api/main.preload.ts` exposes the preload bridge to the renderer.
|
||||
- `libs/shared/interfaces/src/lib/embedded-mpv-session.interface.ts` defines the shared session and audio-track contract.
|
||||
- `libs/ui/playback/src/lib/embedded-mpv-player/` owns the Angular UI and controls.
|
||||
|
||||
Generated native-addon build output:
|
||||
|
||||
- `apps/electron-backend/native/build/`
|
||||
|
||||
The build directory contains files such as `Makefile`, `binding.Makefile`, `config.gypi`, `embedded_mpv.target.mk`, `gyp-mac-tool`, `embedded_mpv.node`, `.o`, and `.d` files. These are generated by `node-gyp` and must not be committed. The repo `.gitignore` ignores this directory.
|
||||
|
||||
## How It Is Embedded
|
||||
|
||||
The embedded player 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 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.
|
||||
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.
|
||||
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:
|
||||
|
||||
- prepare native addon
|
||||
- create session
|
||||
- load playback
|
||||
- set bounds
|
||||
- play/pause
|
||||
- seek
|
||||
- set volume
|
||||
- set audio track
|
||||
- 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.
|
||||
|
||||
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 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.
|
||||
|
||||
## Resume And Track Handling
|
||||
|
||||
`ResolvedPortalPlayback.startTime` is treated as a media offset in seconds for VOD and episodes. The native addon passes it as the `start` option in one MPV `loadfile` options map together with title, user agent, referrer, and HTTP headers.
|
||||
|
||||
Live catchup is different: the catchup URL already encodes the archive window, so live catchup playback must not pass an absolute Unix timestamp as `startTime`.
|
||||
|
||||
Audio tracks are discovered from MPV's `track-list` property. The selected track is controlled through MPV's `aid` property. Switching tracks must not reload the stream.
|
||||
|
||||
## Packaging State
|
||||
|
||||
Current development behavior:
|
||||
|
||||
- The addon build is macOS-only.
|
||||
- The build script first looks for a staged runtime at `vendor/embedded-mpv/darwin-<arch>/`.
|
||||
- The staged runtime must contain `include/mpv/client.h`, `lib/*.dylib`, 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.
|
||||
- `electron-builder` unpacks `.node` files from ASAR.
|
||||
- `electron-builder` also unpacks `.dylib` files from ASAR.
|
||||
|
||||
Current release caveat:
|
||||
|
||||
- Release packaging requires a `vendored-lgpl` runtime manifest.
|
||||
- Release packaging rejects embedded MPV binaries linked to `/opt/homebrew` or `/usr/local`.
|
||||
- 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
|
||||
- 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.
|
||||
|
||||
## Runtime Staging
|
||||
|
||||
Runtime staging tooling lives in:
|
||||
|
||||
- `/Users/4gray/Code/iptvnator/tools/embedded-mpv/`
|
||||
- `/Users/4gray/Code/iptvnator/vendor/embedded-mpv/`
|
||||
|
||||
Release runtime policy:
|
||||
|
||||
- FFmpeg must be built without `--enable-gpl` and without `--enable-nonfree`.
|
||||
- mpv must be built with `-Dlibmpv=true` and `-Dgpl=false`.
|
||||
- The runtime must be dynamically linked and shipped with license/source-distribution notices.
|
||||
|
||||
After building an LGPL-compatible prefix for an 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
|
||||
```
|
||||
|
||||
Tagged macOS release CI builds that prefix from pinned source archives first:
|
||||
|
||||
```bash
|
||||
pnpm embedded-mpv:build-runtime -- arm64 /tmp/embedded-mpv-prefix
|
||||
pnpm embedded-mpv:stage-runtime -- arm64 /tmp/embedded-mpv-prefix
|
||||
```
|
||||
|
||||
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. The generated manifest records source URLs, SHA-256 values, 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.
|
||||
|
||||
For local development before the vendored runtime exists, Homebrew can be used explicitly:
|
||||
|
||||
```bash
|
||||
IPTVNATOR_EMBEDDED_MPV_ALLOW_HOMEBREW=1 \
|
||||
IPTVNATOR_ENABLE_EMBEDDED_MPV_EXPERIMENT=1 \
|
||||
pnpm nx serve electron-backend
|
||||
```
|
||||
|
||||
This path is intentionally development-only. Packaged macOS builds reject `homebrew-dev` manifests and 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:
|
||||
|
||||
```bash
|
||||
ls apps/electron-backend/native/build/Release/embedded_mpv.node
|
||||
```
|
||||
|
||||
If only `embedded-mpv-unavailable.txt` exists, the dev app started from a build where no runtime was available. Stop the Electron dev process and restart it with the flags. The native MPV build target is intentionally uncached because it depends on local runtime files and environment variables such as `IPTVNATOR_EMBEDDED_MPV_ALLOW_HOMEBREW` and `IPTVNATOR_EMBEDDED_MPV_ARCH`.
|
||||
|
||||
If opening Settings hard-crashes Electron on macOS and the crash report says `Code Signature Invalid`, one of the copied runtime binaries was modified by `install_name_tool` without being re-signed. Rebuild the native target and verify the copied addon/runtime files:
|
||||
|
||||
```bash
|
||||
IPTVNATOR_EMBEDDED_MPV_ALLOW_HOMEBREW=1 node apps/electron-backend/build-embedded-mpv.js
|
||||
codesign --verify --verbose=2 apps/electron-backend/native/build/Release/embedded_mpv.node
|
||||
codesign --verify --verbose=2 apps/electron-backend/native/build/Release/lib/libmpv.2.dylib
|
||||
```
|
||||
|
||||
If support detection reports a missing `@rpath/...` dependency, the dependency collector missed an indirect runtime file. The packaging helper must copy that file into `native/lib/`, rewrite the dependency to `@loader_path/<name>`, and include non-`.dylib` Mach-O files in the asset copy glob.
|
||||
|
||||
## Same-Version macOS 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.
|
||||
|
||||
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_ARCH=${arch}` for backend build and packaging
|
||||
- set `IPTVNATOR_REQUIRE_EMBEDDED_MPV=1` for packaging and package-layout verification
|
||||
|
||||
For PR, development, Linux, and Windows packaging, `IPTVNATOR_REQUIRE_EMBEDDED_MPV` stays unset or `0`. In that 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.
|
||||
|
||||
## 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
|
||||
- macOS graphics behavior can vary across Intel, Apple Silicon, external displays, fullscreen transitions, and hardware decoding paths
|
||||
- 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.
|
||||
|
||||
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.
|
||||
|
||||
## Suggested Release Gate
|
||||
|
||||
Do not expose embedded MPV broadly until these pass on both Apple Silicon and Intel macOS:
|
||||
|
||||
- packaged `.app` starts without Homebrew installed
|
||||
- bundled `libmpv` and dependent dylibs pass code signing and notarization
|
||||
- VOD resume starts near the saved offset
|
||||
- live HLS, MPEG-TS, MP4/VOD, headers, referrer, volume, seek, fullscreen, route changes, and cleanup work
|
||||
- audio-track switching works on a stream with multiple audio tracks
|
||||
- fallback behavior is clear when the addon or native dependencies are unavailable
|
||||
@@ -19,7 +19,9 @@
|
||||
"asarUnpack": [
|
||||
"**/better-sqlite3/**",
|
||||
"**/bindings/**",
|
||||
"**/file-uri-to-path/**"
|
||||
"**/file-uri-to-path/**",
|
||||
"**/*.node",
|
||||
"**/*.dylib"
|
||||
],
|
||||
"extraMetadata": {
|
||||
"main": "electron-backend/main.js"
|
||||
@@ -95,7 +97,7 @@
|
||||
]
|
||||
]
|
||||
},
|
||||
"afterPack": "./tools/packaging/linux-after-pack.cjs",
|
||||
"afterPack": "./tools/packaging/electron-after-pack.cjs",
|
||||
"snap": {
|
||||
"base": "core22",
|
||||
"confinement": "strict",
|
||||
|
||||
Vendored
+41
@@ -1,4 +1,7 @@
|
||||
import 'jest-extended';
|
||||
import { EmbeddedMpvBounds } from './libs/shared/interfaces/src/lib/embedded-mpv-session.interface';
|
||||
import { EmbeddedMpvSession } from './libs/shared/interfaces/src/lib/embedded-mpv-session.interface';
|
||||
import { EmbeddedMpvSupport } from './libs/shared/interfaces/src/lib/embedded-mpv-session.interface';
|
||||
import { ExternalPlayerSession } from './libs/shared/interfaces/src/lib/external-player-session.interface';
|
||||
import { PlaybackPositionData } from './libs/shared/interfaces/src/lib/playback-position.interface';
|
||||
import {
|
||||
@@ -11,6 +14,7 @@ import {
|
||||
PlaylistRefreshPayload,
|
||||
} from './libs/shared/interfaces/src/lib/playlist-refresh.interface';
|
||||
import { Playlist } from './libs/shared/interfaces/src/lib/playlist.interface';
|
||||
import { ResolvedPortalPlayback } from './libs/shared/interfaces/src/lib/portal-playback.interface';
|
||||
|
||||
declare module 'video.js' {
|
||||
export interface VideoJsPlayer {
|
||||
@@ -111,9 +115,46 @@ declare global {
|
||||
onExternalPlayerSessionUpdate?: (
|
||||
callback: (data: ExternalPlayerSession) => void
|
||||
) => () => void;
|
||||
onEmbeddedMpvSessionUpdate?: (
|
||||
callback: (data: EmbeddedMpvSession) => void
|
||||
) => () => void;
|
||||
closeExternalPlayerSession: (
|
||||
sessionId: string
|
||||
) => Promise<ExternalPlayerSession | null>;
|
||||
getEmbeddedMpvSupport: () => Promise<EmbeddedMpvSupport>;
|
||||
prepareEmbeddedMpv?: () => Promise<EmbeddedMpvSupport>;
|
||||
createEmbeddedMpvSession: (
|
||||
bounds: EmbeddedMpvBounds,
|
||||
title?: string,
|
||||
initialVolume?: number
|
||||
) => Promise<EmbeddedMpvSession>;
|
||||
loadEmbeddedMpvPlayback: (
|
||||
sessionId: string,
|
||||
playback: ResolvedPortalPlayback
|
||||
) => Promise<void>;
|
||||
setEmbeddedMpvBounds: (
|
||||
sessionId: string,
|
||||
bounds: EmbeddedMpvBounds
|
||||
) => Promise<void>;
|
||||
setEmbeddedMpvPaused: (
|
||||
sessionId: string,
|
||||
paused: boolean
|
||||
) => Promise<EmbeddedMpvSession | null>;
|
||||
seekEmbeddedMpv: (
|
||||
sessionId: string,
|
||||
seconds: number
|
||||
) => Promise<EmbeddedMpvSession | null>;
|
||||
setEmbeddedMpvVolume: (
|
||||
sessionId: string,
|
||||
volume: number
|
||||
) => Promise<EmbeddedMpvSession | null>;
|
||||
setEmbeddedMpvAudioTrack: (
|
||||
sessionId: string,
|
||||
trackId: number
|
||||
) => Promise<EmbeddedMpvSession | null>;
|
||||
disposeEmbeddedMpvSession: (
|
||||
sessionId: string
|
||||
) => Promise<EmbeddedMpvSession | null>;
|
||||
stalkerRequest: (payload: {
|
||||
url: string;
|
||||
macAddress: string;
|
||||
|
||||
@@ -68,6 +68,13 @@
|
||||
[showCaptions]="playerSettings.showCaptions!"
|
||||
/>
|
||||
}
|
||||
@if (playerSettings.player === 'embedded-mpv') {
|
||||
<app-web-player-view
|
||||
[streamUrl]="embeddedPlayback()?.streamUrl ?? ''"
|
||||
[title]="embeddedPlayback()?.title ?? ''"
|
||||
[playback]="embeddedPlayback()"
|
||||
/>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -282,6 +282,23 @@ describe('VideoPlayerComponent', () => {
|
||||
).not.toBeNull();
|
||||
});
|
||||
|
||||
it('renders the embedded mpv inline player with the EPG panel', () => {
|
||||
syncStoreState(sampleChannel);
|
||||
player.set(VideoPlayer.EmbeddedMpv);
|
||||
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(
|
||||
fixture.nativeElement.querySelector('.video-player')
|
||||
).not.toBeNull();
|
||||
expect(
|
||||
fixture.nativeElement.querySelector('app-web-player-view')
|
||||
).not.toBeNull();
|
||||
expect(
|
||||
fixture.nativeElement.querySelector('app-epg-list')
|
||||
).not.toBeNull();
|
||||
});
|
||||
|
||||
it('renders only the EPG panel when an external player is configured', () => {
|
||||
syncStoreState(sampleChannel);
|
||||
player.set(VideoPlayer.MPV);
|
||||
|
||||
@@ -61,6 +61,7 @@ import {
|
||||
HtmlVideoPlayerComponent,
|
||||
SidebarComponent,
|
||||
VjsPlayerComponent,
|
||||
WebPlayerViewComponent,
|
||||
} from '@iptvnator/ui/playback';
|
||||
import { ChannelListLoadingStateComponent } from 'components';
|
||||
import { DataService, PlaylistsService, SettingsStore } from 'services';
|
||||
@@ -71,6 +72,7 @@ import {
|
||||
PLAYLIST_PARSE_BY_URL,
|
||||
M3uRecentlyViewedItem,
|
||||
PlaylistMeta,
|
||||
ResolvedPortalPlayback,
|
||||
STORE_KEY,
|
||||
Settings,
|
||||
VideoPlayer,
|
||||
@@ -98,6 +100,7 @@ const M3U_SIDEBAR_DEFAULT_WIDTH = 460;
|
||||
SidebarComponent,
|
||||
TranslatePipe,
|
||||
VjsPlayerComponent,
|
||||
WebPlayerViewComponent,
|
||||
],
|
||||
templateUrl: './video-player.component.html',
|
||||
styleUrl: './video-player.component.scss',
|
||||
@@ -145,6 +148,38 @@ export class VideoPlayerComponent implements OnInit, OnDestroy {
|
||||
epgParams: '',
|
||||
} as Channel;
|
||||
});
|
||||
readonly embeddedPlayback = computed<ResolvedPortalPlayback | null>(() => {
|
||||
const activeChannel = this.activeChannel();
|
||||
const playbackTarget = this.playbackChannel();
|
||||
|
||||
if (!activeChannel || !playbackTarget) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = {};
|
||||
if (playbackTarget.http['user-agent']) {
|
||||
headers['User-Agent'] = playbackTarget.http['user-agent'];
|
||||
}
|
||||
if (playbackTarget.http.referrer) {
|
||||
headers['Referer'] = playbackTarget.http.referrer;
|
||||
}
|
||||
if (playbackTarget.http.origin) {
|
||||
headers['Origin'] = playbackTarget.http.origin;
|
||||
}
|
||||
|
||||
return {
|
||||
streamUrl: `${playbackTarget.url}${playbackTarget.epgParams ?? ''}`,
|
||||
title:
|
||||
activeChannel.name?.trim() ||
|
||||
activeChannel.tvg?.name ||
|
||||
playbackTarget.url,
|
||||
thumbnail: activeChannel.tvg?.logo ?? null,
|
||||
headers: Object.keys(headers).length > 0 ? headers : undefined,
|
||||
userAgent: playbackTarget.http['user-agent'] || undefined,
|
||||
referer: playbackTarget.http.referrer || undefined,
|
||||
origin: playbackTarget.http.origin || undefined,
|
||||
};
|
||||
});
|
||||
readonly sidebarStorageKey = computed(() =>
|
||||
this.activeView() === 'groups'
|
||||
? M3U_GROUPS_SIDEBAR_STORAGE_KEY
|
||||
|
||||
+7
@@ -88,6 +88,13 @@
|
||||
[showCaptions]="false"
|
||||
/>
|
||||
}
|
||||
@if (player() === 'embedded-mpv') {
|
||||
<app-web-player-view
|
||||
[streamUrl]="currentStreamUrl()"
|
||||
[title]="activeDetail()?.playback?.title ?? ''"
|
||||
[playback]="activeDetail()?.playback ?? null"
|
||||
/>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
+2
-1
@@ -38,7 +38,7 @@ import {
|
||||
import { ResizableDirective } from 'components';
|
||||
import { SettingsStore } from 'services';
|
||||
import { Channel, EpgProgram } from 'shared-interfaces';
|
||||
import { EpgViewComponent } from 'shared-portals';
|
||||
import { EpgViewComponent, WebPlayerViewComponent } from 'shared-portals';
|
||||
|
||||
@Component({
|
||||
selector: 'app-unified-live-tab',
|
||||
@@ -59,6 +59,7 @@ import { EpgViewComponent } from 'shared-portals';
|
||||
ResizableDirective,
|
||||
TranslatePipe,
|
||||
VjsPlayerComponent,
|
||||
WebPlayerViewComponent,
|
||||
],
|
||||
})
|
||||
export class UnifiedLiveTabComponent {
|
||||
|
||||
+5
-1
@@ -79,7 +79,11 @@
|
||||
} @else if (stalkerStore.selectedItem()) {
|
||||
@if (streamUrl() && isEmbeddedPlayer) {
|
||||
<div class="video-player">
|
||||
<app-web-player-view [streamUrl]="streamUrl()" />
|
||||
<app-web-player-view
|
||||
[streamUrl]="streamUrl()"
|
||||
[title]="activePlayback()?.title ?? ''"
|
||||
[playback]="activePlayback()"
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
<div class="epg">
|
||||
|
||||
+2
@@ -43,6 +43,8 @@ class StubChannelListItemComponent {
|
||||
})
|
||||
class StubWebPlayerViewComponent {
|
||||
readonly streamUrl = input('');
|
||||
readonly title = input('');
|
||||
readonly playback = input<unknown>(null);
|
||||
}
|
||||
|
||||
@Component({
|
||||
|
||||
+5
-1
@@ -76,7 +76,11 @@
|
||||
} @else if (streamUrl) {
|
||||
@if (isEmbeddedPlayer) {
|
||||
<div class="video-player">
|
||||
<app-web-player-view [streamUrl]="streamUrl" />
|
||||
<app-web-player-view
|
||||
[streamUrl]="streamUrl"
|
||||
[title]="activePlayback()?.title ?? ''"
|
||||
[playback]="activePlayback()"
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
<div class="epg">
|
||||
|
||||
+2
@@ -40,6 +40,8 @@ class StubPortalChannelsListComponent {
|
||||
})
|
||||
class StubWebPlayerViewComponent {
|
||||
readonly streamUrl = input('');
|
||||
readonly title = input('');
|
||||
readonly playback = input<unknown>(null);
|
||||
}
|
||||
|
||||
@Component({
|
||||
|
||||
+19
-4
@@ -35,7 +35,11 @@ import {
|
||||
import { EpgListComponent, EpgProgramActivationEvent } from '@iptvnator/ui/epg';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { EpgViewComponent, WebPlayerViewComponent } from 'shared-portals';
|
||||
import { EpgItem, EpgProgram } from 'shared-interfaces';
|
||||
import {
|
||||
EpgItem,
|
||||
EpgProgram,
|
||||
ResolvedPortalPlayback,
|
||||
} from 'shared-interfaces';
|
||||
import { PortalChannelsListComponent } from '../portal-channels-list/portal-channels-list.component';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
|
||||
@@ -168,7 +172,10 @@ export class LiveStreamLayoutComponent implements OnInit, OnDestroy {
|
||||
readonly usesEmbeddedPlayer = computed(() =>
|
||||
this.portalPlayer.isEmbeddedPlayer()
|
||||
);
|
||||
readonly activeStreamUrl = signal('');
|
||||
readonly activePlayback = signal<ResolvedPortalPlayback | null>(null);
|
||||
readonly activeStreamUrl = computed(
|
||||
() => this.activePlayback()?.streamUrl ?? ''
|
||||
);
|
||||
favorites = new Map<number, boolean>();
|
||||
|
||||
constructor() {
|
||||
@@ -291,7 +298,11 @@ export class LiveStreamLayoutComponent implements OnInit, OnDestroy {
|
||||
|
||||
playLive(item: XtreamLiveChannelItem) {
|
||||
const streamUrl = this.xtreamStore.constructStreamUrl(item);
|
||||
this.activeStreamUrl.set(streamUrl);
|
||||
this.activePlayback.set({
|
||||
streamUrl,
|
||||
title: item.title ?? item.name ?? '',
|
||||
thumbnail: item.poster_url ?? item.stream_icon ?? null,
|
||||
});
|
||||
if (this.usesEmbeddedPlayer()) {
|
||||
return;
|
||||
}
|
||||
@@ -406,7 +417,11 @@ export class LiveStreamLayoutComponent implements OnInit, OnDestroy {
|
||||
stopTimestamp
|
||||
);
|
||||
|
||||
this.activeStreamUrl.set(catchupUrl);
|
||||
this.activePlayback.set({
|
||||
streamUrl: catchupUrl,
|
||||
title: this.getCatchupPlaybackTitle(item, program),
|
||||
thumbnail: item.poster_url ?? item.stream_icon ?? null,
|
||||
});
|
||||
if (this.usesEmbeddedPlayer()) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
<mat-dialog-content class="content">
|
||||
<app-web-player-view
|
||||
[streamUrl]="streamUrl"
|
||||
[title]="title"
|
||||
[playback]="data.playback ?? null"
|
||||
[startTime]="data.startTime || 0"
|
||||
(timeUpdate)="handleTimeUpdate($event)"
|
||||
/>
|
||||
|
||||
@@ -7,7 +7,7 @@ import { MatSnackBar } from '@angular/material/snack-bar';
|
||||
import { MatTooltip } from '@angular/material/tooltip';
|
||||
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
|
||||
import { PORTAL_PLAYBACK_POSITIONS } from '@iptvnator/portal/shared/util';
|
||||
import { PlayerContentInfo } from 'shared-interfaces';
|
||||
import { PlayerContentInfo, ResolvedPortalPlayback } from 'shared-interfaces';
|
||||
import { WebPlayerViewComponent } from 'shared-portals';
|
||||
|
||||
export interface PlayerDialogData {
|
||||
@@ -15,6 +15,7 @@ export interface PlayerDialogData {
|
||||
title: string;
|
||||
contentInfo?: PlayerContentInfo;
|
||||
startTime?: number;
|
||||
playback?: ResolvedPortalPlayback;
|
||||
}
|
||||
|
||||
@Component({
|
||||
@@ -43,8 +44,8 @@ export class PlayerDialogComponent {
|
||||
private lastSaveTime = 0;
|
||||
|
||||
constructor() {
|
||||
this.streamUrl = this.data.streamUrl;
|
||||
this.title = this.data.title;
|
||||
this.streamUrl = this.data.playback?.streamUrl ?? this.data.streamUrl;
|
||||
this.title = this.data.playback?.title ?? this.data.title;
|
||||
}
|
||||
|
||||
handleTimeUpdate(event: { currentTime: number; duration: number }) {
|
||||
|
||||
@@ -36,6 +36,45 @@ const DEFAULT_SETTINGS: Settings = {
|
||||
downloadFolder: '',
|
||||
};
|
||||
|
||||
let embeddedMpvPrepareScheduled = false;
|
||||
|
||||
function scheduleEmbeddedMpvPrepare(): void {
|
||||
if (
|
||||
embeddedMpvPrepareScheduled ||
|
||||
typeof window === 'undefined' ||
|
||||
!window.electron?.prepareEmbeddedMpv
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
embeddedMpvPrepareScheduled = true;
|
||||
const prepare = () => {
|
||||
void window.electron
|
||||
.prepareEmbeddedMpv?.()
|
||||
.then((support) => {
|
||||
if (!support?.supported) {
|
||||
embeddedMpvPrepareScheduled = false;
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
embeddedMpvPrepareScheduled = false;
|
||||
console.warn('Failed to prepare embedded MPV.', error);
|
||||
});
|
||||
};
|
||||
const idleWindow = window as typeof window & {
|
||||
requestIdleCallback?: (
|
||||
callback: IdleRequestCallback,
|
||||
options?: IdleRequestOptions
|
||||
) => number;
|
||||
};
|
||||
|
||||
if (idleWindow.requestIdleCallback) {
|
||||
idleWindow.requestIdleCallback(prepare, { timeout: 5000 });
|
||||
} else {
|
||||
window.setTimeout(prepare, 2000);
|
||||
}
|
||||
}
|
||||
|
||||
export const SettingsStore = signalStore(
|
||||
{ providedIn: 'root' },
|
||||
withState<Settings>(DEFAULT_SETTINGS),
|
||||
@@ -50,6 +89,12 @@ export const SettingsStore = signalStore(
|
||||
...DEFAULT_SETTINGS,
|
||||
...(stored as Settings),
|
||||
});
|
||||
void this.sanitizeEmbeddedMpvSelection().catch((error) => {
|
||||
console.warn(
|
||||
'Failed to verify embedded MPV support while loading settings.',
|
||||
error
|
||||
);
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load settings:', error);
|
||||
@@ -65,6 +110,9 @@ export const SettingsStore = signalStore(
|
||||
await firstValueFrom(
|
||||
storage.set(STORE_KEY.Settings, completeSettings)
|
||||
);
|
||||
if (completeSettings.player === VideoPlayer.EmbeddedMpv) {
|
||||
scheduleEmbeddedMpvPrepare();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to save settings:', error);
|
||||
throw error;
|
||||
@@ -102,9 +150,47 @@ export const SettingsStore = signalStore(
|
||||
isEmbeddedPlayer() {
|
||||
return (
|
||||
store.player() === VideoPlayer.VideoJs ||
|
||||
store.player() === VideoPlayer.Html5Player
|
||||
store.player() === VideoPlayer.Html5Player ||
|
||||
store.player() === VideoPlayer.ArtPlayer ||
|
||||
store.player() === VideoPlayer.EmbeddedMpv
|
||||
);
|
||||
},
|
||||
|
||||
async sanitizeEmbeddedMpvSelection() {
|
||||
if (store.player() !== VideoPlayer.EmbeddedMpv) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
typeof window === 'undefined' ||
|
||||
!window.electron?.getEmbeddedMpvSupport
|
||||
) {
|
||||
await this.updateSettings({
|
||||
player: DEFAULT_SETTINGS.player,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const support = await window.electron.getEmbeddedMpvSupport();
|
||||
if (!support.supported) {
|
||||
await this.updateSettings({
|
||||
player: DEFAULT_SETTINGS.player,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
scheduleEmbeddedMpvPrepare();
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
'Failed to verify embedded MPV support; reverting to the default inline player.',
|
||||
error
|
||||
);
|
||||
await this.updateSettings({
|
||||
player: DEFAULT_SETTINGS.player,
|
||||
});
|
||||
}
|
||||
},
|
||||
})),
|
||||
withHooks({
|
||||
onInit(store) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export * from './lib/channel.interface';
|
||||
export * from './lib/channel.model';
|
||||
export * from './lib/embedded-mpv-session.interface';
|
||||
export * from './lib/epg-channel-metadata.model';
|
||||
export * from './lib/epg-channel-with-programs.interface';
|
||||
export * from './lib/epg-channel.model';
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
export type EmbeddedMpvSessionStatus =
|
||||
| 'idle'
|
||||
| 'loading'
|
||||
| 'playing'
|
||||
| 'paused'
|
||||
| 'error'
|
||||
| 'closed';
|
||||
|
||||
export interface EmbeddedMpvBounds {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export interface EmbeddedMpvSupport {
|
||||
supported: boolean;
|
||||
platform: string;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export interface EmbeddedMpvAudioTrack {
|
||||
id: number;
|
||||
title?: string;
|
||||
language?: string;
|
||||
selected: boolean;
|
||||
defaultTrack?: boolean;
|
||||
forced?: boolean;
|
||||
}
|
||||
|
||||
export interface EmbeddedMpvSession {
|
||||
id: string;
|
||||
title: string;
|
||||
streamUrl: string;
|
||||
status: EmbeddedMpvSessionStatus;
|
||||
positionSeconds: number;
|
||||
durationSeconds: number | null;
|
||||
volume: number;
|
||||
audioTracks: EmbeddedMpvAudioTrack[];
|
||||
selectedAudioTrackId: number | null;
|
||||
startedAt: string;
|
||||
updatedAt: string;
|
||||
error?: string;
|
||||
}
|
||||
@@ -37,6 +37,17 @@ export const SET_VLC_PLAYER_PATH = 'SET_VLC_PLAYER_PATH';
|
||||
export const CLOSE_EXTERNAL_PLAYER_SESSION = 'CLOSE_EXTERNAL_PLAYER_SESSION';
|
||||
export const EXTERNAL_PLAYER_SESSION_UPDATE =
|
||||
'EXTERNAL_PLAYER_SESSION_UPDATE';
|
||||
export const EMBEDDED_MPV_SUPPORT = 'EMBEDDED_MPV_SUPPORT';
|
||||
export const EMBEDDED_MPV_CREATE_SESSION = 'EMBEDDED_MPV_CREATE_SESSION';
|
||||
export const EMBEDDED_MPV_LOAD_PLAYBACK = 'EMBEDDED_MPV_LOAD_PLAYBACK';
|
||||
export const EMBEDDED_MPV_SET_BOUNDS = 'EMBEDDED_MPV_SET_BOUNDS';
|
||||
export const EMBEDDED_MPV_SET_PAUSED = 'EMBEDDED_MPV_SET_PAUSED';
|
||||
export const EMBEDDED_MPV_SEEK = 'EMBEDDED_MPV_SEEK';
|
||||
export const EMBEDDED_MPV_SET_VOLUME = 'EMBEDDED_MPV_SET_VOLUME';
|
||||
export const EMBEDDED_MPV_SET_AUDIO_TRACK = 'EMBEDDED_MPV_SET_AUDIO_TRACK';
|
||||
export const EMBEDDED_MPV_DISPOSE_SESSION = 'EMBEDDED_MPV_DISPOSE_SESSION';
|
||||
export const EMBEDDED_MPV_SESSION_UPDATE = 'EMBEDDED_MPV_SESSION_UPDATE';
|
||||
export const EMBEDDED_MPV_PREPARE = 'EMBEDDED_MPV_PREPARE';
|
||||
|
||||
// Xtream
|
||||
export const XTREAM_REQUEST = 'XTREAM_REQUEST';
|
||||
|
||||
@@ -8,6 +8,7 @@ import { Theme } from './theme.enum';
|
||||
export enum VideoPlayer {
|
||||
VideoJs = 'videojs',
|
||||
Html5Player = 'html5',
|
||||
EmbeddedMpv = 'embedded-mpv',
|
||||
MPV = 'mpv',
|
||||
VLC = 'vlc',
|
||||
ArtPlayer = 'artplayer',
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export * from './lib/art-player/art-player.component';
|
||||
export * from './lib/audio-player/audio-player.component';
|
||||
export * from './lib/embedded-mpv-player/embedded-mpv-player.component';
|
||||
export * from './lib/external-player-info-dialog/external-player-info-dialog.component';
|
||||
export * from './lib/html-video-player/html-video-player.component';
|
||||
export * from './lib/portal-inline-player/portal-inline-player.component';
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
<div
|
||||
#playerRoot
|
||||
class="embedded-mpv-player"
|
||||
[class.embedded-mpv-player--fullscreen]="isFullscreen()"
|
||||
[class.embedded-mpv-player--controls-enabled]="
|
||||
showControls() && isSupported()
|
||||
"
|
||||
[class.embedded-mpv-player--controls-visible]="controlsAreVisible()"
|
||||
[class.embedded-mpv-player--cursor-hidden]="hideCursor()"
|
||||
(pointerenter)="onPlayerInteraction()"
|
||||
(pointermove)="onPlayerInteraction()"
|
||||
(click)="onPlayerInteraction()"
|
||||
(focusin)="onPlayerInteraction()"
|
||||
>
|
||||
<div #viewport class="embedded-mpv-player__viewport">
|
||||
@if (statusLabel()) {
|
||||
<div class="embedded-mpv-player__status">
|
||||
{{ statusLabel() }}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (showControls() && isSupported()) {
|
||||
<div
|
||||
class="embedded-mpv-player__controls"
|
||||
[class.embedded-mpv-player__controls--visible]="controlsAreVisible()"
|
||||
[class.embedded-mpv-player__controls--panel-open]="
|
||||
volumePopoverOpen() || audioMenuOpen()
|
||||
"
|
||||
>
|
||||
@if (volumePopoverOpen()) {
|
||||
<div
|
||||
class="embedded-mpv-player__mode-panel embedded-mpv-player__mode-panel--volume"
|
||||
>
|
||||
<button
|
||||
mat-icon-button
|
||||
type="button"
|
||||
class="embedded-mpv-player__control-button embedded-mpv-player__back-button"
|
||||
(click)="showDefaultControls()"
|
||||
aria-label="Back to player controls"
|
||||
matTooltip="Back to controls"
|
||||
matTooltipPosition="right"
|
||||
>
|
||||
<mat-icon>arrow_back</mat-icon>
|
||||
</button>
|
||||
|
||||
<div
|
||||
class="embedded-mpv-player__control-panel embedded-mpv-player__volume-panel"
|
||||
role="dialog"
|
||||
aria-label="Volume"
|
||||
>
|
||||
<mat-icon>{{ volumeIcon() }}</mat-icon>
|
||||
<input
|
||||
class="embedded-mpv-player__slider"
|
||||
type="range"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.05"
|
||||
[value]="volume()"
|
||||
[attr.aria-label]="volumeLabel()"
|
||||
(input)="onVolumeInput($event)"
|
||||
/>
|
||||
<span class="embedded-mpv-player__volume-value">
|
||||
{{ volumeLabel() }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
} @else if (audioMenuOpen()) {
|
||||
<div
|
||||
class="embedded-mpv-player__mode-panel embedded-mpv-player__mode-panel--audio"
|
||||
>
|
||||
<button
|
||||
mat-icon-button
|
||||
type="button"
|
||||
class="embedded-mpv-player__control-button embedded-mpv-player__back-button"
|
||||
(click)="showDefaultControls()"
|
||||
aria-label="Back to player controls"
|
||||
matTooltip="Back to controls"
|
||||
matTooltipPosition="right"
|
||||
>
|
||||
<mat-icon>arrow_back</mat-icon>
|
||||
</button>
|
||||
|
||||
<div
|
||||
class="embedded-mpv-player__control-panel embedded-mpv-player__audio-menu"
|
||||
role="menu"
|
||||
aria-label="Audio tracks"
|
||||
>
|
||||
<div class="embedded-mpv-player__menu-title">
|
||||
Audio tracks
|
||||
</div>
|
||||
<div class="embedded-mpv-player__audio-track-list">
|
||||
@for (
|
||||
track of audioTracks();
|
||||
track track.id;
|
||||
let index = $index
|
||||
) {
|
||||
<button
|
||||
type="button"
|
||||
role="menuitemradio"
|
||||
[attr.aria-checked]="track.selected"
|
||||
class="embedded-mpv-player__audio-track"
|
||||
[class.embedded-mpv-player__audio-track--selected]="
|
||||
track.selected
|
||||
"
|
||||
(click)="selectAudioTrack(track.id)"
|
||||
>
|
||||
<span>{{ trackLabel(track, index) }}</span>
|
||||
@if (track.selected) {
|
||||
<mat-icon>check</mat-icon>
|
||||
}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="embedded-mpv-player__transport">
|
||||
<button
|
||||
mat-icon-button
|
||||
type="button"
|
||||
class="embedded-mpv-player__control-button"
|
||||
[disabled]="isLoading()"
|
||||
(click)="togglePaused()"
|
||||
[attr.aria-label]="isPaused() ? 'Play' : 'Pause'"
|
||||
[matTooltip]="isPaused() ? 'Play' : 'Pause'"
|
||||
matTooltipPosition="right"
|
||||
>
|
||||
<mat-icon>{{
|
||||
isPaused() ? 'play_arrow' : 'pause'
|
||||
}}</mat-icon>
|
||||
</button>
|
||||
|
||||
<button
|
||||
mat-icon-button
|
||||
type="button"
|
||||
class="embedded-mpv-player__control-button"
|
||||
[disabled]="!canSeek()"
|
||||
(click)="seekBy(-10)"
|
||||
aria-label="Back 10 seconds"
|
||||
matTooltip="Back 10 seconds"
|
||||
matTooltipPosition="right"
|
||||
>
|
||||
<mat-icon>replay_10</mat-icon>
|
||||
</button>
|
||||
|
||||
<button
|
||||
mat-icon-button
|
||||
type="button"
|
||||
class="embedded-mpv-player__control-button"
|
||||
[disabled]="!canSeek()"
|
||||
(click)="seekBy(10)"
|
||||
aria-label="Forward 10 seconds"
|
||||
matTooltip="Forward 10 seconds"
|
||||
matTooltipPosition="right"
|
||||
>
|
||||
<mat-icon>forward_10</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="embedded-mpv-player__timeline">
|
||||
<input
|
||||
class="embedded-mpv-player__slider"
|
||||
type="range"
|
||||
min="0"
|
||||
[max]="session()?.durationSeconds ?? 0"
|
||||
[value]="timelineValue()"
|
||||
[disabled]="!canSeek()"
|
||||
(input)="onTimelineInput($event)"
|
||||
/>
|
||||
<div class="embedded-mpv-player__time">
|
||||
<span>{{
|
||||
formatTime(session()?.positionSeconds)
|
||||
}}</span>
|
||||
<span>{{
|
||||
canSeek()
|
||||
? formatTime(session()?.durationSeconds)
|
||||
: 'LIVE'
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="embedded-mpv-player__actions">
|
||||
<div class="embedded-mpv-player__popover-anchor">
|
||||
<button
|
||||
mat-icon-button
|
||||
type="button"
|
||||
class="embedded-mpv-player__control-button"
|
||||
(click)="toggleVolumePopover()"
|
||||
[attr.aria-label]="volumeLabel()"
|
||||
[matTooltip]="volumeLabel()"
|
||||
matTooltipPosition="left"
|
||||
>
|
||||
<mat-icon>{{ volumeIcon() }}</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if (hasAudioTracks()) {
|
||||
<div class="embedded-mpv-player__popover-anchor">
|
||||
<button
|
||||
mat-icon-button
|
||||
type="button"
|
||||
class="embedded-mpv-player__control-button"
|
||||
(click)="toggleAudioMenu()"
|
||||
aria-label="Audio tracks"
|
||||
matTooltip="Audio tracks"
|
||||
matTooltipPosition="left"
|
||||
>
|
||||
<mat-icon>graphic_eq</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
<button
|
||||
mat-icon-button
|
||||
type="button"
|
||||
class="embedded-mpv-player__control-button"
|
||||
[disabled]="!canFullscreen()"
|
||||
(click)="toggleFullscreen()"
|
||||
[attr.aria-label]="fullscreenLabel()"
|
||||
[matTooltip]="fullscreenLabel()"
|
||||
matTooltipPosition="left"
|
||||
>
|
||||
<mat-icon>{{
|
||||
isFullscreen() ? 'fullscreen_exit' : 'fullscreen'
|
||||
}}</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@@ -0,0 +1,292 @@
|
||||
:host {
|
||||
display: block;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.embedded-mpv-player {
|
||||
--embedded-mpv-accent: #7dd3fc;
|
||||
--embedded-mpv-glass: rgba(6, 10, 18, 0.78);
|
||||
--embedded-mpv-border: rgba(255, 255, 255, 0.12);
|
||||
--embedded-mpv-controls-height: 96px;
|
||||
|
||||
position: relative;
|
||||
display: block;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(10, 14, 24, 0.08), rgba(10, 14, 24, 0.24)),
|
||||
#02050a;
|
||||
border-radius: inherit;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.embedded-mpv-player--cursor-hidden {
|
||||
cursor: none;
|
||||
}
|
||||
|
||||
.embedded-mpv-player:fullscreen,
|
||||
.embedded-mpv-player--fullscreen {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.embedded-mpv-player__viewport {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
min-height: 0;
|
||||
background:
|
||||
radial-gradient(circle at top, rgba(96, 125, 139, 0.24), transparent 58%),
|
||||
linear-gradient(180deg, rgba(6, 8, 13, 0.88), rgba(2, 4, 8, 0.98));
|
||||
}
|
||||
|
||||
.embedded-mpv-player--controls-enabled .embedded-mpv-player__viewport {
|
||||
bottom: var(--embedded-mpv-controls-height);
|
||||
}
|
||||
|
||||
.embedded-mpv-player__status {
|
||||
position: absolute;
|
||||
inset: auto 16px 16px 16px;
|
||||
padding: 10px 12px;
|
||||
color: rgba(245, 247, 250, 0.94);
|
||||
background: rgba(7, 10, 16, 0.72);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 12px;
|
||||
backdrop-filter: blur(14px);
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.4;
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.embedded-mpv-player__controls {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
z-index: 2;
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
gap: 18px;
|
||||
align-items: center;
|
||||
align-content: center;
|
||||
height: var(--embedded-mpv-controls-height);
|
||||
padding: 12px 18px 14px;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transform: translateY(14px);
|
||||
overflow: hidden;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(2, 5, 10, 0.28), rgba(5, 9, 15, 0.88) 30%),
|
||||
linear-gradient(180deg, rgba(12, 18, 29, 0.82), rgba(5, 9, 15, 0.94));
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.06);
|
||||
box-shadow: 0 -28px 70px rgba(0, 0, 0, 0.46);
|
||||
backdrop-filter: blur(18px);
|
||||
transition:
|
||||
opacity 160ms ease,
|
||||
transform 180ms ease;
|
||||
}
|
||||
|
||||
.embedded-mpv-player__controls--visible {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.embedded-mpv-player__controls--panel-open {
|
||||
background:
|
||||
linear-gradient(180deg, rgba(2, 5, 10, 0.42), rgba(5, 9, 15, 0.92) 28%),
|
||||
linear-gradient(180deg, rgba(12, 18, 29, 0.9), rgba(5, 9, 15, 0.98));
|
||||
}
|
||||
|
||||
.embedded-mpv-player__transport {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.embedded-mpv-player__timeline {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.embedded-mpv-player__time {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
color: rgba(226, 232, 240, 0.84);
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.embedded-mpv-player__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.embedded-mpv-player__popover-anchor {
|
||||
position: relative;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.embedded-mpv-player__control-button {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.embedded-mpv-player__slider {
|
||||
width: 100%;
|
||||
accent-color: var(--embedded-mpv-accent);
|
||||
}
|
||||
|
||||
.embedded-mpv-player__control-panel {
|
||||
min-width: 0;
|
||||
color: rgba(240, 247, 255, 0.94);
|
||||
background:
|
||||
linear-gradient(180deg, rgba(17, 26, 39, 0.9), rgba(5, 9, 16, 0.94));
|
||||
border: 1px solid var(--embedded-mpv-border);
|
||||
border-radius: 18px;
|
||||
box-shadow:
|
||||
0 18px 50px rgba(0, 0, 0, 0.42),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.08);
|
||||
backdrop-filter: blur(18px);
|
||||
}
|
||||
|
||||
.embedded-mpv-player__mode-panel {
|
||||
display: grid;
|
||||
grid-column: 1 / -1;
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.embedded-mpv-player__back-button {
|
||||
color: rgba(241, 245, 249, 0.88);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.embedded-mpv-player__volume-panel {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(140px, 1fr) auto;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.embedded-mpv-player__volume-panel mat-icon {
|
||||
color: var(--embedded-mpv-accent);
|
||||
font-size: 20px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.embedded-mpv-player__volume-value {
|
||||
color: rgba(226, 232, 240, 0.82);
|
||||
font-size: 0.76rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.embedded-mpv-player__audio-menu {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
padding: 8px 10px;
|
||||
}
|
||||
|
||||
.embedded-mpv-player__menu-title {
|
||||
padding: 0 2px;
|
||||
color: rgba(226, 232, 240, 0.7);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.embedded-mpv-player__audio-track-list {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
.embedded-mpv-player__audio-track {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
min-width: 154px;
|
||||
max-width: 240px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 8px 10px;
|
||||
color: rgba(241, 245, 249, 0.88);
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-radius: 12px;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.embedded-mpv-player__audio-track span {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.embedded-mpv-player__audio-track:hover,
|
||||
.embedded-mpv-player__audio-track--selected {
|
||||
color: #f8fafc;
|
||||
background: rgba(125, 211, 252, 0.16);
|
||||
}
|
||||
|
||||
.embedded-mpv-player__audio-track mat-icon {
|
||||
color: var(--embedded-mpv-accent);
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.embedded-mpv-player :is(button[mat-icon-button]) {
|
||||
color: rgba(241, 245, 249, 0.9);
|
||||
}
|
||||
|
||||
.embedded-mpv-player :is(button[mat-icon-button]:hover) {
|
||||
color: #ffffff;
|
||||
background: rgba(125, 211, 252, 0.14);
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.embedded-mpv-player {
|
||||
--embedded-mpv-controls-height: 148px;
|
||||
}
|
||||
|
||||
.embedded-mpv-player__controls {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.embedded-mpv-player__actions {
|
||||
justify-content: flex-end;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.embedded-mpv-player__volume-panel {
|
||||
grid-template-columns: auto 1fr;
|
||||
}
|
||||
|
||||
.embedded-mpv-player__volume-value {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.embedded-mpv-player__mode-panel--audio
|
||||
.embedded-mpv-player__audio-menu {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 6px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,686 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
ElementRef,
|
||||
EventEmitter,
|
||||
OnDestroy,
|
||||
Output,
|
||||
computed,
|
||||
effect,
|
||||
input,
|
||||
signal,
|
||||
untracked,
|
||||
viewChild,
|
||||
} from '@angular/core';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
import {
|
||||
EmbeddedMpvAudioTrack,
|
||||
EmbeddedMpvBounds,
|
||||
EmbeddedMpvSession,
|
||||
EmbeddedMpvSupport,
|
||||
ResolvedPortalPlayback,
|
||||
} from 'shared-interfaces';
|
||||
|
||||
@Component({
|
||||
selector: 'app-embedded-mpv-player',
|
||||
templateUrl: './embedded-mpv-player.component.html',
|
||||
styleUrl: './embedded-mpv-player.component.scss',
|
||||
imports: [MatButtonModule, MatIconModule, MatTooltipModule],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
host: {
|
||||
class: 'embedded-mpv-player-host',
|
||||
},
|
||||
})
|
||||
export class EmbeddedMpvPlayerComponent implements OnDestroy {
|
||||
readonly playback = input.required<ResolvedPortalPlayback>();
|
||||
readonly showControls = input(true);
|
||||
|
||||
@Output() timeUpdate = new EventEmitter<{
|
||||
currentTime: number;
|
||||
duration: number;
|
||||
}>();
|
||||
|
||||
readonly viewport = viewChild<ElementRef<HTMLDivElement>>('viewport');
|
||||
readonly playerRoot = viewChild<ElementRef<HTMLDivElement>>('playerRoot');
|
||||
readonly support = signal<EmbeddedMpvSupport | null>(null);
|
||||
readonly session = signal<EmbeddedMpvSession | null>(null);
|
||||
readonly sessionId = signal<string | null>(null);
|
||||
readonly volume = signal(this.readStoredVolume());
|
||||
readonly isFullscreen = signal(false);
|
||||
readonly controlsVisible = signal(true);
|
||||
readonly volumePopoverOpen = signal(false);
|
||||
readonly audioMenuOpen = signal(false);
|
||||
|
||||
readonly isSupported = computed(() => this.support()?.supported ?? false);
|
||||
readonly canFullscreen = computed(
|
||||
() =>
|
||||
typeof document !== 'undefined' &&
|
||||
Boolean(this.playerRoot()?.nativeElement.requestFullscreen) &&
|
||||
Boolean(document.exitFullscreen)
|
||||
);
|
||||
readonly isLoading = computed(
|
||||
() =>
|
||||
!this.support() ||
|
||||
this.session()?.status === 'loading' ||
|
||||
(!this.session() && this.isSupported())
|
||||
);
|
||||
readonly isPaused = computed(
|
||||
() => this.session()?.status === 'paused' || this.session()?.status === 'idle'
|
||||
);
|
||||
readonly isPlaying = computed(() => this.session()?.status === 'playing');
|
||||
readonly canSeek = computed(
|
||||
() => (this.session()?.durationSeconds ?? 0) > 0
|
||||
);
|
||||
readonly statusLabel = computed(() => {
|
||||
const session = this.session();
|
||||
if (session?.status === 'error') {
|
||||
return session.error ?? 'Embedded MPV playback failed.';
|
||||
}
|
||||
|
||||
if (!this.support()) {
|
||||
return 'Checking embedded MPV support...';
|
||||
}
|
||||
|
||||
if (!this.isSupported()) {
|
||||
return (
|
||||
this.support()?.reason ??
|
||||
'Embedded MPV is not available in this environment.'
|
||||
);
|
||||
}
|
||||
|
||||
if (!session || session.status === 'loading') {
|
||||
return 'Loading stream in MPV...';
|
||||
}
|
||||
|
||||
return '';
|
||||
});
|
||||
readonly fullscreenLabel = computed(() =>
|
||||
this.isFullscreen() ? 'Exit fullscreen' : 'Enter fullscreen'
|
||||
);
|
||||
readonly audioTracks = computed(
|
||||
() => this.session()?.audioTracks ?? []
|
||||
);
|
||||
readonly hasAudioTracks = computed(() => this.audioTracks().length > 1);
|
||||
readonly selectedAudioTrack = computed(
|
||||
() => this.audioTracks().find((track) => track.selected) ?? null
|
||||
);
|
||||
readonly controlsAreVisible = computed(
|
||||
() =>
|
||||
this.showControls() &&
|
||||
this.isSupported() &&
|
||||
(this.controlsVisible() ||
|
||||
this.isLoading() ||
|
||||
this.isPaused() ||
|
||||
this.volumePopoverOpen() ||
|
||||
this.audioMenuOpen() ||
|
||||
Boolean(this.statusLabel()))
|
||||
);
|
||||
readonly hideCursor = computed(
|
||||
() =>
|
||||
this.isFullscreen() &&
|
||||
this.isPlaying() &&
|
||||
!this.controlsAreVisible()
|
||||
);
|
||||
readonly volumeIcon = computed(() => {
|
||||
const value = this.volume();
|
||||
if (value <= 0) {
|
||||
return 'volume_off';
|
||||
}
|
||||
|
||||
return value < 0.5 ? 'volume_down' : 'volume_up';
|
||||
});
|
||||
readonly volumeLabel = computed(
|
||||
() => `Volume ${Math.round(this.volume() * 100)}%`
|
||||
);
|
||||
readonly timelineValue = computed(() =>
|
||||
Math.max(0, this.session()?.positionSeconds ?? 0)
|
||||
);
|
||||
|
||||
private readonly unsubscribeSessionUpdate =
|
||||
window.electron?.onEmbeddedMpvSessionUpdate?.((session) => {
|
||||
if (session.id !== this.sessionId()) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.session.set(session);
|
||||
this.volume.set(session.volume);
|
||||
this.timeUpdate.emit({
|
||||
currentTime: session.positionSeconds,
|
||||
duration: session.durationSeconds ?? 0,
|
||||
});
|
||||
this.scheduleControlsHide();
|
||||
});
|
||||
private boundsAnimationFrame: number | null = null;
|
||||
private controlsHideTimer: number | null = null;
|
||||
private activeBoundsSync: (() => void) | null = null;
|
||||
private readonly onDocumentPointerDown = (event: PointerEvent) => {
|
||||
const playerRoot = this.playerRoot()?.nativeElement;
|
||||
const path = event.composedPath();
|
||||
if (!playerRoot || path.includes(playerRoot)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.closePopovers();
|
||||
};
|
||||
private readonly onDocumentPointerMove = (event: PointerEvent) => {
|
||||
const playerRoot = this.playerRoot()?.nativeElement;
|
||||
if (
|
||||
playerRoot &&
|
||||
!event.composedPath().includes(playerRoot) &&
|
||||
this.isPointerInsidePlayer(event)
|
||||
) {
|
||||
this.revealControls();
|
||||
}
|
||||
};
|
||||
private readonly onDocumentKeydown = (event: KeyboardEvent) => {
|
||||
if (event.key !== 'Escape') {
|
||||
return;
|
||||
}
|
||||
|
||||
this.closePopovers();
|
||||
};
|
||||
private readonly onFullscreenChange = () => {
|
||||
const playerRoot = this.playerRoot()?.nativeElement;
|
||||
this.isFullscreen.set(
|
||||
Boolean(playerRoot && document.fullscreenElement === playerRoot)
|
||||
);
|
||||
this.revealControls();
|
||||
this.activeBoundsSync?.();
|
||||
};
|
||||
|
||||
constructor() {
|
||||
if (typeof document !== 'undefined') {
|
||||
document.addEventListener(
|
||||
'fullscreenchange',
|
||||
this.onFullscreenChange
|
||||
);
|
||||
document.addEventListener(
|
||||
'pointerdown',
|
||||
this.onDocumentPointerDown
|
||||
);
|
||||
document.addEventListener(
|
||||
'pointermove',
|
||||
this.onDocumentPointerMove,
|
||||
{ passive: true }
|
||||
);
|
||||
document.addEventListener('keydown', this.onDocumentKeydown);
|
||||
}
|
||||
|
||||
if (window.electron?.getEmbeddedMpvSupport) {
|
||||
void this.loadSupport();
|
||||
} else {
|
||||
this.support.set({
|
||||
supported: false,
|
||||
platform: typeof window === 'undefined' ? 'web' : 'unknown',
|
||||
reason: 'Embedded MPV requires the Electron desktop build.',
|
||||
});
|
||||
}
|
||||
|
||||
effect((onCleanup) => {
|
||||
const viewport = this.viewport();
|
||||
const playback = this.playback();
|
||||
const support = this.support();
|
||||
|
||||
if (
|
||||
!viewport ||
|
||||
!playback.streamUrl ||
|
||||
!support?.supported ||
|
||||
!window.electron
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
let disposed = false;
|
||||
let activeSessionId: string | null = null;
|
||||
const host = viewport.nativeElement;
|
||||
const initialVolume = untracked(() => this.volume());
|
||||
|
||||
const syncBounds = () => {
|
||||
if (!activeSessionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
void window.electron
|
||||
.setEmbeddedMpvBounds(
|
||||
activeSessionId,
|
||||
this.measureBounds(host)
|
||||
)
|
||||
.catch(() => undefined);
|
||||
};
|
||||
|
||||
const scheduleBoundsSync = () => {
|
||||
if (this.boundsAnimationFrame !== null) {
|
||||
cancelAnimationFrame(this.boundsAnimationFrame);
|
||||
}
|
||||
|
||||
this.boundsAnimationFrame = requestAnimationFrame(() => {
|
||||
this.boundsAnimationFrame = null;
|
||||
syncBounds();
|
||||
});
|
||||
};
|
||||
|
||||
this.activeBoundsSync = scheduleBoundsSync;
|
||||
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
scheduleBoundsSync();
|
||||
});
|
||||
resizeObserver.observe(host);
|
||||
|
||||
window.addEventListener('resize', scheduleBoundsSync);
|
||||
window.addEventListener('scroll', scheduleBoundsSync, true);
|
||||
|
||||
const createSession = async () => {
|
||||
this.session.set(
|
||||
this.createLoadingSession(playback, initialVolume)
|
||||
);
|
||||
await this.waitForStartupPaint();
|
||||
if (disposed) {
|
||||
return;
|
||||
}
|
||||
|
||||
const prepared = await window.electron!.prepareEmbeddedMpv?.();
|
||||
if (disposed) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (prepared && !prepared.supported) {
|
||||
throw new Error(
|
||||
prepared.reason ??
|
||||
'Embedded MPV is not available in this environment.'
|
||||
);
|
||||
}
|
||||
|
||||
const createdSession =
|
||||
await window.electron!.createEmbeddedMpvSession(
|
||||
this.measureBounds(host),
|
||||
playback.title,
|
||||
initialVolume
|
||||
);
|
||||
|
||||
if (disposed) {
|
||||
await window.electron!.disposeEmbeddedMpvSession(
|
||||
createdSession.id
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
activeSessionId = createdSession.id;
|
||||
this.sessionId.set(createdSession.id);
|
||||
this.session.set(createdSession);
|
||||
|
||||
await window.electron!.loadEmbeddedMpvPlayback(
|
||||
createdSession.id,
|
||||
playback
|
||||
);
|
||||
scheduleBoundsSync();
|
||||
};
|
||||
|
||||
void createSession().catch((error) => {
|
||||
const startedAt = new Date().toISOString();
|
||||
this.sessionId.set(null);
|
||||
this.session.set({
|
||||
id: 'embedded-mpv-error',
|
||||
title: playback.title,
|
||||
streamUrl: playback.streamUrl,
|
||||
status: 'error',
|
||||
positionSeconds: 0,
|
||||
durationSeconds: null,
|
||||
volume: initialVolume,
|
||||
audioTracks: [],
|
||||
selectedAudioTrackId: null,
|
||||
startedAt,
|
||||
updatedAt: startedAt,
|
||||
error:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: String(error),
|
||||
});
|
||||
});
|
||||
|
||||
onCleanup(() => {
|
||||
disposed = true;
|
||||
resizeObserver.disconnect();
|
||||
window.removeEventListener('resize', scheduleBoundsSync);
|
||||
window.removeEventListener('scroll', scheduleBoundsSync, true);
|
||||
|
||||
if (this.activeBoundsSync === scheduleBoundsSync) {
|
||||
this.activeBoundsSync = null;
|
||||
}
|
||||
|
||||
if (this.boundsAnimationFrame !== null) {
|
||||
cancelAnimationFrame(this.boundsAnimationFrame);
|
||||
this.boundsAnimationFrame = null;
|
||||
}
|
||||
|
||||
const sessionId = activeSessionId;
|
||||
activeSessionId = null;
|
||||
this.sessionId.set(null);
|
||||
this.session.set(null);
|
||||
|
||||
if (sessionId) {
|
||||
void window.electron?.disposeEmbeddedMpvSession(sessionId);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.unsubscribeSessionUpdate?.();
|
||||
if (typeof document !== 'undefined') {
|
||||
document.removeEventListener(
|
||||
'fullscreenchange',
|
||||
this.onFullscreenChange
|
||||
);
|
||||
document.removeEventListener(
|
||||
'pointerdown',
|
||||
this.onDocumentPointerDown
|
||||
);
|
||||
document.removeEventListener(
|
||||
'pointermove',
|
||||
this.onDocumentPointerMove
|
||||
);
|
||||
document.removeEventListener('keydown', this.onDocumentKeydown);
|
||||
}
|
||||
|
||||
if (this.boundsAnimationFrame !== null) {
|
||||
cancelAnimationFrame(this.boundsAnimationFrame);
|
||||
this.boundsAnimationFrame = null;
|
||||
}
|
||||
|
||||
this.clearControlsHideTimer();
|
||||
}
|
||||
|
||||
onPlayerInteraction(): void {
|
||||
this.revealControls();
|
||||
}
|
||||
|
||||
async togglePaused(): Promise<void> {
|
||||
this.revealControls();
|
||||
const session = this.session();
|
||||
if (!session?.id || !window.electron?.setEmbeddedMpvPaused) {
|
||||
return;
|
||||
}
|
||||
|
||||
const updatedSession = await window.electron.setEmbeddedMpvPaused(
|
||||
session.id,
|
||||
session.status !== 'paused'
|
||||
);
|
||||
if (updatedSession) {
|
||||
this.session.set(updatedSession);
|
||||
}
|
||||
}
|
||||
|
||||
async toggleFullscreen(): Promise<void> {
|
||||
this.revealControls();
|
||||
const playerRoot = this.playerRoot()?.nativeElement;
|
||||
if (!playerRoot || !this.canFullscreen()) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (document.fullscreenElement === playerRoot) {
|
||||
await document.exitFullscreen();
|
||||
} else {
|
||||
await playerRoot.requestFullscreen();
|
||||
}
|
||||
} catch {
|
||||
return;
|
||||
} finally {
|
||||
this.activeBoundsSync?.();
|
||||
}
|
||||
}
|
||||
|
||||
async seekBy(deltaSeconds: number): Promise<void> {
|
||||
this.revealControls();
|
||||
const session = this.session();
|
||||
if (!session?.id || !window.electron?.seekEmbeddedMpv) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextPosition = Math.max(0, session.positionSeconds + deltaSeconds);
|
||||
const updatedSession = await window.electron.seekEmbeddedMpv(
|
||||
session.id,
|
||||
nextPosition
|
||||
);
|
||||
if (updatedSession) {
|
||||
this.session.set(updatedSession);
|
||||
}
|
||||
}
|
||||
|
||||
async onTimelineInput(event: Event): Promise<void> {
|
||||
this.revealControls();
|
||||
const session = this.session();
|
||||
if (!session?.id || !window.electron?.seekEmbeddedMpv) {
|
||||
return;
|
||||
}
|
||||
|
||||
const target = Number((event.target as HTMLInputElement).value);
|
||||
const updatedSession = await window.electron.seekEmbeddedMpv(
|
||||
session.id,
|
||||
target
|
||||
);
|
||||
if (updatedSession) {
|
||||
this.session.set(updatedSession);
|
||||
}
|
||||
}
|
||||
|
||||
toggleVolumePopover(): void {
|
||||
this.volumePopoverOpen.update((open) => !open);
|
||||
if (this.volumePopoverOpen()) {
|
||||
this.audioMenuOpen.set(false);
|
||||
}
|
||||
this.revealControls();
|
||||
this.activeBoundsSync?.();
|
||||
}
|
||||
|
||||
toggleAudioMenu(): void {
|
||||
this.audioMenuOpen.update((open) => !open);
|
||||
if (this.audioMenuOpen()) {
|
||||
this.volumePopoverOpen.set(false);
|
||||
}
|
||||
this.revealControls();
|
||||
this.activeBoundsSync?.();
|
||||
}
|
||||
|
||||
showDefaultControls(): void {
|
||||
this.volumePopoverOpen.set(false);
|
||||
this.audioMenuOpen.set(false);
|
||||
this.revealControls();
|
||||
this.activeBoundsSync?.();
|
||||
}
|
||||
|
||||
onVolumeInput(event: Event): void {
|
||||
const nextVolume = Number((event.target as HTMLInputElement).value);
|
||||
this.volume.set(nextVolume);
|
||||
localStorage.setItem('volume', String(nextVolume));
|
||||
this.revealControls(false);
|
||||
|
||||
const session = this.session();
|
||||
if (!session?.id || !window.electron?.setEmbeddedMpvVolume) {
|
||||
return;
|
||||
}
|
||||
|
||||
void window.electron
|
||||
.setEmbeddedMpvVolume(session.id, nextVolume)
|
||||
.then((updatedSession) => {
|
||||
if (updatedSession) {
|
||||
this.session.set(updatedSession);
|
||||
}
|
||||
})
|
||||
.catch(() => undefined);
|
||||
}
|
||||
|
||||
async selectAudioTrack(trackId: number): Promise<void> {
|
||||
const session = this.session();
|
||||
if (!session?.id || !window.electron?.setEmbeddedMpvAudioTrack) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.revealControls(false);
|
||||
const updatedSession = await window.electron.setEmbeddedMpvAudioTrack(
|
||||
session.id,
|
||||
trackId
|
||||
);
|
||||
if (updatedSession) {
|
||||
this.session.set(updatedSession);
|
||||
}
|
||||
this.audioMenuOpen.set(false);
|
||||
this.scheduleControlsHide();
|
||||
}
|
||||
|
||||
formatTime(value: number | null | undefined): string {
|
||||
const safeValue = Math.max(0, Math.floor(value ?? 0));
|
||||
const hours = Math.floor(safeValue / 3600);
|
||||
const minutes = Math.floor((safeValue % 3600) / 60);
|
||||
const seconds = safeValue % 60;
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
return `${minutes}:${String(seconds).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
trackLabel(track: EmbeddedMpvAudioTrack, index: number): string {
|
||||
const label = track.title || track.language || `Audio ${index + 1}`;
|
||||
return track.defaultTrack ? `${label} · Default` : label;
|
||||
}
|
||||
|
||||
private closePopovers(): void {
|
||||
if (!this.volumePopoverOpen() && !this.audioMenuOpen()) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.volumePopoverOpen.set(false);
|
||||
this.audioMenuOpen.set(false);
|
||||
this.activeBoundsSync?.();
|
||||
this.scheduleControlsHide();
|
||||
}
|
||||
|
||||
private revealControls(scheduleHide = true): void {
|
||||
this.controlsVisible.set(true);
|
||||
if (scheduleHide) {
|
||||
this.clearControlsHideTimer();
|
||||
this.scheduleControlsHide();
|
||||
}
|
||||
}
|
||||
|
||||
private scheduleControlsHide(): void {
|
||||
if (
|
||||
!this.isPlaying() ||
|
||||
this.volumePopoverOpen() ||
|
||||
this.audioMenuOpen() ||
|
||||
Boolean(this.statusLabel())
|
||||
) {
|
||||
this.clearControlsHideTimer();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.controlsVisible() || this.controlsHideTimer !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.controlsHideTimer = window.setTimeout(() => {
|
||||
if (
|
||||
this.isPlaying() &&
|
||||
!this.volumePopoverOpen() &&
|
||||
!this.audioMenuOpen() &&
|
||||
!this.statusLabel()
|
||||
) {
|
||||
this.controlsVisible.set(false);
|
||||
}
|
||||
}, 2500);
|
||||
}
|
||||
|
||||
private clearControlsHideTimer(): void {
|
||||
if (this.controlsHideTimer === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.clearTimeout(this.controlsHideTimer);
|
||||
this.controlsHideTimer = null;
|
||||
}
|
||||
|
||||
private isPointerInsidePlayer(event: PointerEvent): boolean {
|
||||
const playerRoot = this.playerRoot()?.nativeElement;
|
||||
if (!playerRoot) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const rect = playerRoot.getBoundingClientRect();
|
||||
return (
|
||||
event.clientX >= rect.left &&
|
||||
event.clientX <= rect.right &&
|
||||
event.clientY >= rect.top &&
|
||||
event.clientY <= rect.bottom
|
||||
);
|
||||
}
|
||||
|
||||
private async loadSupport(): Promise<void> {
|
||||
try {
|
||||
this.support.set(await window.electron!.getEmbeddedMpvSupport());
|
||||
} catch (error) {
|
||||
this.support.set({
|
||||
supported: false,
|
||||
platform: window.electron?.platform ?? 'unknown',
|
||||
reason:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private createLoadingSession(
|
||||
playback: ResolvedPortalPlayback,
|
||||
volume: number
|
||||
): EmbeddedMpvSession {
|
||||
const now = new Date().toISOString();
|
||||
return {
|
||||
id: 'embedded-mpv-starting',
|
||||
title: playback.title,
|
||||
streamUrl: playback.streamUrl,
|
||||
status: 'loading',
|
||||
positionSeconds: 0,
|
||||
durationSeconds: null,
|
||||
volume,
|
||||
audioTracks: [],
|
||||
selectedAudioTrackId: null,
|
||||
startedAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
}
|
||||
|
||||
private waitForStartupPaint(): Promise<void> {
|
||||
if (typeof requestAnimationFrame !== 'function') {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => resolve());
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private measureBounds(host: HTMLDivElement): EmbeddedMpvBounds {
|
||||
const rect = host.getBoundingClientRect();
|
||||
return {
|
||||
x: Math.round(rect.left),
|
||||
y: Math.round(rect.top),
|
||||
width: Math.max(1, Math.round(rect.width)),
|
||||
height: Math.max(1, Math.round(rect.height)),
|
||||
};
|
||||
}
|
||||
|
||||
private readStoredVolume(): number {
|
||||
const rawValue = Number(localStorage.getItem('volume') ?? '1');
|
||||
if (Number.isNaN(rawValue)) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
return Math.max(0, Math.min(1, rawValue));
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,8 @@
|
||||
<div class="player-shell__viewport">
|
||||
<app-web-player-view
|
||||
[streamUrl]="streamUrl()"
|
||||
[title]="title()"
|
||||
[playback]="playback()"
|
||||
[startTime]="startTime()"
|
||||
(timeUpdate)="onTimeUpdate($event)"
|
||||
/>
|
||||
|
||||
@@ -18,6 +18,11 @@
|
||||
[startTime]="startTime()"
|
||||
(timeUpdate)="timeUpdate.emit($event)"
|
||||
/>
|
||||
} @else if (player === 'embedded-mpv') {
|
||||
<app-embedded-mpv-player
|
||||
[playback]="resolvedPlayback()"
|
||||
(timeUpdate)="timeUpdate.emit($event)"
|
||||
/>
|
||||
} @else {
|
||||
<app-vjs-player
|
||||
[options]="vjsOptions"
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
}
|
||||
|
||||
app-art-player,
|
||||
app-embedded-mpv-player,
|
||||
app-vjs-player,
|
||||
app-html-video-player {
|
||||
display: block;
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
Output,
|
||||
Signal,
|
||||
ViewEncapsulation,
|
||||
computed,
|
||||
effect,
|
||||
inject,
|
||||
input,
|
||||
@@ -11,8 +12,14 @@ import {
|
||||
import { toSignal } from '@angular/core/rxjs-interop';
|
||||
import { StorageMap } from '@ngx-pwa/local-storage';
|
||||
import { getExtensionFromUrl } from 'm3u-utils';
|
||||
import { Settings, STORE_KEY, VideoPlayer } from 'shared-interfaces';
|
||||
import {
|
||||
ResolvedPortalPlayback,
|
||||
Settings,
|
||||
STORE_KEY,
|
||||
VideoPlayer,
|
||||
} from 'shared-interfaces';
|
||||
import { ArtPlayerComponent } from '../art-player/art-player.component';
|
||||
import { EmbeddedMpvPlayerComponent } from '../embedded-mpv-player/embedded-mpv-player.component';
|
||||
import { HtmlVideoPlayerComponent } from '../html-video-player/html-video-player.component';
|
||||
import { VjsPlayerComponent } from '../vjs-player/vjs-player.component';
|
||||
|
||||
@@ -20,13 +27,20 @@ import { VjsPlayerComponent } from '../vjs-player/vjs-player.component';
|
||||
selector: 'app-web-player-view',
|
||||
templateUrl: './web-player-view.component.html',
|
||||
styleUrls: ['./web-player-view.component.scss'],
|
||||
imports: [ArtPlayerComponent, HtmlVideoPlayerComponent, VjsPlayerComponent],
|
||||
imports: [
|
||||
ArtPlayerComponent,
|
||||
EmbeddedMpvPlayerComponent,
|
||||
HtmlVideoPlayerComponent,
|
||||
VjsPlayerComponent,
|
||||
],
|
||||
encapsulation: ViewEncapsulation.None,
|
||||
})
|
||||
export class WebPlayerViewComponent {
|
||||
storage = inject(StorageMap);
|
||||
|
||||
streamUrl = input.required<string>();
|
||||
title = input('');
|
||||
playback = input<ResolvedPortalPlayback | null>(null);
|
||||
startTime = input<number>(0);
|
||||
@Output() timeUpdate = new EventEmitter<{
|
||||
currentTime: number;
|
||||
@@ -40,13 +54,26 @@ export class WebPlayerViewComponent {
|
||||
channel!: { url: string };
|
||||
player!: VideoPlayer;
|
||||
vjsOptions!: { sources: { src: string; type: string }[] };
|
||||
readonly resolvedPlayback = computed<ResolvedPortalPlayback>(() => {
|
||||
const playback = this.playback();
|
||||
if (playback) {
|
||||
return playback;
|
||||
}
|
||||
|
||||
return {
|
||||
streamUrl: this.streamUrl(),
|
||||
title: this.title() || this.streamUrl(),
|
||||
startTime: this.startTime(),
|
||||
};
|
||||
});
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
this.player = this.settings()?.player ?? VideoPlayer.VideoJs;
|
||||
|
||||
this.setChannel(this.streamUrl());
|
||||
this.setVjsOptions(this.streamUrl());
|
||||
const playback = this.resolvedPlayback();
|
||||
this.setChannel(playback.streamUrl);
|
||||
this.setVjsOptions(playback.streamUrl);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
# Embedded MPV macOS Runtime
|
||||
|
||||
This folder contains tooling for preparing the macOS `libmpv` runtime that is bundled with IPTVnator's experimental embedded MPV player.
|
||||
|
||||
## Runtime Policy
|
||||
|
||||
Release builds must use an LGPL-compatible runtime:
|
||||
|
||||
- FFmpeg must be built without `--enable-gpl` and without `--enable-nonfree`.
|
||||
- mpv must be built with `-Dlibmpv=true` and `-Dgpl=false`.
|
||||
- The runtime must be dynamically linked so users can inspect and replace LGPL libraries.
|
||||
- The exact source URLs, versions, build flags, local patches, and checksums must be published with the release.
|
||||
|
||||
Do not ship the Homebrew `mpv` runtime. It is acceptable only for local development when `IPTVNATOR_EMBEDDED_MPV_ALLOW_HOMEBREW=1` is set, and release packaging rejects it.
|
||||
|
||||
## Expected Layout
|
||||
|
||||
The native addon build consumes:
|
||||
|
||||
```text
|
||||
vendor/embedded-mpv/
|
||||
darwin-arm64/
|
||||
include/mpv/client.h
|
||||
lib/*.dylib
|
||||
runtime-manifest.json
|
||||
darwin-x64/
|
||||
include/mpv/client.h
|
||||
lib/*.dylib
|
||||
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:
|
||||
|
||||
```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
|
||||
```
|
||||
|
||||
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`.
|
||||
|
||||
If the prefix contains `runtime-manifest.json`, the staging script copies its build metadata into the vendored manifest. At minimum, record:
|
||||
|
||||
- FFmpeg version, source URL, checksum, configure flags, and patches
|
||||
- mpv version, source URL, checksum, Meson flags, and patches
|
||||
- source-distribution URL for the corresponding release
|
||||
|
||||
## Building The CI Runtime
|
||||
|
||||
Tagged macOS release builds build the runtime from pinned source archives before `electron-backend:build`:
|
||||
|
||||
```bash
|
||||
pnpm embedded-mpv:build-runtime -- arm64 /tmp/embedded-mpv-prefix
|
||||
pnpm embedded-mpv:stage-runtime -- arm64 /tmp/embedded-mpv-prefix
|
||||
```
|
||||
|
||||
The builder currently pins:
|
||||
|
||||
- FFmpeg `8.1`, configured without `--enable-gpl` or `--enable-nonfree`, and with autodetected external libraries disabled
|
||||
- mpv `0.41.0`, configured with `-Dlibmpv=true -Dgpl=false`
|
||||
- libplacebo `7.360.1`
|
||||
- libass `0.17.3` plus FreeType, FriBidi, and HarfBuzz
|
||||
|
||||
The build manifest records source URLs, downloaded source SHA-256 values, and the exact FFmpeg/mpv flags. The staged manifest is normalized to `origin: vendored-lgpl`, which is the only embedded MPV runtime origin allowed in required macOS release packaging.
|
||||
|
||||
## 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`.
|
||||
|
||||
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.
|
||||
|
||||
Set `IPTVNATOR_REQUIRE_EMBEDDED_MPV=1` when packaging a macOS release artifact that must include Embedded MPV. PR and non-tag development builds leave that variable unset or `0`, so the same in-tree code can package without a staged runtime while Settings keeps Embedded MPV hidden.
|
||||
@@ -0,0 +1,475 @@
|
||||
#!/usr/bin/env node
|
||||
import crypto from 'crypto';
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import { spawnSync } from 'child_process';
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const [arch, rawPrefix] = args;
|
||||
const validArchitectures = new Set(['arm64', 'x64']);
|
||||
const macosDeploymentTarget =
|
||||
process.env.MACOSX_DEPLOYMENT_TARGET ?? '11.0';
|
||||
const workspaceRoot = process.cwd();
|
||||
|
||||
const sourcePackages = [
|
||||
{
|
||||
id: 'freetype',
|
||||
version: '2.13.3',
|
||||
url: 'https://download.savannah.gnu.org/releases/freetype/freetype-2.13.3.tar.xz',
|
||||
license: 'FreeType License or GPL-2.0-or-later',
|
||||
},
|
||||
{
|
||||
id: 'fribidi',
|
||||
version: '1.0.16',
|
||||
url: 'https://github.com/fribidi/fribidi/releases/download/v1.0.16/fribidi-1.0.16.tar.xz',
|
||||
license: 'LGPL-2.1-or-later',
|
||||
},
|
||||
{
|
||||
id: 'harfbuzz',
|
||||
version: '8.5.0',
|
||||
url: 'https://github.com/harfbuzz/harfbuzz/releases/download/8.5.0/harfbuzz-8.5.0.tar.xz',
|
||||
license: 'MIT',
|
||||
},
|
||||
{
|
||||
id: 'libass',
|
||||
version: '0.17.3',
|
||||
url: 'https://github.com/libass/libass/releases/download/0.17.3/libass-0.17.3.tar.xz',
|
||||
license: 'ISC',
|
||||
},
|
||||
{
|
||||
id: 'ffmpeg',
|
||||
version: '8.1',
|
||||
url: 'https://ffmpeg.org/releases/ffmpeg-8.1.tar.xz',
|
||||
license: 'LGPL-compatible configuration',
|
||||
},
|
||||
{
|
||||
id: 'libplacebo',
|
||||
version: '7.360.1',
|
||||
url: 'https://github.com/haasn/libplacebo/archive/refs/tags/v7.360.1.tar.gz',
|
||||
license: 'LGPL-2.1-or-later',
|
||||
},
|
||||
{
|
||||
id: 'mpv',
|
||||
version: '0.41.0',
|
||||
url: 'https://github.com/mpv-player/mpv/archive/refs/tags/v0.41.0.tar.gz',
|
||||
license: 'LGPL-compatible configuration with -Dgpl=false',
|
||||
},
|
||||
];
|
||||
|
||||
if (process.platform !== 'darwin') {
|
||||
console.error('Embedded MPV runtime builds are supported on macOS only.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!validArchitectures.has(arch) || !rawPrefix) {
|
||||
console.error(
|
||||
[
|
||||
'Usage: node tools/embedded-mpv/build-macos-runtime.mjs <arm64|x64> <output-prefix>',
|
||||
'',
|
||||
'Builds a pinned LGPL-compatible macOS libmpv runtime from source.',
|
||||
].join('\n')
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const prefix = path.resolve(rawPrefix);
|
||||
const buildRoot = path.resolve(
|
||||
process.env.IPTVNATOR_EMBEDDED_MPV_BUILD_ROOT ??
|
||||
path.join(os.tmpdir(), 'iptvnator-embedded-mpv-runtime', arch)
|
||||
);
|
||||
const archiveRoot = path.join(buildRoot, 'archives');
|
||||
const sourceRoot = path.join(buildRoot, 'sources');
|
||||
const packageById = new Map(sourcePackages.map((source) => [source.id, source]));
|
||||
const parallelism =
|
||||
process.env.MAKEFLAGS?.match(/-j\s*(\d+)/)?.[1] ??
|
||||
String(os.cpus().length);
|
||||
|
||||
const ffmpegConfigureFlags = [
|
||||
`--prefix=${prefix}`,
|
||||
'--enable-shared',
|
||||
'--disable-static',
|
||||
'--disable-doc',
|
||||
'--disable-debug',
|
||||
'--disable-programs',
|
||||
'--disable-autodetect',
|
||||
'--disable-gpl',
|
||||
'--disable-nonfree',
|
||||
'--enable-pic',
|
||||
'--enable-securetransport',
|
||||
'--enable-audiotoolbox',
|
||||
'--enable-videotoolbox',
|
||||
];
|
||||
|
||||
const mpvMesonFlags = [
|
||||
'-Dgpl=false',
|
||||
'-Dlibmpv=true',
|
||||
'-Dcplayer=false',
|
||||
'-Dbuild-date=false',
|
||||
'-Dtests=false',
|
||||
'-Dlua=disabled',
|
||||
'-Djavascript=disabled',
|
||||
'-Dcplugins=disabled',
|
||||
'-Dmanpage-build=disabled',
|
||||
'-Dhtml-build=disabled',
|
||||
'-Dpdf-build=disabled',
|
||||
'-Dlibarchive=disabled',
|
||||
'-Dlibbluray=disabled',
|
||||
'-Ddvdnav=disabled',
|
||||
'-Dcdda=disabled',
|
||||
'-Ddvbin=disabled',
|
||||
'-Djpeg=disabled',
|
||||
'-Dlcms2=disabled',
|
||||
'-Drubberband=disabled',
|
||||
'-Duchardet=disabled',
|
||||
'-Dzimg=disabled',
|
||||
'-Dvulkan=disabled',
|
||||
'-Dshaderc=disabled',
|
||||
'-Dspirv-cross=disabled',
|
||||
'-Dcocoa=disabled',
|
||||
'-Dgl-cocoa=disabled',
|
||||
'-Dmacos-cocoa-cb=disabled',
|
||||
'-Dswift-build=disabled',
|
||||
'-Dplain-gl=enabled',
|
||||
];
|
||||
|
||||
function log(message) {
|
||||
process.stdout.write(`[embedded-mpv-runtime] ${message}\n`);
|
||||
}
|
||||
|
||||
function run(command, commandArgs, options = {}) {
|
||||
log(`${command} ${commandArgs.join(' ')}`);
|
||||
const result = spawnSync(command, commandArgs, {
|
||||
cwd: options.cwd ?? workspaceRoot,
|
||||
env: options.env ?? buildEnv(),
|
||||
stdio: 'inherit',
|
||||
...options,
|
||||
});
|
||||
|
||||
if (result.status !== 0) {
|
||||
throw new Error(
|
||||
`${command} ${commandArgs.join(' ')} failed with status ${
|
||||
result.status ?? 1
|
||||
}.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function commandExists(command) {
|
||||
const result = spawnSync('sh', ['-lc', `command -v ${command}`], {
|
||||
stdio: 'ignore',
|
||||
});
|
||||
return result.status === 0;
|
||||
}
|
||||
|
||||
function buildEnv() {
|
||||
const pkgConfigDirs = [
|
||||
path.join(prefix, 'lib', 'pkgconfig'),
|
||||
path.join(prefix, 'share', 'pkgconfig'),
|
||||
].join(path.delimiter);
|
||||
|
||||
return {
|
||||
...process.env,
|
||||
PATH: [path.join(prefix, 'bin'), process.env.PATH]
|
||||
.filter(Boolean)
|
||||
.join(path.delimiter),
|
||||
PKG_CONFIG_PATH: pkgConfigDirs,
|
||||
PKG_CONFIG_LIBDIR: pkgConfigDirs,
|
||||
CMAKE_PREFIX_PATH: prefix,
|
||||
DYLD_LIBRARY_PATH: path.join(prefix, 'lib'),
|
||||
MACOSX_DEPLOYMENT_TARGET: macosDeploymentTarget,
|
||||
CFLAGS: [`-I${path.join(prefix, 'include')}`, process.env.CFLAGS]
|
||||
.filter(Boolean)
|
||||
.join(' '),
|
||||
LDFLAGS: [`-L${path.join(prefix, 'lib')}`, process.env.LDFLAGS]
|
||||
.filter(Boolean)
|
||||
.join(' '),
|
||||
};
|
||||
}
|
||||
|
||||
function ensureTools() {
|
||||
const requiredCommands = [
|
||||
'curl',
|
||||
'tar',
|
||||
'make',
|
||||
'meson',
|
||||
'ninja',
|
||||
'pkg-config',
|
||||
];
|
||||
const missing = requiredCommands.filter((command) => !commandExists(command));
|
||||
|
||||
if (missing.length > 0) {
|
||||
throw new Error(`Missing required build tools: ${missing.join(', ')}`);
|
||||
}
|
||||
}
|
||||
|
||||
function archivePathFor(sourcePackage) {
|
||||
const extension = sourcePackage.url.endsWith('.tar.xz')
|
||||
? '.tar.xz'
|
||||
: '.tar.gz';
|
||||
return path.join(
|
||||
archiveRoot,
|
||||
`${sourcePackage.id}-${sourcePackage.version}${extension}`
|
||||
);
|
||||
}
|
||||
|
||||
function sourcePathFor(packageId) {
|
||||
return path.join(sourceRoot, packageId);
|
||||
}
|
||||
|
||||
function sha256File(filePath) {
|
||||
const hash = crypto.createHash('sha256');
|
||||
hash.update(fs.readFileSync(filePath));
|
||||
return hash.digest('hex');
|
||||
}
|
||||
|
||||
function downloadSources() {
|
||||
fs.mkdirSync(archiveRoot, { recursive: true });
|
||||
fs.mkdirSync(sourceRoot, { recursive: true });
|
||||
|
||||
for (const sourcePackage of sourcePackages) {
|
||||
const archivePath = archivePathFor(sourcePackage);
|
||||
if (!fs.existsSync(archivePath)) {
|
||||
run('curl', [
|
||||
'-fL',
|
||||
'--retry',
|
||||
'3',
|
||||
'--retry-delay',
|
||||
'5',
|
||||
'-o',
|
||||
archivePath,
|
||||
sourcePackage.url,
|
||||
]);
|
||||
}
|
||||
|
||||
const packageSourcePath = sourcePathFor(sourcePackage.id);
|
||||
fs.rmSync(packageSourcePath, { recursive: true, force: true });
|
||||
fs.mkdirSync(packageSourcePath, { recursive: true });
|
||||
run('tar', [
|
||||
'-xf',
|
||||
archivePath,
|
||||
'-C',
|
||||
packageSourcePath,
|
||||
'--strip-components',
|
||||
'1',
|
||||
]);
|
||||
sourcePackage.sha256 = sha256File(archivePath);
|
||||
}
|
||||
}
|
||||
|
||||
function configureMakeInstall(packageId, configureArgs) {
|
||||
const packageSourcePath = sourcePathFor(packageId);
|
||||
run('./configure', [`--prefix=${prefix}`, ...configureArgs], {
|
||||
cwd: packageSourcePath,
|
||||
});
|
||||
run('make', [`-j${parallelism}`], { cwd: packageSourcePath });
|
||||
run('make', ['install'], { cwd: packageSourcePath });
|
||||
}
|
||||
|
||||
function mesonInstall(packageId, mesonArgs) {
|
||||
const packageSourcePath = sourcePathFor(packageId);
|
||||
const buildDir = path.join(packageSourcePath, 'build-iptvnator');
|
||||
fs.rmSync(buildDir, { recursive: true, force: true });
|
||||
run(
|
||||
'meson',
|
||||
[
|
||||
'setup',
|
||||
buildDir,
|
||||
`--prefix=${prefix}`,
|
||||
'--libdir=lib',
|
||||
'--buildtype=release',
|
||||
'--default-library=shared',
|
||||
...mesonArgs,
|
||||
],
|
||||
{ cwd: packageSourcePath }
|
||||
);
|
||||
run('meson', ['compile', '-C', buildDir], { cwd: packageSourcePath });
|
||||
run('meson', ['install', '-C', buildDir], { cwd: packageSourcePath });
|
||||
}
|
||||
|
||||
function buildRuntime() {
|
||||
fs.rmSync(prefix, { recursive: true, force: true });
|
||||
fs.mkdirSync(prefix, { recursive: true });
|
||||
|
||||
configureMakeInstall('freetype', ['--enable-shared', '--disable-static']);
|
||||
configureMakeInstall('fribidi', ['--enable-shared', '--disable-static']);
|
||||
mesonInstall('harfbuzz', [
|
||||
'-Dglib=disabled',
|
||||
'-Dgobject=disabled',
|
||||
'-Dcairo=disabled',
|
||||
'-Dchafa=disabled',
|
||||
'-Dicu=disabled',
|
||||
'-Dfreetype=enabled',
|
||||
'-Dtests=disabled',
|
||||
'-Dintrospection=disabled',
|
||||
'-Ddocs=disabled',
|
||||
'-Dutilities=disabled',
|
||||
'-Dbenchmark=disabled',
|
||||
]);
|
||||
configureMakeInstall('libass', [
|
||||
'--enable-shared',
|
||||
'--disable-static',
|
||||
'--disable-fontconfig',
|
||||
'--enable-coretext',
|
||||
'--disable-libunibreak',
|
||||
]);
|
||||
|
||||
const ffmpegSourcePath = sourcePathFor('ffmpeg');
|
||||
run('./configure', ffmpegConfigureFlags, { cwd: ffmpegSourcePath });
|
||||
run('make', [`-j${parallelism}`], { cwd: ffmpegSourcePath });
|
||||
run('make', ['install'], { cwd: ffmpegSourcePath });
|
||||
|
||||
mesonInstall('libplacebo', [
|
||||
'-Dopengl=enabled',
|
||||
'-Dvulkan=disabled',
|
||||
'-Dvk-proc-addr=disabled',
|
||||
'-Dglslang=disabled',
|
||||
'-Dshaderc=disabled',
|
||||
'-Dlcms=disabled',
|
||||
'-Ddovi=disabled',
|
||||
'-Dlibdovi=disabled',
|
||||
'-Ddemos=false',
|
||||
'-Dtests=false',
|
||||
'-Dbench=false',
|
||||
'-Dfuzz=false',
|
||||
'-Dunwind=disabled',
|
||||
'-Dxxhash=disabled',
|
||||
]);
|
||||
mesonInstall('mpv', mpvMesonFlags);
|
||||
}
|
||||
|
||||
function listDylibs(directoryPath) {
|
||||
if (!fs.existsSync(directoryPath)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return fs
|
||||
.readdirSync(directoryPath, { withFileTypes: true })
|
||||
.filter((entry) => entry.isFile() && entry.name.endsWith('.dylib'))
|
||||
.map((entry) => path.join(directoryPath, entry.name))
|
||||
.sort();
|
||||
}
|
||||
|
||||
function validateRuntimeLinks() {
|
||||
if (!commandExists('otool')) {
|
||||
log('Skipping otool validation because otool is unavailable.');
|
||||
return;
|
||||
}
|
||||
|
||||
const libDir = path.join(prefix, 'lib');
|
||||
const errors = [];
|
||||
const allowedSystemPrefixes = ['/System/Library/', '/usr/lib/'];
|
||||
const forbiddenPrefixes = ['/opt/homebrew/', '/usr/local/'];
|
||||
|
||||
for (const dylibPath of listDylibs(libDir)) {
|
||||
const result = spawnSync('otool', ['-L', dylibPath], {
|
||||
encoding: 'utf8',
|
||||
stdio: 'pipe',
|
||||
});
|
||||
if (result.status !== 0) {
|
||||
errors.push(`Unable to inspect ${dylibPath}: ${result.stderr}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const dependencyPath of result.stdout
|
||||
.split(/\r?\n/)
|
||||
.slice(1)
|
||||
.map((line) => line.trim().split(/\s+\(/)[0])
|
||||
.filter(Boolean)) {
|
||||
if (
|
||||
allowedSystemPrefixes.some((prefixValue) =>
|
||||
dependencyPath.startsWith(prefixValue)
|
||||
) ||
|
||||
dependencyPath.startsWith('@loader_path/') ||
|
||||
dependencyPath.startsWith('@rpath/') ||
|
||||
dependencyPath.startsWith(prefix)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
forbiddenPrefixes.some((prefixValue) =>
|
||||
dependencyPath.startsWith(prefixValue)
|
||||
)
|
||||
) {
|
||||
errors.push(`${dylibPath} links to forbidden ${dependencyPath}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (path.isAbsolute(dependencyPath)) {
|
||||
errors.push(`${dylibPath} links to external ${dependencyPath}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
throw new Error(
|
||||
['Embedded MPV runtime link validation failed.', ...errors].join('\n')
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function sourceMetadata(packageId) {
|
||||
const sourcePackage = packageById.get(packageId);
|
||||
return {
|
||||
version: sourcePackage.version,
|
||||
sourceUrl: sourcePackage.url,
|
||||
sourceSha256: sourcePackage.sha256,
|
||||
license: sourcePackage.license,
|
||||
};
|
||||
}
|
||||
|
||||
function writeManifest() {
|
||||
const manifest = {
|
||||
origin: 'vendored-lgpl-source-build',
|
||||
arch,
|
||||
generatedAt: new Date().toISOString(),
|
||||
macosDeploymentTarget,
|
||||
buildHost: {
|
||||
platform: process.platform,
|
||||
arch: process.arch,
|
||||
},
|
||||
packages: Object.fromEntries(
|
||||
sourcePackages.map((sourcePackage) => [
|
||||
sourcePackage.id,
|
||||
{
|
||||
version: sourcePackage.version,
|
||||
sourceUrl: sourcePackage.url,
|
||||
sourceSha256: sourcePackage.sha256,
|
||||
license: sourcePackage.license,
|
||||
},
|
||||
])
|
||||
),
|
||||
ffmpeg: {
|
||||
...sourceMetadata('ffmpeg'),
|
||||
licensePolicy: 'LGPL, built without --enable-gpl and --enable-nonfree',
|
||||
configureFlags: ffmpegConfigureFlags,
|
||||
},
|
||||
mpv: {
|
||||
...sourceMetadata('mpv'),
|
||||
licensePolicy:
|
||||
'LGPL-compatible libmpv, built with -Dlibmpv=true -Dgpl=false',
|
||||
mesonFlags: mpvMesonFlags,
|
||||
},
|
||||
sourceDistribution:
|
||||
'Attach the downloaded source archives, this manifest, and any local patches with the macOS binary release.',
|
||||
};
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(prefix, 'runtime-manifest.json'),
|
||||
`${JSON.stringify(manifest, null, 2)}\n`
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
ensureTools();
|
||||
fs.mkdirSync(buildRoot, { recursive: true });
|
||||
downloadSources();
|
||||
buildRuntime();
|
||||
validateRuntimeLinks();
|
||||
writeManifest();
|
||||
log(`Built LGPL-compatible runtime prefix at ${prefix}`);
|
||||
} catch (error) {
|
||||
console.error(error instanceof Error ? error.message : String(error));
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const [arch, sourcePrefix] = args;
|
||||
const validArchitectures = new Set(['arm64', 'x64']);
|
||||
const workspaceRoot = process.cwd();
|
||||
|
||||
if (!validArchitectures.has(arch) || !sourcePrefix) {
|
||||
console.error(
|
||||
[
|
||||
'Usage: node tools/embedded-mpv/stage-macos-runtime.mjs <arm64|x64> <lgpl-runtime-prefix>',
|
||||
'',
|
||||
'The prefix must contain:',
|
||||
'- include/mpv/client.h',
|
||||
'- lib/libmpv.2.dylib or lib/libmpv.dylib',
|
||||
'- all non-system dylib dependencies required by libmpv',
|
||||
].join('\n')
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const normalizedPrefix = path.resolve(sourcePrefix);
|
||||
const destinationRoot = path.join(
|
||||
workspaceRoot,
|
||||
'vendor',
|
||||
'embedded-mpv',
|
||||
`darwin-${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');
|
||||
|
||||
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) {
|
||||
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 findLibMpv(libDir) {
|
||||
for (const candidate of ['libmpv.2.dylib', 'libmpv.dylib']) {
|
||||
const candidatePath = path.join(libDir, candidate);
|
||||
if (fs.existsSync(candidatePath)) {
|
||||
return candidatePath;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function listDylibs(libDir) {
|
||||
return fs
|
||||
.readdirSync(libDir, { withFileTypes: true })
|
||||
.filter((entry) => entry.isFile() && entry.name.endsWith('.dylib'))
|
||||
.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 (!findLibMpv(sourceLibDir)) {
|
||||
throw new Error(`Missing libmpv dylib 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, (_sourcePath, entry) => {
|
||||
return entry.isDirectory() || entry.name.endsWith('.dylib');
|
||||
});
|
||||
|
||||
const externalManifest =
|
||||
readJsonIfExists(path.join(normalizedPrefix, 'runtime-manifest.json')) ??
|
||||
{};
|
||||
const manifest = {
|
||||
...externalManifest,
|
||||
origin: 'vendored-lgpl',
|
||||
arch,
|
||||
stagedAt: new Date().toISOString(),
|
||||
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.',
|
||||
},
|
||||
dylibs: listDylibs(destinationLibDir),
|
||||
sourceDistribution:
|
||||
externalManifest.sourceDistribution ??
|
||||
'Publish exact source archives and local patches with the macOS binary release.',
|
||||
};
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(destinationRoot, 'runtime-manifest.json'),
|
||||
`${JSON.stringify(manifest, null, 2)}\n`
|
||||
);
|
||||
|
||||
console.log(
|
||||
`Staged embedded MPV runtime for darwin-${arch} at ${path.relative(
|
||||
workspaceRoot,
|
||||
destinationRoot
|
||||
)}`
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(error instanceof Error ? error.message : String(error));
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
const linuxAfterPack = require('./linux-after-pack.cjs');
|
||||
const {
|
||||
validatePackagedEmbeddedMpv,
|
||||
} = require('./embedded-mpv-macos.cjs');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
function log(message) {
|
||||
console.log(` - ${message}`);
|
||||
}
|
||||
|
||||
function isTruthy(value) {
|
||||
return ['1', 'true', 'yes', 'on'].includes(
|
||||
String(value ?? '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
);
|
||||
}
|
||||
|
||||
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'
|
||||
);
|
||||
const appPath = params.appOutDir.endsWith('.app')
|
||||
? params.appOutDir
|
||||
: fs
|
||||
.readdirSync(params.appOutDir)
|
||||
.find((entry) => entry.endsWith('.app'));
|
||||
const resourceDir = appPath
|
||||
? path.join(
|
||||
params.appOutDir.endsWith('.app')
|
||||
? params.appOutDir
|
||||
: path.join(params.appOutDir, appPath),
|
||||
'Contents',
|
||||
'Resources'
|
||||
)
|
||||
: params.appOutDir;
|
||||
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;
|
||||
@@ -0,0 +1,465 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { spawnSync } = require('child_process');
|
||||
|
||||
const forbiddenRuntimePathPrefixes = ['/opt/homebrew/', '/usr/local/'];
|
||||
const systemRuntimePathPrefixes = ['/System/Library/', '/usr/lib/'];
|
||||
|
||||
function run(command, args, options = {}) {
|
||||
const result = spawnSync(command, args, {
|
||||
stdio: options.stdio ?? 'pipe',
|
||||
encoding: 'utf8',
|
||||
...options,
|
||||
});
|
||||
|
||||
if (result.status !== 0) {
|
||||
const stderr = result.stderr ? `\n${result.stderr}` : '';
|
||||
throw new Error(
|
||||
`${command} ${args.join(' ')} failed with status ${result.status ?? 1}.${stderr}`
|
||||
);
|
||||
}
|
||||
|
||||
return result.stdout ?? '';
|
||||
}
|
||||
|
||||
function commandExists(command) {
|
||||
const result = spawnSync('sh', ['-lc', `command -v ${command}`], {
|
||||
stdio: 'ignore',
|
||||
});
|
||||
return result.status === 0;
|
||||
}
|
||||
|
||||
function ensureDir(directoryPath) {
|
||||
fs.mkdirSync(directoryPath, { recursive: true });
|
||||
}
|
||||
|
||||
function removeDir(directoryPath) {
|
||||
fs.rmSync(directoryPath, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
function copyFile(sourcePath, destinationPath) {
|
||||
ensureDir(path.dirname(destinationPath));
|
||||
fs.copyFileSync(sourcePath, destinationPath);
|
||||
fs.chmodSync(destinationPath, 0o755);
|
||||
}
|
||||
|
||||
function listDylibs(directoryPath) {
|
||||
if (!fs.existsSync(directoryPath)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return fs
|
||||
.readdirSync(directoryPath, { withFileTypes: true })
|
||||
.filter((entry) => entry.isFile() && entry.name.endsWith('.dylib'))
|
||||
.map((entry) => path.join(directoryPath, entry.name))
|
||||
.sort();
|
||||
}
|
||||
|
||||
function listRuntimeFiles(directoryPath) {
|
||||
if (!fs.existsSync(directoryPath)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return fs
|
||||
.readdirSync(directoryPath, { withFileTypes: true })
|
||||
.filter((entry) => entry.isFile())
|
||||
.map((entry) => path.join(directoryPath, entry.name))
|
||||
.sort();
|
||||
}
|
||||
|
||||
function parseOtoolDependencies(binaryPath) {
|
||||
const output = run('otool', ['-L', binaryPath]);
|
||||
return output
|
||||
.split(/\r?\n/)
|
||||
.slice(1)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
.map((line) => line.split(/\s+\(/)[0])
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function parseOtoolRpaths(binaryPath) {
|
||||
const output = run('otool', ['-l', binaryPath]);
|
||||
const lines = output.split(/\r?\n/);
|
||||
const rpaths = [];
|
||||
|
||||
for (let index = 0; index < lines.length; index += 1) {
|
||||
if (lines[index].trim() !== 'cmd LC_RPATH') {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (let offset = index + 1; offset < lines.length; offset += 1) {
|
||||
const match = lines[offset]
|
||||
.trim()
|
||||
.match(/^path\s+(.+?)\s+\(offset\s+\d+\)$/);
|
||||
if (match) {
|
||||
rpaths.push(match[1]);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return rpaths;
|
||||
}
|
||||
|
||||
function readInstallNameId(binaryPath) {
|
||||
const result = spawnSync('otool', ['-D', binaryPath], {
|
||||
stdio: 'pipe',
|
||||
encoding: 'utf8',
|
||||
});
|
||||
|
||||
if (result.status !== 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
result.stdout
|
||||
.split(/\r?\n/)
|
||||
.slice(1)
|
||||
.map((line) => line.trim())
|
||||
.find(Boolean) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
function isSystemDependency(dependencyPath) {
|
||||
return systemRuntimePathPrefixes.some((prefix) =>
|
||||
dependencyPath.startsWith(prefix)
|
||||
);
|
||||
}
|
||||
|
||||
function isForbiddenDependency(dependencyPath) {
|
||||
return forbiddenRuntimePathPrefixes.some((prefix) =>
|
||||
dependencyPath.startsWith(prefix)
|
||||
);
|
||||
}
|
||||
|
||||
function resolvePathToken(tokenPath, binaryPath) {
|
||||
if (tokenPath.startsWith('@loader_path/')) {
|
||||
return path.resolve(
|
||||
path.dirname(binaryPath),
|
||||
tokenPath.replace('@loader_path/', '')
|
||||
);
|
||||
}
|
||||
|
||||
if (tokenPath.startsWith('@executable_path/')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (tokenPath.startsWith('@rpath/')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return tokenPath;
|
||||
}
|
||||
|
||||
function resolveRpathDependency(dependencyPath, binaryPath) {
|
||||
const dependencyName = dependencyPath.replace('@rpath/', '');
|
||||
|
||||
for (const rpath of parseOtoolRpaths(binaryPath)) {
|
||||
const resolvedRpath = resolvePathToken(rpath, binaryPath);
|
||||
if (!resolvedRpath) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const candidatePath = path.resolve(resolvedRpath, dependencyName);
|
||||
if (fs.existsSync(candidatePath)) {
|
||||
return candidatePath;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolveDependencyPath(dependencyPath, binaryPath) {
|
||||
if (dependencyPath.startsWith('@loader_path/')) {
|
||||
return resolvePathToken(dependencyPath, binaryPath);
|
||||
}
|
||||
|
||||
if (dependencyPath.startsWith('@rpath/')) {
|
||||
return resolveRpathDependency(dependencyPath, binaryPath);
|
||||
}
|
||||
|
||||
if (dependencyPath.startsWith('@executable_path/')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return dependencyPath;
|
||||
}
|
||||
|
||||
function collectExternalDylibs(entryPaths) {
|
||||
const visited = new Set();
|
||||
const queue = [...entryPaths];
|
||||
const result = new Map();
|
||||
|
||||
while (queue.length > 0) {
|
||||
const currentPath = queue.shift();
|
||||
if (!currentPath || visited.has(currentPath) || !fs.existsSync(currentPath)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
visited.add(currentPath);
|
||||
for (const dependencyPath of parseOtoolDependencies(currentPath)) {
|
||||
if (isSystemDependency(dependencyPath)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const resolvedPath = resolveDependencyPath(dependencyPath, currentPath);
|
||||
if (!resolvedPath || !fs.existsSync(resolvedPath)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!result.has(path.basename(resolvedPath))) {
|
||||
result.set(path.basename(resolvedPath), resolvedPath);
|
||||
queue.push(resolvedPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [...result.values()];
|
||||
}
|
||||
|
||||
function findLibMpv(runtimeLibDir) {
|
||||
const candidates = ['libmpv.2.dylib', 'libmpv.dylib'];
|
||||
for (const candidate of candidates) {
|
||||
const candidatePath = path.join(runtimeLibDir, candidate);
|
||||
if (fs.existsSync(candidatePath)) {
|
||||
return candidatePath;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function patchDylibIds(libDir) {
|
||||
for (const dylibPath of listRuntimeFiles(libDir)) {
|
||||
if (!readInstallNameId(dylibPath)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
run('install_name_tool', [
|
||||
'-id',
|
||||
`@loader_path/${path.basename(dylibPath)}`,
|
||||
dylibPath,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
function patchBinaryDependencies(binaryPath, dependencyBaseDir, replacementPrefix) {
|
||||
const availableRuntimeFiles = new Set(
|
||||
listRuntimeFiles(dependencyBaseDir).map((runtimePath) =>
|
||||
path.basename(runtimePath)
|
||||
)
|
||||
);
|
||||
|
||||
for (const dependencyPath of parseOtoolDependencies(binaryPath)) {
|
||||
const dependencyName = path.basename(dependencyPath);
|
||||
if (!availableRuntimeFiles.has(dependencyName)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const replacementPath = `${replacementPrefix}/${dependencyName}`;
|
||||
if (dependencyPath === replacementPath) {
|
||||
continue;
|
||||
}
|
||||
|
||||
run('install_name_tool', [
|
||||
'-change',
|
||||
dependencyPath,
|
||||
replacementPath,
|
||||
binaryPath,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
function adHocSignBinary(binaryPath) {
|
||||
if (process.platform !== 'darwin' || !commandExists('codesign')) {
|
||||
return;
|
||||
}
|
||||
|
||||
run('codesign', ['--force', '--sign', '-', '--timestamp=none', binaryPath]);
|
||||
}
|
||||
|
||||
function adHocSignBinaries(binaryPaths) {
|
||||
for (const binaryPath of binaryPaths) {
|
||||
adHocSignBinary(binaryPath);
|
||||
}
|
||||
}
|
||||
|
||||
function copyRuntimeToNativeBuild({
|
||||
runtimeLibDir,
|
||||
outputLibDir,
|
||||
runtimeOrigin,
|
||||
runtimeManifest,
|
||||
}) {
|
||||
const libMpvPath = findLibMpv(runtimeLibDir);
|
||||
if (!libMpvPath) {
|
||||
throw new Error(`Unable to find libmpv in ${runtimeLibDir}.`);
|
||||
}
|
||||
|
||||
removeDir(outputLibDir);
|
||||
ensureDir(outputLibDir);
|
||||
|
||||
const runtimeFilesByName = new Map([[path.basename(libMpvPath), libMpvPath]]);
|
||||
for (const dylibPath of collectExternalDylibs([libMpvPath])) {
|
||||
runtimeFilesByName.set(path.basename(dylibPath), dylibPath);
|
||||
}
|
||||
if (runtimeOrigin === 'vendored-lgpl') {
|
||||
for (const runtimePath of listRuntimeFiles(runtimeLibDir)) {
|
||||
runtimeFilesByName.set(path.basename(runtimePath), runtimePath);
|
||||
}
|
||||
}
|
||||
|
||||
const dylibs = [...runtimeFilesByName.values()];
|
||||
for (const dylibPath of dylibs) {
|
||||
copyFile(dylibPath, path.join(outputLibDir, path.basename(dylibPath)));
|
||||
}
|
||||
|
||||
if (!fs.existsSync(path.join(outputLibDir, 'libmpv.2.dylib'))) {
|
||||
const copiedLibMpvPath = path.join(outputLibDir, path.basename(libMpvPath));
|
||||
if (path.basename(copiedLibMpvPath) !== 'libmpv.2.dylib') {
|
||||
copyFile(copiedLibMpvPath, path.join(outputLibDir, 'libmpv.2.dylib'));
|
||||
}
|
||||
}
|
||||
|
||||
patchDylibIds(outputLibDir);
|
||||
for (const runtimePath of listRuntimeFiles(outputLibDir)) {
|
||||
patchBinaryDependencies(runtimePath, outputLibDir, '@loader_path');
|
||||
}
|
||||
adHocSignBinaries(listRuntimeFiles(outputLibDir));
|
||||
|
||||
const manifest = {
|
||||
origin: runtimeOrigin,
|
||||
generatedAt: new Date().toISOString(),
|
||||
libDir: 'lib',
|
||||
runtimeFiles: listRuntimeFiles(outputLibDir).map((runtimePath) =>
|
||||
path.basename(runtimePath)
|
||||
),
|
||||
dylibs: listDylibs(outputLibDir).map((dylibPath) =>
|
||||
path.basename(dylibPath)
|
||||
),
|
||||
...runtimeManifest,
|
||||
};
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(path.dirname(outputLibDir), 'embedded-mpv-runtime.json'),
|
||||
`${JSON.stringify(manifest, null, 2)}\n`
|
||||
);
|
||||
|
||||
return manifest;
|
||||
}
|
||||
|
||||
function patchAddonForBundledRuntime(addonPath, outputLibDir) {
|
||||
if (!fs.existsSync(addonPath)) {
|
||||
throw new Error(`Missing embedded MPV native addon: ${addonPath}`);
|
||||
}
|
||||
|
||||
patchBinaryDependencies(addonPath, outputLibDir, '@loader_path/lib');
|
||||
adHocSignBinary(addonPath);
|
||||
}
|
||||
|
||||
function validateNoForbiddenRuntimeLinks(binaryPaths) {
|
||||
const errors = [];
|
||||
|
||||
for (const binaryPath of binaryPaths) {
|
||||
if (!fs.existsSync(binaryPath)) {
|
||||
errors.push(`Missing binary: ${binaryPath}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const dependencyPath of parseOtoolDependencies(binaryPath)) {
|
||||
if (isForbiddenDependency(dependencyPath)) {
|
||||
errors.push(
|
||||
`${binaryPath} links to forbidden runtime path: ${dependencyPath}`
|
||||
);
|
||||
}
|
||||
|
||||
if (dependencyPath.startsWith('@loader_path/')) {
|
||||
const resolvedPath = path.resolve(
|
||||
path.dirname(binaryPath),
|
||||
dependencyPath.replace('@loader_path/', '')
|
||||
);
|
||||
if (!fs.existsSync(resolvedPath)) {
|
||||
errors.push(
|
||||
`${binaryPath} links to missing bundled dependency: ${dependencyPath}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
!isSystemDependency(dependencyPath) &&
|
||||
!dependencyPath.startsWith('@loader_path/')
|
||||
) {
|
||||
errors.push(
|
||||
`${binaryPath} links to non-bundled runtime path: ${dependencyPath}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
function validatePackagedEmbeddedMpv(resourceDir, options = {}) {
|
||||
if (process.platform !== 'darwin') {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!commandExists('otool')) {
|
||||
return ['otool is required to validate embedded MPV macOS packaging.'];
|
||||
}
|
||||
|
||||
const unpackedNativeDir = path.join(
|
||||
resourceDir,
|
||||
'app.asar.unpacked',
|
||||
'electron-backend',
|
||||
'native'
|
||||
);
|
||||
const addonPath = path.join(unpackedNativeDir, 'embedded_mpv.node');
|
||||
const libDir = path.join(unpackedNativeDir, 'lib');
|
||||
const manifestPath = path.join(
|
||||
unpackedNativeDir,
|
||||
'embedded-mpv-runtime.json'
|
||||
);
|
||||
const errors = [];
|
||||
|
||||
if (!fs.existsSync(addonPath)) {
|
||||
if (options.required) {
|
||||
errors.push(`Missing embedded MPV native addon: ${addonPath}`);
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
if (!fs.existsSync(manifestPath)) {
|
||||
errors.push(`Missing embedded MPV runtime manifest: ${manifestPath}`);
|
||||
} else {
|
||||
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
|
||||
if (manifest.origin !== 'vendored-lgpl') {
|
||||
errors.push(
|
||||
`Embedded MPV packaged runtime must be vendored-lgpl, received: ${manifest.origin}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!fs.existsSync(path.join(libDir, 'libmpv.2.dylib'))) {
|
||||
errors.push(`Missing bundled libmpv.2.dylib in ${libDir}`);
|
||||
}
|
||||
|
||||
const binaries = [addonPath, ...listRuntimeFiles(libDir)];
|
||||
errors.push(...validateNoForbiddenRuntimeLinks(binaries));
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
collectExternalDylibs,
|
||||
commandExists,
|
||||
copyRuntimeToNativeBuild,
|
||||
findLibMpv,
|
||||
listRuntimeFiles,
|
||||
listDylibs,
|
||||
parseOtoolDependencies,
|
||||
parseOtoolRpaths,
|
||||
patchAddonForBundledRuntime,
|
||||
validateNoForbiddenRuntimeLinks,
|
||||
validatePackagedEmbeddedMpv,
|
||||
};
|
||||
@@ -1,6 +1,9 @@
|
||||
import fs from 'fs';
|
||||
import { createRequire } from 'module';
|
||||
import path from 'path';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const { validatePackagedEmbeddedMpv } = require('./embedded-mpv-macos.cjs');
|
||||
const args = process.argv.slice(2);
|
||||
const normalizedArgs = args[0] === '--' ? args.slice(1) : args;
|
||||
const [platform, arch = ''] = normalizedArgs;
|
||||
@@ -36,6 +39,7 @@ const electronBuilderConfig = JSON.parse(
|
||||
);
|
||||
const flatpakFinishArgs = electronBuilderConfig.flatpak?.finishArgs ?? [];
|
||||
const snapConfigInspection = loadSnapConfigInspection();
|
||||
const embeddedMpvRequired = isTruthy(process.env.IPTVNATOR_REQUIRE_EMBEDDED_MPV);
|
||||
const workerRelativeDir = path.join(
|
||||
'dist',
|
||||
'apps',
|
||||
@@ -66,6 +70,14 @@ function fileExists(filePath) {
|
||||
return fs.existsSync(filePath) && fs.statSync(filePath).isFile();
|
||||
}
|
||||
|
||||
function isTruthy(value) {
|
||||
return ['1', 'true', 'yes', 'on'].includes(
|
||||
String(value ?? '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
);
|
||||
}
|
||||
|
||||
function getMacResourceDirs() {
|
||||
const candidates = [
|
||||
{
|
||||
@@ -477,6 +489,14 @@ function verifyResourceDir(resourceDir) {
|
||||
verifyLinuxLauncher(resourceDir, errors);
|
||||
}
|
||||
|
||||
if (platform === 'macos') {
|
||||
errors.push(
|
||||
...validatePackagedEmbeddedMpv(resourceDir, {
|
||||
required: embeddedMpvRequired,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
resourceDir,
|
||||
errors,
|
||||
|
||||
Vendored
+10
@@ -0,0 +1,10 @@
|
||||
# Embedded MPV Runtime Artifacts
|
||||
|
||||
This directory is the staging location for macOS embedded MPV runtime artifacts.
|
||||
|
||||
Generated architecture folders are expected at:
|
||||
|
||||
- `vendor/embedded-mpv/darwin-arm64/`
|
||||
- `vendor/embedded-mpv/darwin-x64/`
|
||||
|
||||
Each generated folder must contain `include/mpv/client.h`, `lib/*.dylib`, and `runtime-manifest.json`. The binary runtime directories are ignored by git by default; generate or restore them in release packaging jobs before building the Electron backend.
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
# darwin-arm64 Embedded MPV Runtime
|
||||
|
||||
Generate this folder with:
|
||||
|
||||
```bash
|
||||
node tools/embedded-mpv/stage-macos-runtime.mjs arm64 /path/to/lgpl-prefix
|
||||
```
|
||||
|
||||
The generated `include/`, `lib/`, and `runtime-manifest.json` files are release inputs and are ignored by git by default.
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
# darwin-x64 Embedded MPV Runtime
|
||||
|
||||
Generate this folder with:
|
||||
|
||||
```bash
|
||||
node tools/embedded-mpv/stage-macos-runtime.mjs x64 /path/to/lgpl-prefix
|
||||
```
|
||||
|
||||
The generated `include/`, `lib/`, and `runtime-manifest.json` files are release inputs and are ignored by git by default.
|
||||
Reference in new issue
Block a user