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:
4gray authored and GitHub committed 2026-07-15 21:27:56 +02:00
1 parent 7d75d989e8
commit 59e08fd2d6
41 files changed
+1284 -157

No files matched your search

+45
View File
@@ -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, &region, 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
+20 -2
View File
@@ -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"
]
}
]
@@ -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),
+1 -1
View File
@@ -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": {
+1 -1
View File
@@ -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": {
+1 -1
View File
@@ -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": {
+1 -1
View File
@@ -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": {
+1 -1
View File
@@ -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": {
+1 -1
View File
@@ -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": {
+1 -1
View File
@@ -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": {
+1 -1
View File
@@ -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": {
+1 -1
View File
@@ -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": {
+1 -1
View File
@@ -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": {
+1 -1
View File
@@ -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": {
+1 -1
View File
@@ -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": {
+1 -1
View File
@@ -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": {
+1 -1
View File
@@ -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": {
+1 -1
View File
@@ -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": {
+1 -1
View File
@@ -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": {
+1 -1
View File
@@ -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": {
+1 -1
View File
@@ -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": {