mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-13 12:36:15 -08:00
feat(embedded-mpv): add Windows frame-copy support (#1175)
Port the embedded mpv frame-copy pipeline to Windows with WGL rendering and named shared memory. Includes packaging validation, platform gates, tests, and architecture documentation.
This commit is contained in:
1 parent
7d75d989e8
commit
59e08fd2d6
41 files changed
+1284
-157
No files matched your search
@@ -83,6 +83,23 @@
|
||||
],
|
||||
"include_dirs": [
|
||||
"helper"
|
||||
],
|
||||
"conditions": [
|
||||
[
|
||||
"OS==\"win\"",
|
||||
{
|
||||
"defines": [
|
||||
"WIN32_LEAN_AND_MEAN",
|
||||
"NOMINMAX"
|
||||
],
|
||||
"msvs_settings": {
|
||||
"VCCLCompilerTool": {
|
||||
"CompileAs": 2,
|
||||
"ExceptionHandling": 1
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -153,6 +170,34 @@
|
||||
"-ldl"
|
||||
]
|
||||
}
|
||||
],
|
||||
[
|
||||
"OS==\"win\"",
|
||||
{
|
||||
"type": "executable",
|
||||
"sources": [
|
||||
"helper/mpv_frame_helper.cpp"
|
||||
],
|
||||
"include_dirs": [
|
||||
"<!(node -p \"(process.env.LIBMPV_INCLUDE_DIR || process.cwd()).replace(/\\\\\\\\/g, '/')\")",
|
||||
"helper"
|
||||
],
|
||||
"defines": [
|
||||
"WIN32_LEAN_AND_MEAN",
|
||||
"NOMINMAX"
|
||||
],
|
||||
"msvs_settings": {
|
||||
"VCCLCompilerTool": {
|
||||
"ExceptionHandling": 1
|
||||
}
|
||||
},
|
||||
"libraries": [
|
||||
"<!(node -e \"const path = require('path'); const dir = process.env.LIBMPV_LIBRARY_DIR || process.cwd(); const lib = process.env.LIBMPV_IMPORT_LIB || path.join(dir, 'mpv.lib'); process.stdout.write(lib.replace(/\\\\\\\\/g, '/'))\")",
|
||||
"opengl32.lib",
|
||||
"gdi32.lib",
|
||||
"user32.lib"
|
||||
]
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
|
||||
@@ -15,6 +15,13 @@
|
||||
* core symbols from that linked library and falls back to eglGetProcAddress
|
||||
* for extensions.
|
||||
*
|
||||
* Windows: WGL against a hidden 1x1 window (never shown, never pumped —
|
||||
* offscreen FBO rendering only, no SwapBuffers). A legacy context
|
||||
* bootstraps wglCreateContextAttribsARB for a 3.2 core context. opengl32
|
||||
* only exports GL 1.1, so the post-1.1 entry points the render pipeline
|
||||
* needs are declared here as same-named function pointers and resolved via
|
||||
* wglGetProcAddress; mpv resolves its own through the same loader.
|
||||
*
|
||||
* Threading contract: create() runs on the main thread and must leave the
|
||||
* context unbound; the render thread calls makeCurrent() once and owns the
|
||||
* context until destroy().
|
||||
@@ -59,6 +66,59 @@
|
||||
#define EGL_PLATFORM_GBM_KHR 0x31D7
|
||||
#endif
|
||||
|
||||
#elif defined(_WIN32)
|
||||
|
||||
#ifndef WIN32_LEAN_AND_MEAN
|
||||
#define WIN32_LEAN_AND_MEAN
|
||||
#endif
|
||||
#ifndef NOMINMAX
|
||||
#define NOMINMAX
|
||||
#endif
|
||||
#include <windows.h>
|
||||
|
||||
#include <GL/gl.h>
|
||||
|
||||
#include <cstdint>
|
||||
#include <cstdio>
|
||||
|
||||
/* The Windows SDK ships GL 1.1 headers only; everything newer that the
|
||||
* render pipeline touches is declared below and resolved at runtime. */
|
||||
#ifndef GL_BGRA
|
||||
#define GL_BGRA 0x80E1
|
||||
#endif
|
||||
#ifndef GL_UNSIGNED_INT_8_8_8_8_REV
|
||||
#define GL_UNSIGNED_INT_8_8_8_8_REV 0x8367
|
||||
#endif
|
||||
#ifndef GL_FRAMEBUFFER
|
||||
#define GL_FRAMEBUFFER 0x8D40
|
||||
#endif
|
||||
#ifndef GL_READ_FRAMEBUFFER
|
||||
#define GL_READ_FRAMEBUFFER 0x8CA8
|
||||
#endif
|
||||
#ifndef GL_COLOR_ATTACHMENT0
|
||||
#define GL_COLOR_ATTACHMENT0 0x8CE0
|
||||
#endif
|
||||
#ifndef GL_FRAMEBUFFER_COMPLETE
|
||||
#define GL_FRAMEBUFFER_COMPLETE 0x8CD5
|
||||
#endif
|
||||
#ifndef GL_PIXEL_PACK_BUFFER
|
||||
#define GL_PIXEL_PACK_BUFFER 0x88EB
|
||||
#endif
|
||||
#ifndef GL_STREAM_READ
|
||||
#define GL_STREAM_READ 0x88E1
|
||||
#endif
|
||||
#ifndef GL_MAP_READ_BIT
|
||||
#define GL_MAP_READ_BIT 0x0001
|
||||
#endif
|
||||
|
||||
typedef ptrdiff_t GLsizeiptr;
|
||||
typedef ptrdiff_t GLintptr;
|
||||
|
||||
#define WGL_CONTEXT_MAJOR_VERSION_ARB 0x2091
|
||||
#define WGL_CONTEXT_MINOR_VERSION_ARB 0x2092
|
||||
#define WGL_CONTEXT_PROFILE_MASK_ARB 0x9126
|
||||
#define WGL_CONTEXT_CORE_PROFILE_BIT_ARB 0x00000001
|
||||
|
||||
#endif
|
||||
|
||||
namespace frame_helper {
|
||||
@@ -512,6 +572,222 @@ private:
|
||||
std::string renderer_;
|
||||
};
|
||||
|
||||
#elif defined(_WIN32)
|
||||
|
||||
/* Post-1.1 GL entry points used by frame_helper_render.h, with their real
|
||||
* names so the shared render code compiles unchanged. APIENTRY (stdcall)
|
||||
* must match the driver exports. Single-TU helper; inline for safety. */
|
||||
#define FRAME_HELPER_WGL_ENTRY_POINTS(X) \
|
||||
X(void, glGenFramebuffers, (GLsizei n, GLuint* out)) \
|
||||
X(void, glDeleteFramebuffers, (GLsizei n, const GLuint* ids)) \
|
||||
X(void, glBindFramebuffer, (GLenum target, GLuint fbo)) \
|
||||
X(void, glFramebufferTexture2D, \
|
||||
(GLenum target, GLenum attachment, GLenum textarget, GLuint texture, \
|
||||
GLint level)) \
|
||||
X(GLenum, glCheckFramebufferStatus, (GLenum target)) \
|
||||
X(void, glGenBuffers, (GLsizei n, GLuint* out)) \
|
||||
X(void, glDeleteBuffers, (GLsizei n, const GLuint* ids)) \
|
||||
X(void, glBindBuffer, (GLenum target, GLuint buffer)) \
|
||||
X(void, glBufferData, \
|
||||
(GLenum target, GLsizeiptr size, const void* data, GLenum usage)) \
|
||||
X(void*, glMapBufferRange, \
|
||||
(GLenum target, GLintptr offset, GLsizeiptr length, GLbitfield access)) \
|
||||
X(GLboolean, glUnmapBuffer, (GLenum target))
|
||||
|
||||
#define FRAME_HELPER_WGL_DECLARE(ret, name, args) \
|
||||
inline ret(APIENTRY* name) args = nullptr;
|
||||
FRAME_HELPER_WGL_ENTRY_POINTS(FRAME_HELPER_WGL_DECLARE)
|
||||
#undef FRAME_HELPER_WGL_DECLARE
|
||||
|
||||
typedef HGLRC(WINAPI* FrameHelperWglCreateContextAttribsFn)(HDC, HGLRC,
|
||||
const int*);
|
||||
|
||||
/* wglGetProcAddress covers post-1.1 symbols but returns sentinel values for
|
||||
* the 1.1 core set, which lives in opengl32.dll instead. */
|
||||
inline void* wglLoaderGetProcAddress(void* ctx, const char* name) {
|
||||
PROC proc = wglGetProcAddress(name);
|
||||
const intptr_t sentinel = reinterpret_cast<intptr_t>(proc);
|
||||
if (proc && sentinel != 1 && sentinel != 2 && sentinel != 3 &&
|
||||
sentinel != -1) {
|
||||
return reinterpret_cast<void*>(proc);
|
||||
}
|
||||
return reinterpret_cast<void*>(
|
||||
GetProcAddress(static_cast<HMODULE>(ctx), name));
|
||||
}
|
||||
|
||||
class GlContext {
|
||||
public:
|
||||
bool create(std::string& errorOut) {
|
||||
opengl32_ = GetModuleHandleA("opengl32.dll");
|
||||
if (!opengl32_) {
|
||||
errorOut = "opengl32.dll is not loaded";
|
||||
return false;
|
||||
}
|
||||
|
||||
WNDCLASSA windowClass = {};
|
||||
windowClass.style = CS_OWNDC;
|
||||
windowClass.lpfnWndProc = DefWindowProcA;
|
||||
windowClass.hInstance = GetModuleHandleA(nullptr);
|
||||
windowClass.lpszClassName = "iptvnator_mpv_helper_gl";
|
||||
/* Re-registration fails harmlessly if a previous context leaked the
|
||||
* class; CreateWindow below is the real gate. */
|
||||
RegisterClassA(&windowClass);
|
||||
window_ = CreateWindowExA(0, windowClass.lpszClassName, "", WS_POPUP,
|
||||
0, 0, 1, 1, nullptr, nullptr,
|
||||
windowClass.hInstance, nullptr);
|
||||
if (!window_) {
|
||||
errorOut = "failed to create the hidden GL window";
|
||||
return false;
|
||||
}
|
||||
dc_ = GetDC(window_);
|
||||
if (!dc_) {
|
||||
errorOut = "failed to get a DC for the hidden GL window";
|
||||
return false;
|
||||
}
|
||||
|
||||
PIXELFORMATDESCRIPTOR descriptor = {};
|
||||
descriptor.nSize = sizeof(descriptor);
|
||||
descriptor.nVersion = 1;
|
||||
descriptor.dwFlags = PFD_SUPPORT_OPENGL | PFD_DRAW_TO_WINDOW;
|
||||
descriptor.iPixelType = PFD_TYPE_RGBA;
|
||||
descriptor.cColorBits = 32;
|
||||
descriptor.cAlphaBits = 8;
|
||||
descriptor.iLayerType = PFD_MAIN_PLANE;
|
||||
const int pixelFormat = ChoosePixelFormat(dc_, &descriptor);
|
||||
if (pixelFormat == 0 ||
|
||||
SetPixelFormat(dc_, pixelFormat, &descriptor) != TRUE) {
|
||||
errorOut = "no usable pixel format for the hidden GL window";
|
||||
return false;
|
||||
}
|
||||
|
||||
HGLRC legacy = wglCreateContext(dc_);
|
||||
if (!legacy) {
|
||||
errorOut = "failed to create a legacy WGL context";
|
||||
return false;
|
||||
}
|
||||
if (wglMakeCurrent(dc_, legacy) != TRUE) {
|
||||
if (wglDeleteContext(legacy) != TRUE) {
|
||||
std::fprintf(stderr,
|
||||
"wgl create: failed to delete unbound legacy "
|
||||
"context\n");
|
||||
}
|
||||
errorOut = "failed to bind the legacy WGL context";
|
||||
return false;
|
||||
}
|
||||
|
||||
const auto createContextAttribs =
|
||||
reinterpret_cast<FrameHelperWglCreateContextAttribsFn>(
|
||||
wglLoaderGetProcAddress(opengl32_,
|
||||
"wglCreateContextAttribsARB"));
|
||||
if (createContextAttribs) {
|
||||
const int attribs[] = {
|
||||
WGL_CONTEXT_MAJOR_VERSION_ARB, 3,
|
||||
WGL_CONTEXT_MINOR_VERSION_ARB, 2,
|
||||
WGL_CONTEXT_PROFILE_MASK_ARB, WGL_CONTEXT_CORE_PROFILE_BIT_ARB,
|
||||
0,
|
||||
};
|
||||
HGLRC core = createContextAttribs(dc_, nullptr, attribs);
|
||||
if (core) {
|
||||
if (wglMakeCurrent(dc_, core) != TRUE) {
|
||||
if (wglDeleteContext(core) != TRUE) {
|
||||
std::fprintf(
|
||||
stderr,
|
||||
"wgl create: failed to delete unbound core "
|
||||
"context\n");
|
||||
}
|
||||
if (wglGetCurrentContext() != legacy &&
|
||||
wglMakeCurrent(dc_, legacy) != TRUE) {
|
||||
errorOut =
|
||||
"failed to restore the legacy WGL context";
|
||||
return false;
|
||||
}
|
||||
context_ = legacy;
|
||||
} else {
|
||||
context_ = core;
|
||||
if (wglDeleteContext(legacy) != TRUE) {
|
||||
std::fprintf(
|
||||
stderr,
|
||||
"wgl create: failed to delete superseded legacy "
|
||||
"context\n");
|
||||
}
|
||||
}
|
||||
} else {
|
||||
/* Pre-3.2 driver: keep the legacy (compatibility) context;
|
||||
* the entry-point loading below decides adequacy. */
|
||||
context_ = legacy;
|
||||
}
|
||||
} else {
|
||||
context_ = legacy;
|
||||
}
|
||||
|
||||
bool loaded = true;
|
||||
const char* missing = nullptr;
|
||||
#define FRAME_HELPER_WGL_LOAD(ret, name, args) \
|
||||
name = reinterpret_cast<ret(APIENTRY*) args>( \
|
||||
wglLoaderGetProcAddress(opengl32_, #name)); \
|
||||
if (!name && loaded) { \
|
||||
loaded = false; \
|
||||
missing = #name; \
|
||||
}
|
||||
FRAME_HELPER_WGL_ENTRY_POINTS(FRAME_HELPER_WGL_LOAD)
|
||||
#undef FRAME_HELPER_WGL_LOAD
|
||||
if (!loaded) {
|
||||
errorOut = std::string("missing GL entry point: ") + missing;
|
||||
if (wglMakeCurrent(nullptr, nullptr) != TRUE) {
|
||||
errorOut += "; failed to release the WGL context";
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/* Unbind before returning — see the threading contract above. */
|
||||
if (wglMakeCurrent(nullptr, nullptr) != TRUE) {
|
||||
errorOut =
|
||||
"failed to release the WGL context for render-thread handoff";
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool makeCurrent(std::string& errorOut) {
|
||||
if (!dc_ || !context_) {
|
||||
errorOut = "WGL render context is unavailable";
|
||||
return false;
|
||||
}
|
||||
if (wglMakeCurrent(dc_, context_) != TRUE) {
|
||||
errorOut = "failed to bind the WGL render context";
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
void destroy() {
|
||||
if (context_ && wglMakeCurrent(nullptr, nullptr) != TRUE) {
|
||||
std::fprintf(stderr,
|
||||
"wgl teardown: failed to release current context\n");
|
||||
return;
|
||||
}
|
||||
if (context_ && wglDeleteContext(context_) != TRUE) {
|
||||
std::fprintf(stderr, "wgl teardown: failed to delete context\n");
|
||||
return;
|
||||
}
|
||||
context_ = nullptr;
|
||||
/* The hidden window/class belong to the main thread (DestroyWindow
|
||||
* is thread-affine and this runs on the render thread); the process
|
||||
* exits right after, so they are left to OS teardown. */
|
||||
dc_ = nullptr;
|
||||
window_ = nullptr;
|
||||
}
|
||||
|
||||
GlGetProcAddressFn procLoader() const { return wglLoaderGetProcAddress; }
|
||||
void* procLoaderCtx() const { return opengl32_; }
|
||||
|
||||
private:
|
||||
HWND window_ = nullptr;
|
||||
HDC dc_ = nullptr;
|
||||
HGLRC context_ = nullptr;
|
||||
HMODULE opengl32_ = nullptr;
|
||||
};
|
||||
|
||||
#else
|
||||
#error "frame_helper_gl.h has no GL context implementation for this platform"
|
||||
#endif
|
||||
|
||||
@@ -14,9 +14,11 @@
|
||||
#include <mpv/client.h>
|
||||
#include <mpv/render_gl.h>
|
||||
|
||||
#if !defined(_WIN32)
|
||||
#include <fcntl.h>
|
||||
#include <sys/mman.h>
|
||||
#include <unistd.h>
|
||||
#endif
|
||||
|
||||
#include <atomic>
|
||||
#include <chrono>
|
||||
@@ -39,19 +41,47 @@ struct ShmRing {
|
||||
uint8_t* base = nullptr;
|
||||
size_t size = 0;
|
||||
std::string name;
|
||||
#if defined(_WIN32)
|
||||
/* Named sections are refcounted: this handle keeps the mapping alive
|
||||
* for the helper's lifetime; readers holding views keep the memory
|
||||
* valid after the helper exits, matching unlinked-POSIX-shm semantics. */
|
||||
HANDLE mapping = nullptr;
|
||||
#endif
|
||||
|
||||
bool create(const std::string& shmName, int width, int height,
|
||||
uint32_t generation) {
|
||||
destroy();
|
||||
shm_unlink(shmName.c_str());
|
||||
const int fd = shm_open(shmName.c_str(), O_CREAT | O_EXCL | O_RDWR, 0600);
|
||||
if (fd < 0) return false;
|
||||
const uint64_t frameBytes = (uint64_t)width * 4u * (uint64_t)height;
|
||||
const uint64_t dataOffset =
|
||||
(sizeof(FrameShmHeader) + FRAME_SHM_DATA_ALIGN - 1) &
|
||||
~(uint64_t)(FRAME_SHM_DATA_ALIGN - 1);
|
||||
const size_t total =
|
||||
(size_t)(dataOffset + FRAME_SHM_RING_SLOTS * frameBytes);
|
||||
#if defined(_WIN32)
|
||||
char mappingName[256];
|
||||
frame_shm_windows_name(shmName.c_str(), mappingName,
|
||||
sizeof(mappingName));
|
||||
HANDLE created = CreateFileMappingA(
|
||||
INVALID_HANDLE_VALUE, nullptr, PAGE_READWRITE,
|
||||
(DWORD)(total >> 32), (DWORD)(total & 0xffffffffu), mappingName);
|
||||
if (!created) return false;
|
||||
if (GetLastError() == ERROR_ALREADY_EXISTS) {
|
||||
/* Never adopt an existing section: its layout is unknown. The
|
||||
* per-session UUID + generation suffix make collisions mean a
|
||||
* bug, not a retry. */
|
||||
CloseHandle(created);
|
||||
return false;
|
||||
}
|
||||
void* mapped = MapViewOfFile(created, FILE_MAP_ALL_ACCESS, 0, 0, 0);
|
||||
if (!mapped) {
|
||||
CloseHandle(created);
|
||||
return false;
|
||||
}
|
||||
mapping = created;
|
||||
#else
|
||||
shm_unlink(shmName.c_str());
|
||||
const int fd = shm_open(shmName.c_str(), O_CREAT | O_EXCL | O_RDWR, 0600);
|
||||
if (fd < 0) return false;
|
||||
if (ftruncate(fd, (off_t)total) != 0) {
|
||||
close(fd);
|
||||
shm_unlink(shmName.c_str());
|
||||
@@ -64,6 +94,7 @@ struct ShmRing {
|
||||
shm_unlink(shmName.c_str());
|
||||
return false;
|
||||
}
|
||||
#endif
|
||||
std::memset(mapped, 0, sizeof(FrameShmHeader));
|
||||
auto* hdr = static_cast<FrameShmHeader*>(mapped);
|
||||
hdr->version = FRAME_SHM_VERSION;
|
||||
@@ -89,10 +120,16 @@ struct ShmRing {
|
||||
}
|
||||
|
||||
void destroy() {
|
||||
#if defined(_WIN32)
|
||||
if (base) UnmapViewOfFile(base);
|
||||
if (mapping) CloseHandle(mapping);
|
||||
mapping = nullptr;
|
||||
#else
|
||||
if (base) {
|
||||
munmap(base, size);
|
||||
shm_unlink(name.c_str());
|
||||
}
|
||||
#endif
|
||||
header = nullptr;
|
||||
base = nullptr;
|
||||
size = 0;
|
||||
|
||||
@@ -6,14 +6,29 @@
|
||||
* resize creates a fresh shm segment named `<base>-g<generation>` and the
|
||||
* reader re-attaches when the helper announces the new generation.
|
||||
*
|
||||
* Must compile as C11 (reader addon) and C++17 (helper). Both consumers
|
||||
* build with node-gyp's GNU dialects; frame_shm_now_ns() relies on POSIX
|
||||
* clock_gettime, which strict -std=c11 (__STRICT_ANSI__) would hide.
|
||||
* Must compile as C11 (reader addon) and C++17 (helper) on POSIX; on
|
||||
* Windows both consumers build as C++ (MSVC's C mode lacks <stdatomic.h>).
|
||||
* POSIX builds use node-gyp's GNU dialects; frame_shm_now_ns() relies on
|
||||
* clock_gettime there, which strict -std=c11 (__STRICT_ANSI__) would hide.
|
||||
*/
|
||||
#pragma once
|
||||
|
||||
#include <stdint.h>
|
||||
|
||||
#if defined(_WIN32)
|
||||
#ifndef WIN32_LEAN_AND_MEAN
|
||||
#define WIN32_LEAN_AND_MEAN
|
||||
#endif
|
||||
#ifndef NOMINMAX
|
||||
#define NOMINMAX
|
||||
#endif
|
||||
#include <windows.h>
|
||||
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
#else
|
||||
#include <time.h>
|
||||
#endif
|
||||
|
||||
#ifdef __cplusplus
|
||||
#include <atomic>
|
||||
@@ -25,14 +40,44 @@ typedef _Atomic uint64_t frame_shm_atomic_u64;
|
||||
|
||||
/* Monotonic clock for produce_time_ns/heartbeat_ns. Producer (helper) and
|
||||
* consumer (reader addon) MUST use this same clock so age math stays valid.
|
||||
* CLOCK_MONOTONIC is portable across macOS (10.12+) and Linux; the Windows
|
||||
* port will need a QueryPerformanceCounter shim here. */
|
||||
* POSIX: CLOCK_MONOTONIC (macOS 10.12+, Linux). Windows: QPC, scaled to ns
|
||||
* without overflow by splitting whole seconds from the remainder. */
|
||||
static inline uint64_t frame_shm_now_ns(void) {
|
||||
#if defined(_WIN32)
|
||||
/* C++11 function-local initialization is synchronized, so the helper's
|
||||
* render/event threads cannot race while caching the boot-stable QPF. */
|
||||
static const uint64_t frequency = []() -> uint64_t {
|
||||
LARGE_INTEGER value;
|
||||
if (QueryPerformanceFrequency(&value) != TRUE ||
|
||||
value.QuadPart <= 0) {
|
||||
return 1;
|
||||
}
|
||||
return (uint64_t)value.QuadPart;
|
||||
}();
|
||||
LARGE_INTEGER counter;
|
||||
QueryPerformanceCounter(&counter);
|
||||
const uint64_t ticks = (uint64_t)counter.QuadPart;
|
||||
return (ticks / frequency) * 1000000000ull +
|
||||
(ticks % frequency) * 1000000000ull / frequency;
|
||||
#else
|
||||
struct timespec ts;
|
||||
clock_gettime(CLOCK_MONOTONIC, &ts);
|
||||
return (uint64_t)ts.tv_sec * 1000000000ull + (uint64_t)ts.tv_nsec;
|
||||
#endif
|
||||
}
|
||||
|
||||
#if defined(_WIN32)
|
||||
/* The stdio protocol carries POSIX-style names ("/impv-...-gN") on every
|
||||
* platform so the TypeScript layer stays platform-agnostic; on Windows the
|
||||
* helper (create) and reader (open) both derive the actual file-mapping
|
||||
* object name from it: session-local namespace, slashes stripped. */
|
||||
static inline void frame_shm_windows_name(const char* posix_name, char* out,
|
||||
size_t out_size) {
|
||||
while (*posix_name == '/') posix_name++;
|
||||
snprintf(out, out_size, "Local\\%s", posix_name);
|
||||
}
|
||||
#endif
|
||||
|
||||
#define FRAME_SHM_MAGIC 0x564d5046u /* 'FPMV' */
|
||||
#define FRAME_SHM_VERSION 1u
|
||||
#define FRAME_SHM_RING_SLOTS 3u
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/*
|
||||
* iptvnator-mpv-helper — frame-copy embedded MPV helper process
|
||||
* (macOS + Linux; platform GL context in frame_helper_gl.h).
|
||||
* (macOS + Linux + Windows; platform GL context in frame_helper_gl.h).
|
||||
*
|
||||
* One process = one playback session. Owns libmpv end to end: decodes,
|
||||
* renders offscreen at viewport size, publishes BGRA frames into a shared
|
||||
@@ -101,7 +101,11 @@ std::string isoTimestampNow() {
|
||||
char buffer[32];
|
||||
const time_t now = time(nullptr);
|
||||
struct tm utc;
|
||||
#if defined(_WIN32)
|
||||
gmtime_s(&utc, &now);
|
||||
#else
|
||||
gmtime_r(&now, &utc);
|
||||
#endif
|
||||
strftime(buffer, sizeof(buffer), "%Y-%m-%dT%H:%M:%SZ", &utc);
|
||||
return buffer;
|
||||
}
|
||||
@@ -633,7 +637,9 @@ HelperArgs parseArgs(int argc, char** argv) {
|
||||
|
||||
int main(int argc, char** argv) {
|
||||
std::setlocale(LC_NUMERIC, "C");
|
||||
#if !defined(_WIN32)
|
||||
signal(SIGPIPE, SIG_IGN);
|
||||
#endif
|
||||
const HelperArgs args = parseArgs(argc, argv);
|
||||
|
||||
g_state.mpv = mpv_create();
|
||||
@@ -725,9 +731,14 @@ int main(int argc, char** argv) {
|
||||
mpv_render_context_set_update_callback(g_state.pipeline.renderContext(),
|
||||
onRenderUpdate, &g_state.pipeline);
|
||||
|
||||
#if defined(_WIN32)
|
||||
const double helperPid = (double)GetCurrentProcessId();
|
||||
#else
|
||||
const double helperPid = (double)getpid();
|
||||
#endif
|
||||
emitLine(JsonWriter()
|
||||
.str("event", "hello")
|
||||
.num("pid", (double)getpid())
|
||||
.num("pid", helperPid)
|
||||
.num("protocolVersion", 1)
|
||||
.finish());
|
||||
|
||||
|
||||
@@ -7,13 +7,30 @@
|
||||
* memory cage forbids external ArrayBuffers over foreign memory.
|
||||
*
|
||||
* Plain C N-API (no node-addon-api) so it stays ABI-stable and trivial.
|
||||
* macOS and Linux (POSIX shm) — other platforms export an empty object;
|
||||
* the TypeScript side gates on platform before requiring it.
|
||||
* macOS and Linux (POSIX shm) plus Windows (named file mapping; binding.gyp
|
||||
* compiles this file as C++ there because MSVC's C mode has no
|
||||
* <stdatomic.h>) — other platforms export an empty object; the TypeScript
|
||||
* side gates on platform before requiring it.
|
||||
*/
|
||||
#define NAPI_VERSION 8
|
||||
#include <node_api.h>
|
||||
|
||||
#if defined(__APPLE__) || defined(__linux__)
|
||||
#if defined(__APPLE__) || defined(__linux__) || defined(_WIN32)
|
||||
|
||||
#if defined(_WIN32)
|
||||
|
||||
#include <stdint.h>
|
||||
#include <string.h>
|
||||
|
||||
#include "../helper/frame_shm.h"
|
||||
|
||||
/* frame_shm.h typed the fields as std::atomic in C++ mode; keep the shared
|
||||
* atomic_load_explicit call sites below spelling-compatible. */
|
||||
using std::atomic_load_explicit;
|
||||
using std::memory_order_acquire;
|
||||
using std::memory_order_relaxed;
|
||||
|
||||
#else
|
||||
|
||||
#include <fcntl.h>
|
||||
#include <stdatomic.h>
|
||||
@@ -26,6 +43,8 @@
|
||||
|
||||
#include "../helper/frame_shm.h"
|
||||
|
||||
#endif
|
||||
|
||||
static FrameShmHeader* g_header = NULL;
|
||||
static uint8_t* g_base = NULL;
|
||||
static size_t g_size = 0;
|
||||
@@ -35,7 +54,11 @@ static uint64_t now_ns(void) { return frame_shm_now_ns(); }
|
||||
|
||||
static void unmap_current(void) {
|
||||
if (g_base) {
|
||||
#if defined(_WIN32)
|
||||
UnmapViewOfFile(g_base);
|
||||
#else
|
||||
munmap(g_base, g_size);
|
||||
#endif
|
||||
}
|
||||
g_base = NULL;
|
||||
g_header = NULL;
|
||||
@@ -68,6 +91,23 @@ static napi_value Open(napi_env env, napi_callback_info info) {
|
||||
return throw_error(env, "shm name must be a string");
|
||||
}
|
||||
|
||||
#if defined(_WIN32)
|
||||
char mapping_name[280];
|
||||
frame_shm_windows_name(name, mapping_name, sizeof(mapping_name));
|
||||
HANDLE mapping = OpenFileMappingA(FILE_MAP_READ, FALSE, mapping_name);
|
||||
if (!mapping) return throw_error(env, "shm open failed (helper gone?)");
|
||||
void* mapped = MapViewOfFile(mapping, FILE_MAP_READ, 0, 0, 0);
|
||||
/* The view holds its own reference to the section object. */
|
||||
CloseHandle(mapping);
|
||||
if (!mapped) return throw_error(env, "shm map failed");
|
||||
MEMORY_BASIC_INFORMATION region;
|
||||
if (VirtualQuery(mapped, ®ion, sizeof(region)) == 0 ||
|
||||
region.RegionSize < sizeof(FrameShmHeader)) {
|
||||
UnmapViewOfFile(mapped);
|
||||
return throw_error(env, "shm segment too small");
|
||||
}
|
||||
const size_t segment_size = (size_t)region.RegionSize;
|
||||
#else
|
||||
const int fd = shm_open(name, O_RDONLY, 0);
|
||||
if (fd < 0) return throw_error(env, "shm_open failed (helper gone?)");
|
||||
struct stat segment;
|
||||
@@ -80,17 +120,23 @@ static napi_value Open(napi_env env, napi_callback_info info) {
|
||||
mmap(NULL, (size_t)segment.st_size, PROT_READ, MAP_SHARED, fd, 0);
|
||||
close(fd);
|
||||
if (mapped == MAP_FAILED) return throw_error(env, "mmap failed");
|
||||
const size_t segment_size = (size_t)segment.st_size;
|
||||
#endif
|
||||
|
||||
FrameShmHeader* header = (FrameShmHeader*)mapped;
|
||||
if (header->magic != FRAME_SHM_MAGIC ||
|
||||
header->version != FRAME_SHM_VERSION) {
|
||||
munmap(mapped, (size_t)segment.st_size);
|
||||
#if defined(_WIN32)
|
||||
UnmapViewOfFile(mapped);
|
||||
#else
|
||||
munmap(mapped, segment_size);
|
||||
#endif
|
||||
return throw_error(env, "frame ring not initialized yet");
|
||||
}
|
||||
|
||||
unmap_current();
|
||||
g_base = (uint8_t*)mapped;
|
||||
g_size = (size_t)segment.st_size;
|
||||
g_size = segment_size;
|
||||
g_header = header;
|
||||
|
||||
napi_value result;
|
||||
@@ -204,7 +250,7 @@ static napi_value Init(napi_env env, napi_value exports) {
|
||||
return exports;
|
||||
}
|
||||
|
||||
#else /* neither __APPLE__ nor __linux__ */
|
||||
#else /* no frame-copy port for this platform */
|
||||
|
||||
static napi_value Init(napi_env env, napi_value exports) {
|
||||
/* Frame-copy is not ported to this platform yet; loading this module
|
||||
|
||||
@@ -52,7 +52,16 @@
|
||||
".deps/**",
|
||||
"obj.target/**",
|
||||
"**/.deps/**",
|
||||
"**/obj.target/**"
|
||||
"**/obj.target/**",
|
||||
"obj/**",
|
||||
"**/obj/**",
|
||||
"**/*.pdb",
|
||||
"**/*.iobj",
|
||||
"**/*.ipdb",
|
||||
"**/*.ilk",
|
||||
"**/*.exp",
|
||||
"**/*.lib",
|
||||
"**/*.dll.a"
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -105,7 +114,16 @@
|
||||
".deps/**",
|
||||
"obj.target/**",
|
||||
"**/.deps/**",
|
||||
"**/obj.target/**"
|
||||
"**/obj.target/**",
|
||||
"obj/**",
|
||||
"**/obj/**",
|
||||
"**/*.pdb",
|
||||
"**/*.iobj",
|
||||
"**/*.ipdb",
|
||||
"**/*.ilk",
|
||||
"**/*.exp",
|
||||
"**/*.lib",
|
||||
"**/*.dll.a"
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
+10
-3
@@ -32,7 +32,8 @@ describe('embedded-mpv-frame-copy-platform.util', () => {
|
||||
['darwin', 'x64', false],
|
||||
['linux', 'x64', true],
|
||||
['linux', 'arm64', true],
|
||||
['win32', 'x64', false],
|
||||
['win32', 'x64', true],
|
||||
['freebsd', 'x64', false],
|
||||
])('%s/%s -> %s', (platform, arch, expected) => {
|
||||
Object.defineProperty(process, 'platform', { value: platform });
|
||||
Object.defineProperty(process, 'arch', { value: arch });
|
||||
@@ -54,8 +55,14 @@ describe('embedded-mpv-frame-copy-platform.util', () => {
|
||||
'build',
|
||||
'Release'
|
||||
);
|
||||
// The resolver looks for the host platform's binary name, so the
|
||||
// fixture must follow it for the spec to stay host-agnostic.
|
||||
const helperFileName = () =>
|
||||
process.platform === 'win32'
|
||||
? 'iptvnator_mpv_helper.exe'
|
||||
: 'iptvnator_mpv_helper';
|
||||
const helperPath = () =>
|
||||
path.join(releaseDir(), 'iptvnator_mpv_helper');
|
||||
path.join(releaseDir(), helperFileName());
|
||||
const readerPath = () =>
|
||||
path.join(releaseDir(), 'embedded_mpv_frame_reader.node');
|
||||
|
||||
@@ -134,7 +141,7 @@ describe('embedded-mpv-frame-copy-platform.util', () => {
|
||||
mkdirSync(packagedNativeDir, { recursive: true });
|
||||
const packagedHelper = path.join(
|
||||
packagedNativeDir,
|
||||
'iptvnator_mpv_helper'
|
||||
helperFileName()
|
||||
);
|
||||
writeFileSync(packagedHelper, '#!/bin/sh\n');
|
||||
chmodSync(packagedHelper, 0o755);
|
||||
|
||||
@@ -8,12 +8,17 @@ import path from 'path';
|
||||
* sandbox and engine decisions cannot drift. These helpers must remain
|
||||
* callable before app.whenReady().
|
||||
*
|
||||
* macOS stays Apple-Silicon-only. Linux supports every architecture because
|
||||
* its helper renders through headless EGL and links libmpv out of process.
|
||||
* macOS: Apple Silicon only (owner decision 2026-07-10) — Intel Macs keep
|
||||
* the docked native engine. Linux: any arch — the helper renders offscreen
|
||||
* through headless EGL and links libmpv out of process, so neither window
|
||||
* embedding nor the in-process-libmpv ban constrains it. Windows: any arch
|
||||
* with a helper binary (WGL offscreen render; in practice x64, the only
|
||||
* vendored runtime) — the helper-presence check below is the real gate.
|
||||
*/
|
||||
export function isFrameCopyPlatformSupported(): boolean {
|
||||
return (
|
||||
process.platform === 'linux' ||
|
||||
process.platform === 'win32' ||
|
||||
(process.platform === 'darwin' && process.arch === 'arm64')
|
||||
);
|
||||
}
|
||||
@@ -80,7 +85,8 @@ export function getEmbeddedMpvAddonCandidatePaths(): string[] {
|
||||
/**
|
||||
* Resolve the first executable frame-copy helper with a readable regular-file
|
||||
* frame reader beside it. Both artifacts are required before the engine may
|
||||
* relax the renderer sandbox.
|
||||
* relax the renderer sandbox. Windows uses the `.exe` helper name; X_OK is an
|
||||
* existence check there, while POSIX platforms also require the execute bit.
|
||||
*/
|
||||
export function resolveFrameCopyHelperPath(): string | null {
|
||||
// Packaged startup must never fall through to cwd/dist development
|
||||
@@ -89,12 +95,15 @@ export function resolveFrameCopyHelperPath(): string | null {
|
||||
const addonCandidates = app.isPackaged
|
||||
? getPackagedAddonPaths()
|
||||
: [getLocalBuildAddonPath(), ...getDistAddonPaths()];
|
||||
|
||||
const helperFileName =
|
||||
process.platform === 'win32'
|
||||
? 'iptvnator_mpv_helper.exe'
|
||||
: 'iptvnator_mpv_helper';
|
||||
return (
|
||||
addonCandidates
|
||||
.map((candidatePath) => path.dirname(candidatePath))
|
||||
.map((nativeDir) => ({
|
||||
helper: path.join(nativeDir, 'iptvnator_mpv_helper'),
|
||||
helper: path.join(nativeDir, helperFileName),
|
||||
reader: path.join(
|
||||
nativeDir,
|
||||
'embedded_mpv_frame_reader.node'
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { EventEmitter } from 'events';
|
||||
import path from 'path';
|
||||
|
||||
const spawnMock = jest.fn();
|
||||
jest.mock('child_process', () => ({
|
||||
@@ -117,8 +118,10 @@ describe('EmbeddedMpvFrameCopyAdapter', () => {
|
||||
expect(frameSourceChanges).toEqual([
|
||||
{ sessionId, shmName: `/${sessionId}-g1` },
|
||||
]);
|
||||
// path.join output is host-specific; build the expectation the
|
||||
// same way so the spec passes on Windows checkouts too.
|
||||
expect(adapter.getFrameSource(sessionId)?.readerPath).toBe(
|
||||
'/native/embedded_mpv_frame_reader.node'
|
||||
path.join('/native', 'embedded_mpv_frame_reader.node')
|
||||
);
|
||||
});
|
||||
|
||||
@@ -190,7 +193,8 @@ describe('EmbeddedMpvFrameCopyAdapter', () => {
|
||||
['darwin', 'x64', false],
|
||||
['linux', 'x64', true],
|
||||
['linux', 'arm64', true],
|
||||
['win32', 'x64', false],
|
||||
['win32', 'x64', true],
|
||||
['freebsd', 'x64', false],
|
||||
])(
|
||||
'on %s/%s with a helper binary present -> %s',
|
||||
(platform, arch, expected) => {
|
||||
|
||||
@@ -40,19 +40,23 @@ describe('Embedded MPV native source recording invariants', () => {
|
||||
'utf8'
|
||||
);
|
||||
const frameHelperRenderSource = readFileSync(
|
||||
path.resolve(
|
||||
__dirname,
|
||||
'../../../native/helper/frame_helper_render.h'
|
||||
),
|
||||
path.resolve(__dirname, '../../../native/helper/frame_helper_render.h'),
|
||||
'utf8'
|
||||
);
|
||||
const frameHelperGlSource = readFileSync(
|
||||
path.resolve(__dirname, '../../../native/helper/frame_helper_gl.h'),
|
||||
'utf8'
|
||||
);
|
||||
const frameShmSource = readFileSync(
|
||||
path.resolve(__dirname, '../../../native/helper/frame_shm.h'),
|
||||
'utf8'
|
||||
);
|
||||
const linuxFrameHelperGlSource = frameHelperGlSource.slice(
|
||||
frameHelperGlSource.indexOf('inline void* eglWrapGetProcAddress')
|
||||
);
|
||||
const windowsFrameHelperGlSource = frameHelperGlSource.slice(
|
||||
frameHelperGlSource.indexOf('#define FRAME_HELPER_WGL_ENTRY_POINTS')
|
||||
);
|
||||
|
||||
function functionBody(name: string): string {
|
||||
return sourceFunctionBody(nativeSource, `Napi::Value ${name}(`, name);
|
||||
@@ -304,9 +308,7 @@ describe('Embedded MPV native source recording invariants', () => {
|
||||
});
|
||||
|
||||
it('copies Windows runtime DLLs next to the addon for Windows loader lookup', () => {
|
||||
expect(buildScriptSource).toContain(
|
||||
"'libmpv-2.dll',"
|
||||
);
|
||||
expect(buildScriptSource).toContain("'libmpv-2.dll',");
|
||||
expect(buildScriptSource).toContain(
|
||||
'path.join(outputDir, windowsDllName)'
|
||||
);
|
||||
@@ -719,6 +721,57 @@ describe('Embedded MPV native source recording invariants', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('checks WGL context handoff and never deletes a context after failed unbind', () => {
|
||||
const create = sourceFunctionBody(
|
||||
windowsFrameHelperGlSource,
|
||||
'bool create(std::string& errorOut)',
|
||||
'WGL GlContext::create'
|
||||
);
|
||||
const makeCurrent = sourceFunctionBody(
|
||||
windowsFrameHelperGlSource,
|
||||
'bool makeCurrent(std::string& errorOut)',
|
||||
'WGL GlContext::makeCurrent'
|
||||
);
|
||||
const destroy = sourceFunctionBody(
|
||||
windowsFrameHelperGlSource,
|
||||
'void destroy()',
|
||||
'WGL GlContext::destroy'
|
||||
);
|
||||
|
||||
expect(create).toContain('wglMakeCurrent(dc_, core) != TRUE');
|
||||
expect(create).toContain('wglMakeCurrent(nullptr, nullptr) != TRUE');
|
||||
expect(create).toContain('wglLoaderGetProcAddress(opengl32_,');
|
||||
expect(create).toContain('"wglCreateContextAttribsARB")');
|
||||
expect(create).not.toContain(
|
||||
'wglGetProcAddress("wglCreateContextAttribsARB")'
|
||||
);
|
||||
expect(makeCurrent).toContain('wglMakeCurrent(dc_, context_) != TRUE');
|
||||
expect(makeCurrent).toContain('return false;');
|
||||
|
||||
const unbindIndex = destroy.indexOf(
|
||||
'wglMakeCurrent(nullptr, nullptr) != TRUE'
|
||||
);
|
||||
const deleteIndex = destroy.indexOf(
|
||||
'wglDeleteContext(context_) != TRUE'
|
||||
);
|
||||
expect(unbindIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(deleteIndex).toBeGreaterThan(unbindIndex);
|
||||
expect(destroy.slice(0, deleteIndex)).toContain('return;');
|
||||
});
|
||||
|
||||
it('initializes the Windows QPC frequency without a cross-thread data race', () => {
|
||||
const nowNs = sourceFunctionBody(
|
||||
frameShmSource,
|
||||
'static inline uint64_t frame_shm_now_ns(void)',
|
||||
'frame_shm_now_ns'
|
||||
);
|
||||
|
||||
expect(nowNs).toContain(
|
||||
'static const uint64_t frequency = []() -> uint64_t'
|
||||
);
|
||||
expect(nowNs).not.toContain('static uint64_t frequency;');
|
||||
});
|
||||
|
||||
it('resolves linked core GL symbols before falling back to EGL extension lookup', () => {
|
||||
const dlsymIndex = linuxFrameHelperGlSource.indexOf(
|
||||
'dlsym(RTLD_DEFAULT, name)'
|
||||
|
||||
@@ -111,9 +111,9 @@ export class EmbeddedMpvNativeService {
|
||||
|
||||
/**
|
||||
* Frame-copy engine: helper process + shm ring + renderer canvas.
|
||||
* Experimental, macOS Apple Silicon (owner decision 2026-07-10) and
|
||||
* Linux, opted into with IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY=1 on
|
||||
* top of the regular embedded MPV experiment flag.
|
||||
* Experimental, macOS Apple Silicon (owner decision 2026-07-10), Linux
|
||||
* and Windows, opted into with IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY=1
|
||||
* on top of the regular embedded MPV experiment flag.
|
||||
*/
|
||||
private isFrameCopyEngineRequested(): boolean {
|
||||
return ['1', 'true', 'yes', 'on'].includes(
|
||||
@@ -215,9 +215,9 @@ export class EmbeddedMpvNativeService {
|
||||
};
|
||||
}
|
||||
|
||||
// The frame-copy engine renders offscreen (headless EGL on Linux)
|
||||
// into a renderer canvas: the Linux X11/Xwayland and system-mpv
|
||||
// requirements below only bind the native --wid engine. Both
|
||||
// The frame-copy engine renders offscreen (headless EGL on Linux,
|
||||
// WGL on Windows) into a renderer canvas: the Linux X11/Xwayland and
|
||||
// system-mpv requirements below only bind the native --wid engine. Both
|
||||
// native-engine failure returns still advertise frameCopyAvailable
|
||||
// so the Settings toggle stays reachable — otherwise the states the
|
||||
// frame-copy engine exists to fix would hide the way to enable it.
|
||||
|
||||
@@ -184,7 +184,9 @@
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (supportsExternalPlayerPathSettings() && form().value.player === 'mpv') {
|
||||
@if (
|
||||
supportsExternalPlayerPathSettings() && form().value.player === 'mpv'
|
||||
) {
|
||||
<div class="setting-item">
|
||||
<div class="setting-item__meta">
|
||||
<h4>{{ 'SETTINGS.MPV_PLAYER_PATH_LABEL' | translate }}</h4>
|
||||
@@ -256,7 +258,9 @@
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (supportsExternalPlayerPathSettings() && form().value.player === 'vlc') {
|
||||
@if (
|
||||
supportsExternalPlayerPathSettings() && form().value.player === 'vlc'
|
||||
) {
|
||||
<div class="setting-item">
|
||||
<div class="setting-item__meta">
|
||||
<h4>{{ 'SETTINGS.VLC_PLAYER_PATH_LABEL' | translate }}</h4>
|
||||
|
||||
@@ -171,6 +171,30 @@ describe('SettingsPlaybackSectionComponent', () => {
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it.each<[string, boolean, boolean, boolean, boolean]>([
|
||||
['shows the available desktop engine', true, true, false, true],
|
||||
['keeps a stale desktop opt-in clearable', true, false, true, true],
|
||||
['hides a stale opt-in in the PWA', false, false, true, false],
|
||||
])(
|
||||
'%s',
|
||||
(_description, isDesktop, frameCopyAvailable, stored, expected) => {
|
||||
const form = createForm();
|
||||
form.controls['embeddedMpvFrameCopy'].setValue(stored);
|
||||
fixture.componentRef.setInput('form', form);
|
||||
fixture.componentRef.setInput('isDesktop', isDesktop);
|
||||
fixture.componentRef.setInput(
|
||||
'frameCopyAvailable',
|
||||
frameCopyAvailable
|
||||
);
|
||||
fixture.detectChanges();
|
||||
|
||||
const setting = fixture.nativeElement.querySelector(
|
||||
'[data-test-id="embedded-mpv-frame-copy-setting"]'
|
||||
);
|
||||
expect(Boolean(setting)).toBe(expected);
|
||||
}
|
||||
);
|
||||
|
||||
it('shows the recording folder setting only in desktop builds', () => {
|
||||
fixture.componentRef.setInput('isDesktop', true);
|
||||
fixture.detectChanges();
|
||||
@@ -364,6 +388,7 @@ function createForm(player = VideoPlayer.VideoJs): FormGroup {
|
||||
streamFormat: new FormControl(StreamFormat.AutoStreamFormat),
|
||||
openStreamOnDoubleClick: new FormControl(false),
|
||||
showExternalPlaybackBar: new FormControl(true),
|
||||
embeddedMpvFrameCopy: new FormControl(false),
|
||||
mpvPlayerPath: new FormControl(''),
|
||||
mpvPlayerArguments: new FormControl(''),
|
||||
mpvReuseInstance: new FormControl(false),
|
||||
|
||||
@@ -430,7 +430,7 @@
|
||||
"APP_UPDATE_OPEN_RELEASE": "Open GitHub release",
|
||||
"APP_UPDATE_RELEASE_NOTES": "What's new",
|
||||
"EMBEDDED_MPV_FRAME_COPY": "MPV المدمج: محرك نسخ الإطارات (تجريبي)",
|
||||
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "يُرسم الفيديو على لوحة التطبيق بحيث تظهر القوائم ومربعات الحوار فوقه. متاح فقط على macOS بمعالج Apple Silicon وعلى Linux.",
|
||||
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "يُرسم الفيديو على لوحة التطبيق بحيث تظهر القوائم ومربعات الحوار فوقه.",
|
||||
"EMBEDDED_MPV_FRAME_COPY_RESTART": "يسري المفعول بعد إعادة تشغيل التطبيق."
|
||||
},
|
||||
"THEMES": {
|
||||
|
||||
@@ -430,7 +430,7 @@
|
||||
"APP_UPDATE_OPEN_RELEASE": "Open GitHub release",
|
||||
"APP_UPDATE_RELEASE_NOTES": "What's new",
|
||||
"EMBEDDED_MPV_FRAME_COPY": "MPV المدمج: محرك نسخ الإطارات (تجريبي)",
|
||||
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "الفيديو كيترسم على لوحة التطبيق باش القوائم والحوارات يبانو فوقو. غير على macOS ب Apple Silicon و Linux.",
|
||||
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "الفيديو كيترسم على لوحة التطبيق باش القوائم والحوارات يبانو فوقو.",
|
||||
"EMBEDDED_MPV_FRAME_COPY_RESTART": "غادي يخدم من بعد ما تعاود تشغل التطبيق."
|
||||
},
|
||||
"THEMES": {
|
||||
|
||||
@@ -430,7 +430,7 @@
|
||||
"APP_UPDATE_OPEN_RELEASE": "Open GitHub release",
|
||||
"APP_UPDATE_RELEASE_NOTES": "What's new",
|
||||
"EMBEDDED_MPV_FRAME_COPY": "Embedded MPV: рухавік frame-copy (эксперыментальна)",
|
||||
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "Відэа малюецца на канвасе праграмы, таму меню і дыялогі паказваюцца па-над ім. Толькі macOS на Apple Silicon і Linux.",
|
||||
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "Відэа малюецца на канвасе праграмы, таму меню і дыялогі паказваюцца па-над ім.",
|
||||
"EMBEDDED_MPV_FRAME_COPY_RESTART": "Пачне дзейнічаць пасля перазапуску праграмы."
|
||||
},
|
||||
"THEMES": {
|
||||
|
||||
@@ -430,7 +430,7 @@
|
||||
"APP_UPDATE_OPEN_RELEASE": "Open GitHub release",
|
||||
"APP_UPDATE_RELEASE_NOTES": "What's new",
|
||||
"EMBEDDED_MPV_FRAME_COPY": "Embedded MPV: Frame-Copy-Engine (experimentell)",
|
||||
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "Das Video wird auf die App-Leinwand gerendert, sodass Menüs und Dialoge darüber liegen. Nur macOS auf Apple Silicon und Linux.",
|
||||
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "Das Video wird auf die App-Leinwand gerendert, sodass Menüs und Dialoge darüber liegen.",
|
||||
"EMBEDDED_MPV_FRAME_COPY_RESTART": "Wird nach einem Neustart der App wirksam."
|
||||
},
|
||||
"THEMES": {
|
||||
|
||||
@@ -430,7 +430,7 @@
|
||||
"APP_UPDATE_OPEN_RELEASE": "Open GitHub release",
|
||||
"APP_UPDATE_RELEASE_NOTES": "What's new",
|
||||
"EMBEDDED_MPV_FRAME_COPY": "Embedded MPV: μηχανή frame-copy (πειραματικό)",
|
||||
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "Το βίντεο σχεδιάζεται στον καμβά της εφαρμογής, ώστε τα μενού και τα παράθυρα διαλόγου να εμφανίζονται από πάνω του. Μόνο macOS με Apple Silicon και Linux.",
|
||||
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "Το βίντεο σχεδιάζεται στον καμβά της εφαρμογής, ώστε τα μενού και τα παράθυρα διαλόγου να εμφανίζονται από πάνω του.",
|
||||
"EMBEDDED_MPV_FRAME_COPY_RESTART": "Ισχύει μετά την επανεκκίνηση της εφαρμογής."
|
||||
},
|
||||
"THEMES": {
|
||||
|
||||
@@ -430,7 +430,7 @@
|
||||
"APP_UPDATE_OPEN_RELEASE": "Open GitHub release",
|
||||
"APP_UPDATE_RELEASE_NOTES": "What's new",
|
||||
"EMBEDDED_MPV_FRAME_COPY": "Embedded MPV: frame-copy engine (experimental)",
|
||||
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "Render video onto the app canvas so menus and dialogs stack above it. macOS (Apple Silicon) and Linux only.",
|
||||
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "Render video onto the app canvas so menus and dialogs stack above it.",
|
||||
"EMBEDDED_MPV_FRAME_COPY_RESTART": "Takes effect after restarting the app."
|
||||
},
|
||||
"THEMES": {
|
||||
|
||||
@@ -430,7 +430,7 @@
|
||||
"APP_UPDATE_OPEN_RELEASE": "Open GitHub release",
|
||||
"APP_UPDATE_RELEASE_NOTES": "What's new",
|
||||
"EMBEDDED_MPV_FRAME_COPY": "Embedded MPV: motor frame-copy (experimental)",
|
||||
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "El vídeo se dibuja en el lienzo de la aplicación, de modo que los menús y diálogos quedan encima. Solo macOS con Apple Silicon y Linux.",
|
||||
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "El vídeo se dibuja en el lienzo de la aplicación, de modo que los menús y diálogos quedan encima.",
|
||||
"EMBEDDED_MPV_FRAME_COPY_RESTART": "Se aplica tras reiniciar la aplicación."
|
||||
},
|
||||
"THEMES": {
|
||||
|
||||
@@ -430,7 +430,7 @@
|
||||
"APP_UPDATE_OPEN_RELEASE": "Open GitHub release",
|
||||
"APP_UPDATE_RELEASE_NOTES": "What's new",
|
||||
"EMBEDDED_MPV_FRAME_COPY": "Embedded MPV : moteur frame-copy (expérimental)",
|
||||
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "La vidéo est rendue sur le canevas de l'application, les menus et boîtes de dialogue s'affichent donc au-dessus. macOS Apple Silicon et Linux uniquement.",
|
||||
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "La vidéo est rendue sur le canevas de l'application, les menus et boîtes de dialogue s'affichent donc au-dessus.",
|
||||
"EMBEDDED_MPV_FRAME_COPY_RESTART": "Prend effet après le redémarrage de l'application."
|
||||
},
|
||||
"THEMES": {
|
||||
|
||||
@@ -430,7 +430,7 @@
|
||||
"APP_UPDATE_OPEN_RELEASE": "Open GitHub release",
|
||||
"APP_UPDATE_RELEASE_NOTES": "What's new",
|
||||
"EMBEDDED_MPV_FRAME_COPY": "Embedded MPV: motore frame-copy (sperimentale)",
|
||||
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "Il video viene disegnato sul canvas dell'app, così menu e finestre di dialogo appaiono sopra di esso. Solo macOS con Apple Silicon e Linux.",
|
||||
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "Il video viene disegnato sul canvas dell'app, così menu e finestre di dialogo appaiono sopra di esso.",
|
||||
"EMBEDDED_MPV_FRAME_COPY_RESTART": "Ha effetto dopo il riavvio dell'app."
|
||||
},
|
||||
"THEMES": {
|
||||
|
||||
@@ -430,7 +430,7 @@
|
||||
"APP_UPDATE_OPEN_RELEASE": "Open GitHub release",
|
||||
"APP_UPDATE_RELEASE_NOTES": "What's new",
|
||||
"EMBEDDED_MPV_FRAME_COPY": "埋め込みMPV:フレームコピーエンジン(実験的)",
|
||||
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "映像をアプリのキャンバスに描画するため、メニューやダイアログをその上に表示できます。Apple Silicon搭載macOSおよびLinuxのみ。",
|
||||
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "映像をアプリのキャンバスに描画するため、メニューやダイアログをその上に表示できます。",
|
||||
"EMBEDDED_MPV_FRAME_COPY_RESTART": "アプリの再起動後に有効になります。"
|
||||
},
|
||||
"THEMES": {
|
||||
|
||||
@@ -430,7 +430,7 @@
|
||||
"APP_UPDATE_OPEN_RELEASE": "Open GitHub release",
|
||||
"APP_UPDATE_RELEASE_NOTES": "What's new",
|
||||
"EMBEDDED_MPV_FRAME_COPY": "내장 MPV: 프레임 복사 엔진(실험적)",
|
||||
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "비디오를 앱 캔버스에 렌더링하여 메뉴와 대화 상자가 그 위에 표시됩니다. Apple Silicon macOS 및 Linux 전용.",
|
||||
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "비디오를 앱 캔버스에 렌더링하여 메뉴와 대화 상자가 그 위에 표시됩니다.",
|
||||
"EMBEDDED_MPV_FRAME_COPY_RESTART": "앱을 다시 시작한 후 적용됩니다."
|
||||
},
|
||||
"THEMES": {
|
||||
|
||||
@@ -430,7 +430,7 @@
|
||||
"APP_UPDATE_OPEN_RELEASE": "Open GitHub release",
|
||||
"APP_UPDATE_RELEASE_NOTES": "What's new",
|
||||
"EMBEDDED_MPV_FRAME_COPY": "Embedded MPV: frame-copy-engine (experimenteel)",
|
||||
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "De video wordt op het app-canvas getekend zodat menu's en dialoogvensters erboven verschijnen. Alleen macOS op Apple Silicon en Linux.",
|
||||
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "De video wordt op het app-canvas getekend zodat menu's en dialoogvensters erboven verschijnen.",
|
||||
"EMBEDDED_MPV_FRAME_COPY_RESTART": "Wordt van kracht na het herstarten van de app."
|
||||
},
|
||||
"THEMES": {
|
||||
|
||||
@@ -430,7 +430,7 @@
|
||||
"APP_UPDATE_OPEN_RELEASE": "Open GitHub release",
|
||||
"APP_UPDATE_RELEASE_NOTES": "What's new",
|
||||
"EMBEDDED_MPV_FRAME_COPY": "Embedded MPV: silnik frame-copy (eksperymentalny)",
|
||||
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "Wideo jest rysowane na kanwie aplikacji, dzięki czemu menu i okna dialogowe wyświetlają się nad nim. Tylko macOS na Apple Silicon i Linux.",
|
||||
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "Wideo jest rysowane na kanwie aplikacji, dzięki czemu menu i okna dialogowe wyświetlają się nad nim.",
|
||||
"EMBEDDED_MPV_FRAME_COPY_RESTART": "Zacznie działać po ponownym uruchomieniu aplikacji."
|
||||
},
|
||||
"THEMES": {
|
||||
|
||||
@@ -430,7 +430,7 @@
|
||||
"APP_UPDATE_OPEN_RELEASE": "Open GitHub release",
|
||||
"APP_UPDATE_RELEASE_NOTES": "What's new",
|
||||
"EMBEDDED_MPV_FRAME_COPY": "Embedded MPV: motor frame-copy (experimental)",
|
||||
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "O vídeo é desenhado na tela da aplicação, para que menus e diálogos fiquem por cima. Apenas macOS com Apple Silicon e Linux.",
|
||||
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "O vídeo é desenhado na tela da aplicação, para que menus e diálogos fiquem por cima.",
|
||||
"EMBEDDED_MPV_FRAME_COPY_RESTART": "Entra em vigor após reiniciar a aplicação."
|
||||
},
|
||||
"THEMES": {
|
||||
|
||||
@@ -430,7 +430,7 @@
|
||||
"APP_UPDATE_OPEN_RELEASE": "Открыть GitHub release",
|
||||
"APP_UPDATE_RELEASE_NOTES": "Что нового",
|
||||
"EMBEDDED_MPV_FRAME_COPY": "Embedded MPV: движок frame-copy (экспериментально)",
|
||||
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "Видео рисуется на канвасе приложения, поэтому меню и диалоги отображаются поверх него. Только macOS на Apple Silicon и Linux.",
|
||||
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "Видео рисуется на канвасе приложения, поэтому меню и диалоги отображаются поверх него.",
|
||||
"EMBEDDED_MPV_FRAME_COPY_RESTART": "Вступит в силу после перезапуска приложения."
|
||||
},
|
||||
"THEMES": {
|
||||
|
||||
@@ -430,7 +430,7 @@
|
||||
"APP_UPDATE_OPEN_RELEASE": "Open GitHub release",
|
||||
"APP_UPDATE_RELEASE_NOTES": "What's new",
|
||||
"EMBEDDED_MPV_FRAME_COPY": "Embedded MPV: frame-copy motoru (deneysel)",
|
||||
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "Video uygulama tuvaline çizilir, böylece menüler ve iletişim kutuları üzerinde görüntülenir. Yalnızca Apple Silicon macOS ve Linux.",
|
||||
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "Video uygulama tuvaline çizilir, böylece menüler ve iletişim kutuları üzerinde görüntülenir.",
|
||||
"EMBEDDED_MPV_FRAME_COPY_RESTART": "Uygulama yeniden başlatıldıktan sonra geçerli olur."
|
||||
},
|
||||
"THEMES": {
|
||||
|
||||
@@ -430,7 +430,7 @@
|
||||
"APP_UPDATE_OPEN_RELEASE": "Open GitHub release",
|
||||
"APP_UPDATE_RELEASE_NOTES": "What's new",
|
||||
"EMBEDDED_MPV_FRAME_COPY": "嵌入式 MPV:帧拷贝引擎(实验性)",
|
||||
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "视频渲染到应用画布上,菜单和对话框可以显示在其上方。仅限 Apple Silicon 的 macOS 和 Linux。",
|
||||
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "视频渲染到应用画布上,菜单和对话框可以显示在其上方。",
|
||||
"EMBEDDED_MPV_FRAME_COPY_RESTART": "重启应用后生效。"
|
||||
},
|
||||
"THEMES": {
|
||||
|
||||
@@ -430,7 +430,7 @@
|
||||
"APP_UPDATE_OPEN_RELEASE": "Open GitHub release",
|
||||
"APP_UPDATE_RELEASE_NOTES": "What's new",
|
||||
"EMBEDDED_MPV_FRAME_COPY": "內嵌 MPV:幀複製引擎(實驗性)",
|
||||
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "影片會繪製到應用程式畫布上,選單和對話框可顯示在其上方。僅限 Apple Silicon 的 macOS 和 Linux。",
|
||||
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "影片會繪製到應用程式畫布上,選單和對話框可顯示在其上方。",
|
||||
"EMBEDDED_MPV_FRAME_COPY_RESTART": "重新啟動應用程式後生效。"
|
||||
},
|
||||
"THEMES": {
|
||||
|
||||
Reference in new issue
Block a user