Files
iptvnator/apps/electron-backend/native/src/embedded_mpv.mm
T

2564 lines
79 KiB
Plaintext

#include <napi.h>
#define GL_SILENCE_DEPRECATION
#import <AppKit/AppKit.h>
#import <CoreVideo/CoreVideo.h>
#import <Foundation/Foundation.h>
#import <OpenGL/gl3.h>
#import <OpenGL/OpenGL.h>
#import <QuartzCore/QuartzCore.h>
#include <mpv/client.h>
#include <mpv/render.h>
#include <mpv/render_gl.h>
#include <algorithm>
#include <atomic>
#include <chrono>
#include <cctype>
#include <cmath>
#include <cstdint>
#include <cstdlib>
#include <cstring>
#include <ctime>
#include <dlfcn.h>
#include <memory>
#include <mutex>
#include <sstream>
#include <stdexcept>
#include <string>
#include <thread>
#include <unordered_map>
#include <utility>
#include <vector>
@interface EmbeddedMpvContainerView : NSView
@end
@implementation EmbeddedMpvContainerView
- (BOOL)isFlipped
{
return YES;
}
- (NSView*)hitTest:(NSPoint)point
{
return nil;
}
@end
@interface EmbeddedMpvOpenGLView : NSOpenGLView
@end
@implementation EmbeddedMpvOpenGLView
- (BOOL)isFlipped
{
return YES;
}
- (NSView*)hitTest:(NSPoint)point
{
return nil;
}
@end
namespace {
enum class SessionStatus {
Idle,
Loading,
Playing,
Paused,
Ended,
Error,
Closed,
};
enum class RenderBackend {
OpenGL,
Software,
};
struct AudioTrack {
int64_t id = 0;
std::string title;
std::string language;
bool selected = false;
bool defaultTrack = false;
bool forced = false;
};
struct SessionSnapshot {
SessionStatus status = SessionStatus::Idle;
double positionSeconds = 0.0;
double durationSeconds = -1.0;
double volumePercent = 100.0;
std::string streamUrl;
std::string error;
std::vector<AudioTrack> audioTracks;
int64_t selectedAudioTrackId = -1;
std::vector<AudioTrack> subtitleTracks;
int64_t selectedSubtitleTrackId = -1;
double playbackSpeed = 1.0;
std::string aspectOverride = "no";
bool recordingActive = false;
std::string recordingTargetPath;
std::string recordingStartedAt;
std::string recordingError;
};
struct Session {
std::string id;
mpv_handle* handle = nullptr;
mpv_render_context* renderContext = nullptr;
NSView* __strong parentView = nil;
NSView* __strong hostView = nil;
NSView* __strong containerView = nil;
NSOpenGLPixelFormat* __strong openGLPixelFormat = nil;
NSOpenGLContext* __strong openGLContext = nil;
CVDisplayLinkRef displayLink = nullptr;
dispatch_queue_t renderQueue = nullptr;
std::thread eventThread;
std::atomic<bool> running{false};
std::atomic<bool> renderScheduled{false};
std::atomic<bool> renderNeeded{false};
std::atomic<uint64_t> renderedFrameCount{0};
std::atomic<uint64_t> skippedFrameCount{0};
std::atomic<uint64_t> totalRenderNanoseconds{0};
std::mutex mutex;
SessionSnapshot snapshot;
uint64_t pendingRecordingStartRequestId = 0;
uint64_t pendingRecordingStopRequestId = 0;
std::string pendingRecordingTargetPath;
std::string pendingRecordingStartedAt;
std::string pendingRecordingStopStartedAt;
uint64_t pendingPlaybackLoadRequestId = 0;
std::weak_ptr<Session>* renderCallbackContext = nullptr;
RenderBackend renderBackend = RenderBackend::OpenGL;
int renderWidthPixels = 0;
int renderHeightPixels = 0;
double renderContentsScale = 1.0;
bool paused = false;
bool loadedPath = false;
};
std::atomic<uint64_t> gNextSessionId{1};
std::atomic<uint64_t> gNextAsyncRequestId{1};
std::mutex gSessionsMutex;
std::unordered_map<std::string, std::shared_ptr<Session>> gSessions;
std::string toStatusString(SessionStatus status)
{
switch (status) {
case SessionStatus::Idle:
return "idle";
case SessionStatus::Loading:
return "loading";
case SessionStatus::Playing:
return "playing";
case SessionStatus::Paused:
return "paused";
case SessionStatus::Ended:
return "ended";
case SessionStatus::Error:
return "error";
case SessionStatus::Closed:
return "closed";
}
return "idle";
}
void runOnMainSync(dispatch_block_t block)
{
if ([NSThread isMainThread]) {
block();
return;
}
dispatch_sync(dispatch_get_main_queue(), block);
}
std::shared_ptr<Session> findSession(const std::string& sessionId)
{
std::lock_guard<std::mutex> sessionsLock(gSessionsMutex);
const auto iterator = gSessions.find(sessionId);
if (iterator == gSessions.end()) {
return nullptr;
}
return iterator->second;
}
std::shared_ptr<Session> getSessionOrThrow(
Napi::Env env,
const std::string& sessionId)
{
auto session = findSession(sessionId);
if (!session) {
throw Napi::Error::New(env, "Embedded MPV session not found.");
}
return session;
}
void scheduleRender(const std::shared_ptr<Session>& session);
void requestRender(const std::shared_ptr<Session>& session);
void updateSessionError(const std::shared_ptr<Session>& session, const std::string& error);
bool isEmbeddedMpvTraceEnabled()
{
const char* value = std::getenv("IPTVNATOR_TRACE_EMBEDDED_MPV");
return value && value[0] != '\0' && std::string(value) != "0";
}
std::string normalizeEnvValue(const char* value)
{
if (!value) {
return "";
}
std::string result(value);
std::transform(
result.begin(),
result.end(),
result.begin(),
[](unsigned char character) {
return static_cast<char>(std::tolower(character));
}
);
return result;
}
RenderBackend requestedRenderBackend()
{
const std::string renderer =
normalizeEnvValue(std::getenv("IPTVNATOR_EMBEDDED_MPV_RENDERER"));
return renderer == "sw"
? RenderBackend::Software
: RenderBackend::OpenGL;
}
const char* renderBackendName(RenderBackend backend)
{
return backend == RenderBackend::OpenGL ? "gl" : "sw";
}
void traceEmbeddedMpv(const std::shared_ptr<Session>& session, const std::string& message)
{
if (!isEmbeddedMpvTraceEnabled()) {
return;
}
fprintf(
stderr,
"[embedded-mpv][%s][trace] %s\n",
session ? session->id.c_str() : "unknown",
message.c_str()
);
}
void recordRenderedFrame(
const std::shared_ptr<Session>& session,
std::chrono::steady_clock::duration renderDuration
)
{
if (!session) {
return;
}
const uint64_t durationNanoseconds =
static_cast<uint64_t>(
std::chrono::duration_cast<std::chrono::nanoseconds>(
renderDuration
).count()
);
const uint64_t frameCount =
session->renderedFrameCount.fetch_add(1) + 1;
const uint64_t totalNanoseconds =
session->totalRenderNanoseconds.fetch_add(durationNanoseconds) +
durationNanoseconds;
if (!isEmbeddedMpvTraceEnabled() || frameCount % 120 != 0) {
return;
}
const double averageMilliseconds =
static_cast<double>(totalNanoseconds) /
static_cast<double>(frameCount) /
1000000.0;
fprintf(
stderr,
"[embedded-mpv][%s][trace] renderer=%s frames=%llu avgRenderMs=%.2f skipped=%llu\n",
session->id.c_str(),
renderBackendName(session->renderBackend),
static_cast<unsigned long long>(frameCount),
averageMilliseconds,
static_cast<unsigned long long>(session->skippedFrameCount.load())
);
}
void* getOpenGLProcAddress(void*, const char* name)
{
if (!name) {
return nullptr;
}
void* symbol = dlsym(RTLD_DEFAULT, name);
if (symbol) {
return symbol;
}
CFBundleRef bundle =
CFBundleGetBundleWithIdentifier(CFSTR("com.apple.opengl"));
if (!bundle) {
return nullptr;
}
CFStringRef symbolName = CFStringCreateWithCString(
kCFAllocatorDefault,
name,
kCFStringEncodingASCII
);
if (!symbolName) {
return nullptr;
}
symbol = CFBundleGetFunctionPointerForName(bundle, symbolName);
CFRelease(symbolName);
return symbol;
}
void updateRenderSurfaceMetrics(const std::shared_ptr<Session>& session)
{
runOnMainSync(^{
if (!session->containerView) {
return;
}
const NSSize backingSize =
[session->containerView convertSizeToBacking:session->containerView.bounds.size];
const NSInteger widthPixels = std::max<NSInteger>(
0,
static_cast<NSInteger>(std::lround(backingSize.width))
);
const NSInteger heightPixels = std::max<NSInteger>(
0,
static_cast<NSInteger>(std::lround(backingSize.height))
);
const double contentsScale =
session->containerView.window
? session->containerView.window.backingScaleFactor
: NSScreen.mainScreen.backingScaleFactor;
{
std::lock_guard<std::mutex> lock(session->mutex);
session->renderWidthPixels = static_cast<int>(widthPixels);
session->renderHeightPixels = static_cast<int>(heightPixels);
session->renderContentsScale = contentsScale > 0
? contentsScale
: 1.0;
}
if (session->containerView.layer) {
session->containerView.layer.contentsScale = contentsScale;
}
});
}
void setSessionFrame(const std::shared_ptr<Session>& session, double x, double y, double width, double height)
{
runOnMainSync(^{
if (!session->parentView || !session->hostView || !session->containerView) {
return;
}
NSRect parentBounds = [session->parentView bounds];
const CGFloat frameWidth = static_cast<CGFloat>(std::max(width, 0.0));
const CGFloat frameHeight = static_cast<CGFloat>(std::max(height, 0.0));
const CGFloat originX = static_cast<CGFloat>(x);
const CGFloat originY = [session->parentView isFlipped]
? static_cast<CGFloat>(y)
: NSMaxY(parentBounds) - static_cast<CGFloat>(y) - frameHeight;
NSRect frameRect = NSMakeRect(originX, originY, frameWidth, frameHeight);
if (session->hostView != session->parentView) {
frameRect = [session->hostView convertRect:frameRect
fromView:session->parentView];
}
[session->containerView setFrame:frameRect];
if (session->openGLContext) {
[session->openGLContext update];
} else {
[session->containerView setNeedsDisplay:YES];
}
});
updateRenderSurfaceMetrics(session);
requestRender(session);
}
void destroySession(const std::shared_ptr<Session>& session)
{
if (!session) {
return;
}
session->running.store(false);
session->renderNeeded.store(false);
if (session->displayLink) {
CVDisplayLinkStop(session->displayLink);
}
if (session->handle) {
bool recordingActive = false;
{
std::lock_guard<std::mutex> lock(session->mutex);
recordingActive = session->snapshot.recordingActive;
}
if (recordingActive) {
const char* disabledValue = "";
mpv_set_property(
session->handle,
"stream-record",
MPV_FORMAT_STRING,
const_cast<char**>(&disabledValue)
);
std::lock_guard<std::mutex> lock(session->mutex);
session->snapshot.recordingActive = false;
session->snapshot.recordingStartedAt.clear();
}
mpv_wakeup(session->handle);
}
if (session->eventThread.joinable()) {
session->eventThread.join();
}
if (session->renderContext) {
auto freeRenderContext = ^{
if (!session->renderContext) {
return;
}
if (session->renderBackend == RenderBackend::OpenGL &&
session->openGLContext) {
CGLContextObj cglContext =
[session->openGLContext CGLContextObj];
if (cglContext) {
CGLLockContext(cglContext);
[session->openGLContext makeCurrentContext];
}
mpv_render_context_set_update_callback(
session->renderContext,
nullptr,
nullptr
);
mpv_render_context_free(session->renderContext);
session->renderContext = nullptr;
[NSOpenGLContext clearCurrentContext];
if (cglContext) {
CGLUnlockContext(cglContext);
}
return;
}
mpv_render_context_set_update_callback(
session->renderContext,
nullptr,
nullptr
);
mpv_render_context_free(session->renderContext);
session->renderContext = nullptr;
};
if (session->renderQueue) {
dispatch_sync(session->renderQueue, freeRenderContext);
} else {
freeRenderContext();
}
}
if (session->renderCallbackContext) {
delete session->renderCallbackContext;
session->renderCallbackContext = nullptr;
}
if (session->displayLink) {
CVDisplayLinkRelease(session->displayLink);
session->displayLink = nullptr;
}
if (session->handle) {
mpv_terminate_destroy(session->handle);
session->handle = nullptr;
}
{
std::lock_guard<std::mutex> lock(session->mutex);
session->snapshot.status = SessionStatus::Closed;
}
runOnMainSync(^{
if (session->containerView) {
[session->containerView removeFromSuperview];
session->containerView = nil;
}
session->openGLContext = nil;
session->openGLPixelFormat = nil;
session->parentView = nil;
session->hostView = nil;
});
}
void renderSoftwareFrame(const std::shared_ptr<Session>& session)
{
if (!session || !session->renderContext) {
return;
}
const auto startedAt = std::chrono::steady_clock::now();
int widthPixels = 0;
int heightPixels = 0;
double contentsScale = 1.0;
{
std::lock_guard<std::mutex> lock(session->mutex);
widthPixels = session->renderWidthPixels;
heightPixels = session->renderHeightPixels;
contentsScale = session->renderContentsScale;
}
if (widthPixels <= 0 || heightPixels <= 0) {
return;
}
const size_t stride = ((static_cast<size_t>(widthPixels) * 4) + 63) & ~static_cast<size_t>(63);
std::vector<uint8_t> frameBytes(stride * static_cast<size_t>(heightPixels));
int size[] = { widthPixels, heightPixels };
size_t strideValue = stride;
char format[] = "bgr0";
mpv_render_param params[] = {
{ MPV_RENDER_PARAM_SW_SIZE, size },
{ MPV_RENDER_PARAM_SW_FORMAT, format },
{ MPV_RENDER_PARAM_SW_STRIDE, &strideValue },
{ MPV_RENDER_PARAM_SW_POINTER, frameBytes.data() },
{ MPV_RENDER_PARAM_INVALID, nullptr },
};
const int result = mpv_render_context_render(session->renderContext, params);
if (result < 0) {
updateSessionError(
session,
std::string("Failed to render frame: ") + mpv_error_string(result)
);
return;
}
CFDataRef frameData = CFDataCreate(
kCFAllocatorDefault,
frameBytes.data(),
static_cast<CFIndex>(frameBytes.size())
);
if (!frameData) {
return;
}
CGDataProviderRef provider = CGDataProviderCreateWithCFData(frameData);
CGColorSpaceRef colorSpace = CGColorSpaceCreateDeviceRGB();
CGBitmapInfo bitmapInfo =
static_cast<CGBitmapInfo>(kCGBitmapByteOrder32Little |
kCGImageAlphaNoneSkipFirst);
CGImageRef image = CGImageCreate(
static_cast<size_t>(widthPixels),
static_cast<size_t>(heightPixels),
8,
32,
strideValue,
colorSpace,
bitmapInfo,
provider,
nullptr,
false,
kCGRenderingIntentDefault
);
CGColorSpaceRelease(colorSpace);
CGDataProviderRelease(provider);
CFRelease(frameData);
if (!image) {
return;
}
id frameImage = CFBridgingRelease(image);
dispatch_async(dispatch_get_main_queue(), ^{
if (!session->containerView || !session->containerView.layer) {
return;
}
session->containerView.layer.contents = frameImage;
session->containerView.layer.contentsScale = contentsScale;
session->containerView.layer.contentsGravity = kCAGravityResize;
});
recordRenderedFrame(session, std::chrono::steady_clock::now() - startedAt);
}
void renderOpenGLFrame(const std::shared_ptr<Session>& session)
{
if (!session || !session->renderContext || !session->openGLContext) {
return;
}
int widthPixels = 0;
int heightPixels = 0;
{
std::lock_guard<std::mutex> lock(session->mutex);
widthPixels = session->renderWidthPixels;
heightPixels = session->renderHeightPixels;
}
if (widthPixels <= 0 || heightPixels <= 0) {
session->skippedFrameCount.fetch_add(1);
return;
}
const auto startedAt = std::chrono::steady_clock::now();
CGLContextObj cglContext = [session->openGLContext CGLContextObj];
if (!cglContext) {
session->skippedFrameCount.fetch_add(1);
return;
}
CGLLockContext(cglContext);
[session->openGLContext makeCurrentContext];
const uint64_t updateFlags =
mpv_render_context_update(session->renderContext);
if ((updateFlags & MPV_RENDER_UPDATE_FRAME) == 0) {
CGLUnlockContext(cglContext);
session->skippedFrameCount.fetch_add(1);
return;
}
glViewport(0, 0, widthPixels, heightPixels);
glClearColor(0.0F, 0.0F, 0.0F, 1.0F);
glClear(GL_COLOR_BUFFER_BIT);
mpv_opengl_fbo fbo = {
0,
widthPixels,
heightPixels,
GL_RGBA8,
};
int flipY = 1;
int blockForTargetTime = 0;
mpv_render_param params[] = {
{ MPV_RENDER_PARAM_OPENGL_FBO, &fbo },
{ MPV_RENDER_PARAM_FLIP_Y, &flipY },
{ MPV_RENDER_PARAM_BLOCK_FOR_TARGET_TIME, &blockForTargetTime },
{ MPV_RENDER_PARAM_INVALID, nullptr },
};
const int result = mpv_render_context_render(
session->renderContext,
params
);
if (result < 0) {
CGLUnlockContext(cglContext);
updateSessionError(
session,
std::string("Failed to render OpenGL frame: ") +
mpv_error_string(result)
);
return;
}
[session->openGLContext flushBuffer];
mpv_render_context_report_swap(session->renderContext);
CGLUnlockContext(cglContext);
recordRenderedFrame(session, std::chrono::steady_clock::now() - startedAt);
}
void renderSessionFrame(const std::shared_ptr<Session>& session)
{
if (!session) {
return;
}
if (session->renderBackend == RenderBackend::OpenGL) {
renderOpenGLFrame(session);
return;
}
renderSoftwareFrame(session);
}
void scheduleRender(const std::shared_ptr<Session>& session)
{
if (!session || !session->renderQueue || !session->renderContext) {
return;
}
if (session->renderScheduled.exchange(true)) {
return;
}
auto sessionCopy = session;
dispatch_async(session->renderQueue, ^{
renderSessionFrame(sessionCopy);
sessionCopy->renderScheduled.store(false);
});
}
void requestRender(const std::shared_ptr<Session>& session)
{
if (!session) {
return;
}
if (session->renderBackend == RenderBackend::OpenGL) {
session->renderNeeded.store(true);
return;
}
scheduleRender(session);
}
CVReturn displayLinkCallback(
CVDisplayLinkRef,
const CVTimeStamp*,
const CVTimeStamp*,
CVOptionFlags,
CVOptionFlags*,
void* context
)
{
const auto* weakSession =
static_cast<std::weak_ptr<Session>*>(context);
if (!weakSession) {
return kCVReturnSuccess;
}
if (const auto session = weakSession->lock()) {
if (session->running.load() &&
session->renderBackend == RenderBackend::OpenGL &&
session->renderNeeded.exchange(false)) {
scheduleRender(session);
}
}
return kCVReturnSuccess;
}
void onRenderContextUpdate(void* context)
{
const auto* weakSession =
static_cast<std::weak_ptr<Session>*>(context);
if (!weakSession) {
return;
}
if (const auto session = weakSession->lock()) {
requestRender(session);
}
}
void replaceAllInPlace(std::string& value, const std::string& from, const std::string& to)
{
if (from.empty()) {
return;
}
size_t position = 0;
while ((position = value.find(from, position)) != std::string::npos) {
value.replace(position, from.length(), to);
position += to.length();
}
}
std::string joinHeaderFields(const Napi::Object& headers)
{
const Napi::Array propertyNames = headers.GetPropertyNames();
std::vector<std::string> fields;
fields.reserve(propertyNames.Length());
for (uint32_t index = 0; index < propertyNames.Length(); index += 1) {
const Napi::Value propertyName = propertyNames.Get(index);
if (!propertyName.IsString()) {
continue;
}
const std::string key = propertyName.As<Napi::String>().Utf8Value();
const Napi::Value propertyValue = headers.Get(propertyName);
if (!propertyValue.IsString() && !propertyValue.IsNumber()) {
continue;
}
std::string value = propertyValue.ToString().Utf8Value();
if (key.empty() || value.empty()) {
continue;
}
// mpv's `--http-header-fields` option is a comma-separated list
// (OPT_STRINGLIST). Header values that themselves contain commas
// (e.g. the Stalker MAG250 user agent "...(KHTML, like Gecko) MAG250")
// must be escaped so mpv does not split them into bogus fields and
// the server rejects the request with HTTP 400. mpv strips the
// backslash and keeps the comma inside the header value.
replaceAllInPlace(value, "\\", "\\\\");
replaceAllInPlace(value, ",", "\\,");
fields.push_back(key + ": " + value);
}
std::ostringstream stream;
for (size_t index = 0; index < fields.size(); index += 1) {
if (index > 0) {
stream << ",";
}
stream << fields[index];
}
return stream.str();
}
std::string readOptionalString(const Napi::Object& object, const char* key)
{
if (!object.Has(key)) {
return "";
}
const Napi::Value value = object.Get(key);
if (value.IsUndefined() || value.IsNull()) {
return "";
}
return value.ToString().Utf8Value();
}
double readOptionalNumber(const Napi::Object& object, const char* key, double fallbackValue)
{
if (!object.Has(key)) {
return fallbackValue;
}
const Napi::Value value = object.Get(key);
if (!value.IsNumber()) {
return fallbackValue;
}
return value.As<Napi::Number>().DoubleValue();
}
bool parseIntegerString(const std::string& value, int64_t& result)
{
if (value.empty()) {
return false;
}
char* end = nullptr;
const long long parsed = std::strtoll(value.c_str(), &end, 10);
if (!end || *end != '\0') {
return false;
}
result = static_cast<int64_t>(parsed);
return true;
}
const mpv_node* getNodeMapValue(const mpv_node& node, const char* key)
{
if (node.format != MPV_FORMAT_NODE_MAP ||
!node.u.list ||
!node.u.list->keys ||
!node.u.list->values) {
return nullptr;
}
for (int index = 0; index < node.u.list->num; index += 1) {
const char* candidateKey = node.u.list->keys[index];
if (candidateKey && std::strcmp(candidateKey, key) == 0) {
return &node.u.list->values[index];
}
}
return nullptr;
}
std::string readNodeString(const mpv_node& node)
{
switch (node.format) {
case MPV_FORMAT_STRING:
return node.u.string ? node.u.string : "";
case MPV_FORMAT_INT64:
return std::to_string(node.u.int64);
case MPV_FORMAT_DOUBLE: {
std::ostringstream stream;
stream << node.u.double_;
return stream.str();
}
default:
return "";
}
}
bool readNodeFlag(const mpv_node& node)
{
if (node.format == MPV_FORMAT_FLAG) {
return node.u.flag != 0;
}
if (node.format == MPV_FORMAT_STRING && node.u.string) {
const std::string value = normalizeEnvValue(node.u.string);
return value == "yes" || value == "true" || value == "1";
}
return false;
}
bool readNodeInteger(const mpv_node& node, int64_t& result)
{
if (node.format == MPV_FORMAT_INT64) {
result = node.u.int64;
return true;
}
if (node.format == MPV_FORMAT_DOUBLE) {
result = static_cast<int64_t>(node.u.double_);
return true;
}
if (node.format == MPV_FORMAT_STRING && node.u.string) {
return parseIntegerString(node.u.string, result);
}
return false;
}
void updateSelectedAudioTrack(SessionSnapshot& snapshot, int64_t selectedTrackId)
{
snapshot.selectedAudioTrackId = selectedTrackId >= 0 ? selectedTrackId : -1;
for (auto& track : snapshot.audioTracks) {
track.selected = track.id == snapshot.selectedAudioTrackId;
}
}
void updateSelectedSubtitleTrack(SessionSnapshot& snapshot, int64_t selectedTrackId)
{
snapshot.selectedSubtitleTrackId =
selectedTrackId >= 0 ? selectedTrackId : -1;
for (auto& track : snapshot.subtitleTracks) {
track.selected = track.id == snapshot.selectedSubtitleTrackId;
}
}
void updateTracksFromNode(
SessionSnapshot& snapshot,
const mpv_node& node,
const std::string& typeFilter,
std::vector<AudioTrack>& outTracks,
int64_t& outSelectedId)
{
outTracks.clear();
outSelectedId = -1;
if (node.format != MPV_FORMAT_NODE_ARRAY ||
!node.u.list ||
!node.u.list->values) {
return;
}
for (int index = 0; index < node.u.list->num; index += 1) {
const mpv_node& trackNode = node.u.list->values[index];
if (trackNode.format != MPV_FORMAT_NODE_MAP) {
continue;
}
const mpv_node* typeNode = getNodeMapValue(trackNode, "type");
if (!typeNode || readNodeString(*typeNode) != typeFilter) {
continue;
}
const mpv_node* idNode = getNodeMapValue(trackNode, "id");
int64_t id = -1;
if (!idNode || !readNodeInteger(*idNode, id) || id < 0) {
continue;
}
AudioTrack track;
track.id = id;
if (const mpv_node* titleNode = getNodeMapValue(trackNode, "title")) {
track.title = readNodeString(*titleNode);
}
if (const mpv_node* languageNode = getNodeMapValue(trackNode, "lang")) {
track.language = readNodeString(*languageNode);
}
if (const mpv_node* selectedNode = getNodeMapValue(trackNode, "selected")) {
track.selected = readNodeFlag(*selectedNode);
}
if (const mpv_node* defaultNode = getNodeMapValue(trackNode, "default")) {
track.defaultTrack = readNodeFlag(*defaultNode);
}
if (const mpv_node* forcedNode = getNodeMapValue(trackNode, "forced")) {
track.forced = readNodeFlag(*forcedNode);
}
if (track.selected) {
outSelectedId = track.id;
}
outTracks.push_back(track);
}
}
void updateAudioTracksFromNode(SessionSnapshot& snapshot, const mpv_node& node)
{
updateTracksFromNode(
snapshot,
node,
"audio",
snapshot.audioTracks,
snapshot.selectedAudioTrackId
);
}
void updateSubtitleTracksFromNode(SessionSnapshot& snapshot, const mpv_node& node)
{
updateTracksFromNode(
snapshot,
node,
"sub",
snapshot.subtitleTracks,
snapshot.selectedSubtitleTrackId
);
}
void updateSessionError(const std::shared_ptr<Session>& session, const std::string& error)
{
std::lock_guard<std::mutex> lock(session->mutex);
session->snapshot.status = SessionStatus::Error;
session->snapshot.error = error;
}
uint64_t nextAsyncRequestId()
{
return gNextAsyncRequestId.fetch_add(1);
}
std::string currentUtcTimestamp()
{
std::time_t now = std::time(nullptr);
std::tm timeInfo{};
#if defined(_WIN32)
gmtime_s(&timeInfo, &now);
#else
gmtime_r(&now, &timeInfo);
#endif
char buffer[32]{};
std::strftime(buffer, sizeof(buffer), "%Y-%m-%dT%H:%M:%SZ", &timeInfo);
return buffer;
}
bool reconcileRecordingPropertyReply(
const std::shared_ptr<Session>& session,
uint64_t requestId,
int error
)
{
if (requestId == 0) {
return false;
}
if (requestId == session->pendingRecordingStartRequestId) {
session->pendingRecordingStartRequestId = 0;
const std::string targetPath = session->pendingRecordingTargetPath;
const std::string startedAt = session->pendingRecordingStartedAt;
session->pendingRecordingTargetPath.clear();
session->pendingRecordingStartedAt.clear();
if (error < 0) {
session->snapshot.recordingActive = false;
session->snapshot.recordingTargetPath = targetPath;
session->snapshot.recordingStartedAt.clear();
session->snapshot.recordingError = mpv_error_string(error);
return true;
}
session->snapshot.recordingActive = true;
session->snapshot.recordingTargetPath = targetPath;
session->snapshot.recordingStartedAt = startedAt;
session->snapshot.recordingError.clear();
return true;
}
if (requestId == session->pendingRecordingStopRequestId) {
session->pendingRecordingStopRequestId = 0;
const std::string startedAt = session->pendingRecordingStopStartedAt;
session->pendingRecordingStopStartedAt.clear();
if (error < 0) {
session->snapshot.recordingActive = true;
session->snapshot.recordingStartedAt = startedAt;
session->snapshot.recordingError = mpv_error_string(error);
return true;
}
session->snapshot.recordingActive = false;
session->snapshot.recordingStartedAt.clear();
session->snapshot.recordingError.clear();
return true;
}
return false;
}
bool reconcilePlaybackLoadReply(
const std::shared_ptr<Session>& session,
uint64_t requestId,
int error
)
{
if (
requestId == 0 ||
requestId != session->pendingPlaybackLoadRequestId
) {
return false;
}
session->pendingPlaybackLoadRequestId = 0;
if (error < 0) {
session->snapshot.status = SessionStatus::Error;
session->snapshot.error = mpv_error_string(error);
}
return true;
}
double clampVolumePercent(double volume)
{
return std::clamp(volume, 0.0, 1.0) * 100.0;
}
void runEventLoop(const std::shared_ptr<Session>& session)
{
while (session->running.load()) {
mpv_event* event = mpv_wait_event(session->handle, 0.1);
if (!event || event->event_id == MPV_EVENT_NONE) {
continue;
}
std::lock_guard<std::mutex> lock(session->mutex);
switch (event->event_id) {
case MPV_EVENT_START_FILE:
session->snapshot.status = SessionStatus::Loading;
session->snapshot.error.clear();
session->snapshot.audioTracks.clear();
session->snapshot.selectedAudioTrackId = -1;
session->snapshot.subtitleTracks.clear();
session->snapshot.selectedSubtitleTrackId = -1;
session->loadedPath = false;
break;
case MPV_EVENT_FILE_LOADED:
session->loadedPath = true;
session->snapshot.status = session->paused
? SessionStatus::Paused
: SessionStatus::Playing;
break;
case MPV_EVENT_END_FILE: {
const auto* endFile =
static_cast<mpv_event_end_file*>(event->data);
if (endFile && endFile->reason == MPV_END_FILE_REASON_ERROR) {
session->snapshot.status = SessionStatus::Error;
session->snapshot.error =
endFile->error < 0
? mpv_error_string(endFile->error)
: "Playback failed.";
} else if (
endFile &&
endFile->reason == MPV_END_FILE_REASON_EOF &&
session->running.load()) {
session->snapshot.status = SessionStatus::Ended;
} else if (
endFile &&
endFile->reason == MPV_END_FILE_REASON_REDIRECT &&
session->running.load()) {
session->snapshot.status = SessionStatus::Loading;
session->snapshot.error.clear();
session->loadedPath = false;
} else if (session->running.load()) {
session->snapshot.status = SessionStatus::Idle;
}
break;
}
case MPV_EVENT_PROPERTY_CHANGE: {
const auto* property =
static_cast<mpv_event_property*>(event->data);
if (!property || !property->name) {
break;
}
const std::string propertyName = property->name;
if (propertyName == "time-pos" &&
property->format == MPV_FORMAT_DOUBLE &&
property->data) {
session->snapshot.positionSeconds =
*static_cast<double*>(property->data);
break;
}
if (propertyName == "duration" &&
property->format == MPV_FORMAT_DOUBLE &&
property->data) {
session->snapshot.durationSeconds =
*static_cast<double*>(property->data);
break;
}
if (propertyName == "pause" &&
property->format == MPV_FORMAT_FLAG &&
property->data) {
session->paused = *static_cast<int*>(property->data) != 0;
if (session->snapshot.status != SessionStatus::Loading &&
session->snapshot.status != SessionStatus::Ended &&
session->snapshot.status != SessionStatus::Error &&
session->loadedPath) {
session->snapshot.status = session->paused
? SessionStatus::Paused
: SessionStatus::Playing;
}
break;
}
if (propertyName == "eof-reached" &&
property->format == MPV_FORMAT_FLAG &&
property->data) {
const bool eofReached =
*static_cast<int*>(property->data) != 0;
if (eofReached &&
session->running.load() &&
session->loadedPath) {
session->snapshot.status = SessionStatus::Ended;
}
break;
}
if (propertyName == "volume" &&
property->format == MPV_FORMAT_DOUBLE &&
property->data) {
session->snapshot.volumePercent =
*static_cast<double*>(property->data);
break;
}
if (propertyName == "path" &&
property->format == MPV_FORMAT_STRING &&
property->data) {
const auto* pathValue =
static_cast<char*>(property->data);
session->snapshot.streamUrl = pathValue ? pathValue : "";
break;
}
if (propertyName == "track-list" &&
property->format == MPV_FORMAT_NODE &&
property->data) {
const auto& trackListNode =
*static_cast<mpv_node*>(property->data);
updateAudioTracksFromNode(
session->snapshot,
trackListNode
);
updateSubtitleTracksFromNode(
session->snapshot,
trackListNode
);
break;
}
if (propertyName == "aid" &&
property->format == MPV_FORMAT_STRING &&
property->data) {
const auto* aidValue = static_cast<char*>(property->data);
int64_t selectedTrackId = -1;
if (aidValue && parseIntegerString(aidValue, selectedTrackId)) {
updateSelectedAudioTrack(
session->snapshot,
selectedTrackId
);
}
break;
}
if (propertyName == "sid" &&
property->format == MPV_FORMAT_STRING &&
property->data) {
const auto* sidValue = static_cast<char*>(property->data);
int64_t selectedTrackId = -1;
if (sidValue && parseIntegerString(sidValue, selectedTrackId)) {
updateSelectedSubtitleTrack(
session->snapshot,
selectedTrackId
);
} else {
updateSelectedSubtitleTrack(session->snapshot, -1);
}
break;
}
if (propertyName == "speed" &&
property->format == MPV_FORMAT_DOUBLE &&
property->data) {
session->snapshot.playbackSpeed =
*static_cast<double*>(property->data);
break;
}
if (propertyName == "video-aspect-override" &&
property->format == MPV_FORMAT_STRING &&
property->data) {
const auto* aspectValue =
static_cast<char*>(property->data);
session->snapshot.aspectOverride =
aspectValue ? aspectValue : "no";
break;
}
break;
}
case MPV_EVENT_LOG_MESSAGE: {
const auto* logMessage =
static_cast<mpv_event_log_message*>(event->data);
if (!logMessage || !logMessage->level || !logMessage->text) {
break;
}
const std::string level = logMessage->level;
const char* prefix =
logMessage->prefix ? logMessage->prefix : "mpv";
if (level == "warn" || level == "error" || level == "fatal") {
fprintf(
stderr,
"[embedded-mpv][%s][%s] %s: %s",
session->id.c_str(),
level.c_str(),
prefix,
logMessage->text
);
}
if (level == "error" || level == "fatal") {
session->snapshot.error = logMessage->text;
}
if (level == "fatal") {
session->snapshot.status = SessionStatus::Error;
}
break;
}
case MPV_EVENT_COMMAND_REPLY:
case MPV_EVENT_SET_PROPERTY_REPLY:
if (reconcilePlaybackLoadReply(
session,
event->reply_userdata,
event->error
)) {
break;
}
if (reconcileRecordingPropertyReply(
session,
event->reply_userdata,
event->error
)) {
break;
}
// Only a failed loadfile may flip the session status: a
// rejected seek/aid/speed reply on a live stream must not
// surface the error UI while playback keeps running.
if (event->error < 0) {
session->snapshot.error = mpv_error_string(event->error);
}
break;
case MPV_EVENT_SHUTDOWN:
session->running.store(false);
session->snapshot.status = SessionStatus::Closed;
break;
default:
break;
}
}
}
bool createSoftwareView(const std::shared_ptr<Session>& session)
{
__block bool created = false;
runOnMainSync(^{
session->containerView =
[[EmbeddedMpvContainerView alloc] initWithFrame:NSMakeRect(0, 0, 0, 0)];
session->containerView.wantsLayer = YES;
session->containerView.layer = [CALayer layer];
session->containerView.layer.backgroundColor =
NSColor.blackColor.CGColor;
session->containerView.layer.contentsGravity = kCAGravityResize;
[session->hostView addSubview:session->containerView
positioned:NSWindowAbove
relativeTo:nil];
created = true;
});
return created;
}
bool createOpenGLView(const std::shared_ptr<Session>& session)
{
__block bool created = false;
runOnMainSync(^{
NSOpenGLPixelFormatAttribute attributes[] = {
NSOpenGLPFAOpenGLProfile,
NSOpenGLProfileVersion3_2Core,
NSOpenGLPFAAccelerated,
NSOpenGLPFADoubleBuffer,
NSOpenGLPFAColorSize,
24,
NSOpenGLPFAAlphaSize,
8,
0,
};
session->openGLPixelFormat =
[[NSOpenGLPixelFormat alloc] initWithAttributes:attributes];
if (!session->openGLPixelFormat) {
return;
}
auto* openGLView =
[[EmbeddedMpvOpenGLView alloc] initWithFrame:NSMakeRect(0, 0, 0, 0)
pixelFormat:session->openGLPixelFormat];
if (!openGLView) {
session->openGLPixelFormat = nil;
return;
}
[openGLView setWantsBestResolutionOpenGLSurface:YES];
session->openGLContext = [openGLView openGLContext];
if (!session->openGLContext) {
session->openGLPixelFormat = nil;
return;
}
GLint swapInterval = 1;
[session->openGLContext setValues:&swapInterval
forParameter:NSOpenGLContextParameterSwapInterval];
[session->openGLContext setView:openGLView];
session->containerView = openGLView;
[session->hostView addSubview:session->containerView
positioned:NSWindowAbove
relativeTo:nil];
created = true;
});
return created;
}
void removeRenderView(const std::shared_ptr<Session>& session)
{
runOnMainSync(^{
if (session->containerView) {
[session->containerView removeFromSuperview];
session->containerView = nil;
}
session->openGLContext = nil;
session->openGLPixelFormat = nil;
});
}
int createSoftwareRenderContext(const std::shared_ptr<Session>& session)
{
mpv_render_param renderParams[] = {
{ MPV_RENDER_PARAM_API_TYPE, (void*)MPV_RENDER_API_TYPE_SW },
{ MPV_RENDER_PARAM_INVALID, nullptr },
};
return mpv_render_context_create(
&session->renderContext,
session->handle,
renderParams
);
}
int createOpenGLRenderContext(const std::shared_ptr<Session>& session)
{
if (!session->openGLContext || !session->renderQueue) {
return MPV_ERROR_UNSUPPORTED;
}
__block int renderResult = MPV_ERROR_UNSUPPORTED;
dispatch_sync(session->renderQueue, ^{
CGLContextObj cglContext = [session->openGLContext CGLContextObj];
if (!cglContext) {
renderResult = MPV_ERROR_UNSUPPORTED;
return;
}
CGLLockContext(cglContext);
[session->openGLContext makeCurrentContext];
mpv_opengl_init_params openGLInitParams = {
getOpenGLProcAddress,
nullptr,
};
mpv_render_param renderParams[] = {
{ MPV_RENDER_PARAM_API_TYPE, (void*)MPV_RENDER_API_TYPE_OPENGL },
{ MPV_RENDER_PARAM_OPENGL_INIT_PARAMS, &openGLInitParams },
{ MPV_RENDER_PARAM_INVALID, nullptr },
};
renderResult = mpv_render_context_create(
&session->renderContext,
session->handle,
renderParams
);
[NSOpenGLContext clearCurrentContext];
CGLUnlockContext(cglContext);
});
return renderResult;
}
bool startDisplayLink(const std::shared_ptr<Session>& session)
{
if (!session->openGLContext ||
!session->openGLPixelFormat ||
!session->renderCallbackContext) {
return false;
}
CVDisplayLinkRef displayLink = nullptr;
CVReturn result = CVDisplayLinkCreateWithActiveCGDisplays(&displayLink);
if (result != kCVReturnSuccess || !displayLink) {
return false;
}
result = CVDisplayLinkSetCurrentCGDisplayFromOpenGLContext(
displayLink,
[session->openGLContext CGLContextObj],
[session->openGLPixelFormat CGLPixelFormatObj]
);
if (result != kCVReturnSuccess) {
CVDisplayLinkRelease(displayLink);
return false;
}
CVDisplayLinkSetOutputCallback(
displayLink,
displayLinkCallback,
session->renderCallbackContext
);
result = CVDisplayLinkStart(displayLink);
if (result != kCVReturnSuccess) {
CVDisplayLinkRelease(displayLink);
return false;
}
session->displayLink = displayLink;
return true;
}
Napi::Value IsSupported(const Napi::CallbackInfo& info)
{
return Napi::Boolean::New(info.Env(), true);
}
Napi::Value CreateSession(const Napi::CallbackInfo& info)
{
Napi::Env env = info.Env();
if (info.Length() < 2 || !info[0].IsBuffer() || !info[1].IsObject()) {
throw Napi::TypeError::New(
env,
"Expected window handle buffer and bounds object."
);
}
const auto windowHandle = info[0].As<Napi::Buffer<uint8_t>>();
if (windowHandle.Length() < sizeof(uintptr_t)) {
throw Napi::Error::New(env, "Native window handle buffer is too small.");
}
uintptr_t windowHandleValue = 0;
std::memcpy(
&windowHandleValue,
windowHandle.Data(),
std::min(windowHandle.Length(), sizeof(uintptr_t))
);
NSView* parentView = (__bridge NSView*)reinterpret_cast<void*>(
windowHandleValue
);
if (!parentView) {
throw Napi::Error::New(env, "Unable to resolve Electron native window handle.");
}
const auto bounds = info[1].As<Napi::Object>();
const auto session = std::make_shared<Session>();
session->id = "embedded-mpv-" + std::to_string(gNextSessionId.fetch_add(1));
session->parentView = parentView;
session->hostView = [parentView window]
? [[parentView window] contentView]
: parentView;
session->renderQueue = dispatch_queue_create(
"dev.iptvnator.embedded-mpv.render",
DISPATCH_QUEUE_SERIAL
);
session->snapshot.volumePercent =
info.Length() >= 4 && info[3].IsNumber()
? clampVolumePercent(info[3].As<Napi::Number>().DoubleValue())
: 100.0;
if (!session->hostView) {
throw Napi::Error::New(env, "Unable to resolve the Electron content view.");
}
session->renderBackend = requestedRenderBackend();
if (session->renderBackend == RenderBackend::OpenGL &&
!createOpenGLView(session)) {
traceEmbeddedMpv(
session,
"OpenGL view creation failed; falling back to software renderer."
);
session->renderBackend = RenderBackend::Software;
}
if (session->renderBackend == RenderBackend::Software &&
!createSoftwareView(session)) {
destroySession(session);
throw Napi::Error::New(env, "Failed to create embedded MPV view.");
}
session->handle = mpv_create();
if (!session->handle) {
destroySession(session);
throw Napi::Error::New(env, "Failed to create libmpv handle.");
}
mpv_set_option_string(session->handle, "terminal", "no");
mpv_set_option_string(session->handle, "config", "no");
mpv_set_option_string(session->handle, "osc", "no");
mpv_set_option_string(session->handle, "idle", "yes");
mpv_set_option_string(session->handle, "keep-open", "yes");
mpv_set_option_string(session->handle, "input-default-bindings", "no");
mpv_set_option_string(session->handle, "input-vo-keyboard", "no");
// Keep parity with the external MPV launch path and avoid yt-dlp probing
// IPTV-style URLs before handing them to FFmpeg directly.
mpv_set_option_string(session->handle, "ytdl", "no");
// The macOS `wid` path stays black inside Electron. Render into our own
// surface instead of asking mpv to own a foreign Cocoa subwindow.
mpv_set_option_string(session->handle, "vo", "libmpv");
if (session->renderBackend == RenderBackend::OpenGL) {
mpv_set_option_string(session->handle, "hwdec", "auto-safe");
}
const auto initialVolume =
std::to_string(session->snapshot.volumePercent);
mpv_set_option_string(session->handle, "volume", initialVolume.c_str());
mpv_request_log_messages(session->handle, "warn");
const int initializeResult = mpv_initialize(session->handle);
if (initializeResult < 0) {
destroySession(session);
throw Napi::Error::New(
env,
std::string("Failed to initialize libmpv: ") +
mpv_error_string(initializeResult)
);
}
int renderResult = session->renderBackend == RenderBackend::OpenGL
? createOpenGLRenderContext(session)
: createSoftwareRenderContext(session);
if (renderResult < 0 &&
session->renderBackend == RenderBackend::OpenGL) {
traceEmbeddedMpv(
session,
std::string("OpenGL render context failed: ") +
mpv_error_string(renderResult) +
"; falling back to software renderer."
);
removeRenderView(session);
session->renderBackend = RenderBackend::Software;
if (!createSoftwareView(session)) {
destroySession(session);
throw Napi::Error::New(env, "Failed to create embedded MPV fallback view.");
}
renderResult = createSoftwareRenderContext(session);
}
if (renderResult < 0) {
destroySession(session);
throw Napi::Error::New(
env,
std::string("Failed to create libmpv render context: ") +
mpv_error_string(renderResult)
);
}
session->renderCallbackContext =
new std::weak_ptr<Session>(session);
if (session->renderBackend == RenderBackend::OpenGL &&
!startDisplayLink(session)) {
traceEmbeddedMpv(
session,
"CVDisplayLink startup failed; falling back to software renderer."
);
dispatch_sync(session->renderQueue, ^{
if (session->renderContext) {
CGLContextObj cglContext = [session->openGLContext CGLContextObj];
if (cglContext) {
CGLLockContext(cglContext);
[session->openGLContext makeCurrentContext];
}
mpv_render_context_free(session->renderContext);
session->renderContext = nullptr;
[NSOpenGLContext clearCurrentContext];
if (cglContext) {
CGLUnlockContext(cglContext);
}
}
});
removeRenderView(session);
session->renderBackend = RenderBackend::Software;
if (!createSoftwareView(session)) {
destroySession(session);
throw Napi::Error::New(env, "Failed to create embedded MPV fallback view.");
}
renderResult = createSoftwareRenderContext(session);
if (renderResult < 0) {
destroySession(session);
throw Napi::Error::New(
env,
std::string("Failed to create libmpv fallback render context: ") +
mpv_error_string(renderResult)
);
}
}
traceEmbeddedMpv(
session,
std::string("Using renderer backend: ") +
renderBackendName(session->renderBackend)
);
mpv_render_context_set_update_callback(
session->renderContext,
onRenderContextUpdate,
session->renderCallbackContext
);
mpv_observe_property(session->handle, 1, "time-pos", MPV_FORMAT_DOUBLE);
mpv_observe_property(session->handle, 2, "duration", MPV_FORMAT_DOUBLE);
mpv_observe_property(session->handle, 3, "pause", MPV_FORMAT_FLAG);
mpv_observe_property(session->handle, 4, "volume", MPV_FORMAT_DOUBLE);
mpv_observe_property(session->handle, 5, "path", MPV_FORMAT_STRING);
mpv_observe_property(session->handle, 6, "track-list", MPV_FORMAT_NODE);
mpv_observe_property(session->handle, 7, "aid", MPV_FORMAT_STRING);
mpv_observe_property(session->handle, 8, "sid", MPV_FORMAT_STRING);
mpv_observe_property(session->handle, 9, "speed", MPV_FORMAT_DOUBLE);
mpv_observe_property(
session->handle,
10,
"video-aspect-override",
MPV_FORMAT_STRING
);
mpv_observe_property(session->handle, 11, "eof-reached", MPV_FORMAT_FLAG);
session->running.store(true);
session->eventThread = std::thread(runEventLoop, session);
setSessionFrame(
session,
readOptionalNumber(bounds, "x", 0),
readOptionalNumber(bounds, "y", 0),
readOptionalNumber(bounds, "width", 0),
readOptionalNumber(bounds, "height", 0)
);
{
std::lock_guard<std::mutex> sessionsLock(gSessionsMutex);
gSessions.emplace(session->id, session);
}
return Napi::String::New(env, session->id);
}
Napi::Value LoadPlayback(const Napi::CallbackInfo& info)
{
Napi::Env env = info.Env();
if (info.Length() < 2 || !info[0].IsString() || !info[1].IsObject()) {
throw Napi::TypeError::New(env, "Expected session id and playback object.");
}
const std::string sessionId = info[0].As<Napi::String>().Utf8Value();
const auto playback = info[1].As<Napi::Object>();
const auto session = getSessionOrThrow(env, sessionId);
const std::string streamUrl = readOptionalString(playback, "streamUrl");
if (streamUrl.empty()) {
throw Napi::Error::New(env, "Embedded MPV playback requires a stream URL.");
}
const std::string title = readOptionalString(playback, "title");
const std::string userAgent = readOptionalString(playback, "userAgent");
const std::string referer = readOptionalString(playback, "referer");
const double startTime = readOptionalNumber(playback, "startTime", -1);
bool recordingActive = false;
{
std::lock_guard<std::mutex> lock(session->mutex);
recordingActive = session->snapshot.recordingActive;
}
if (recordingActive) {
const char* disabledValue = "";
const uint64_t stopRecordingRequestId = nextAsyncRequestId();
{
std::lock_guard<std::mutex> lock(session->mutex);
session->pendingRecordingStopRequestId = stopRecordingRequestId;
session->pendingRecordingStopStartedAt =
session->snapshot.recordingStartedAt;
}
const int stopResult = mpv_set_property_async(
session->handle,
stopRecordingRequestId,
"stream-record",
MPV_FORMAT_STRING,
const_cast<char**>(&disabledValue)
);
if (stopResult < 0) {
{
std::lock_guard<std::mutex> lock(session->mutex);
if (
session->pendingRecordingStopRequestId ==
stopRecordingRequestId
) {
session->pendingRecordingStopRequestId = 0;
session->pendingRecordingStopStartedAt.clear();
}
}
updateSessionError(session, mpv_error_string(stopResult));
throw Napi::Error::New(
env,
std::string("Failed to stop recording before loading playback: ") +
mpv_error_string(stopResult)
);
}
}
std::vector<std::pair<std::string, std::string>> options;
if (!title.empty()) {
options.emplace_back("force-media-title", title);
}
if (!userAgent.empty()) {
options.emplace_back("user-agent", userAgent);
}
if (!referer.empty()) {
options.emplace_back("referrer", referer);
}
if (std::isfinite(startTime) && startTime >= 0) {
std::ostringstream startValue;
startValue << startTime;
options.emplace_back("start", startValue.str());
}
if (playback.Has("headers") && playback.Get("headers").IsObject()) {
const auto headerFields =
joinHeaderFields(playback.Get("headers").As<Napi::Object>());
if (!headerFields.empty()) {
options.emplace_back("http-header-fields", headerFields);
}
}
std::vector<mpv_node> optionValues(options.size());
std::vector<char*> optionKeys(options.size());
for (size_t index = 0; index < options.size(); index += 1) {
optionKeys[index] =
const_cast<char*>(options[index].first.c_str());
optionValues[index].format = MPV_FORMAT_STRING;
optionValues[index].u.string =
const_cast<char*>(options[index].second.c_str());
}
mpv_node_list optionList{};
optionList.num = static_cast<int>(options.size());
optionList.values = options.empty() ? nullptr : optionValues.data();
optionList.keys = options.empty() ? nullptr : optionKeys.data();
mpv_node optionMap{};
optionMap.format = MPV_FORMAT_NODE_MAP;
optionMap.u.list = &optionList;
mpv_node commandValues[5]{};
commandValues[0].format = MPV_FORMAT_STRING;
commandValues[0].u.string = const_cast<char*>("loadfile");
commandValues[1].format = MPV_FORMAT_STRING;
commandValues[1].u.string = const_cast<char*>(streamUrl.c_str());
commandValues[2].format = MPV_FORMAT_STRING;
commandValues[2].u.string = const_cast<char*>("replace");
commandValues[3].format = MPV_FORMAT_INT64;
commandValues[3].u.int64 = -1;
commandValues[4] = optionMap;
mpv_node_list commandList{};
commandList.num = 5;
commandList.values = commandValues;
commandList.keys = nullptr;
mpv_node command{};
command.format = MPV_FORMAT_NODE_ARRAY;
command.u.list = &commandList;
const uint64_t loadPlaybackRequestId = nextAsyncRequestId();
{
std::lock_guard<std::mutex> lock(session->mutex);
session->snapshot.streamUrl = streamUrl;
session->snapshot.error.clear();
session->snapshot.status = SessionStatus::Loading;
session->snapshot.recordingActive = false;
session->snapshot.recordingTargetPath.clear();
session->snapshot.recordingStartedAt.clear();
session->snapshot.recordingError.clear();
session->pendingPlaybackLoadRequestId = loadPlaybackRequestId;
}
const int commandResult = mpv_command_node_async(
session->handle,
loadPlaybackRequestId,
&command
);
if (commandResult < 0) {
{
std::lock_guard<std::mutex> lock(session->mutex);
session->pendingPlaybackLoadRequestId = 0;
}
updateSessionError(session, mpv_error_string(commandResult));
throw Napi::Error::New(
env,
std::string("Failed to load playback: ") +
mpv_error_string(commandResult)
);
}
return env.Undefined();
}
Napi::Value SetBounds(const Napi::CallbackInfo& info)
{
Napi::Env env = info.Env();
if (info.Length() < 2 || !info[0].IsString() || !info[1].IsObject()) {
throw Napi::TypeError::New(env, "Expected session id and bounds object.");
}
const std::string sessionId = info[0].As<Napi::String>().Utf8Value();
const auto bounds = info[1].As<Napi::Object>();
const auto session = getSessionOrThrow(env, sessionId);
setSessionFrame(
session,
readOptionalNumber(bounds, "x", 0),
readOptionalNumber(bounds, "y", 0),
readOptionalNumber(bounds, "width", 0),
readOptionalNumber(bounds, "height", 0)
);
return env.Undefined();
}
Napi::Value SetPaused(const Napi::CallbackInfo& info)
{
Napi::Env env = info.Env();
if (info.Length() < 2 || !info[0].IsString() || !info[1].IsBoolean()) {
throw Napi::TypeError::New(env, "Expected session id and paused flag.");
}
const std::string sessionId = info[0].As<Napi::String>().Utf8Value();
const auto session = getSessionOrThrow(env, sessionId);
int paused = info[1].As<Napi::Boolean>().Value() ? 1 : 0;
const int result = mpv_set_property_async(
session->handle,
nextAsyncRequestId(),
"pause",
MPV_FORMAT_FLAG,
&paused
);
if (result < 0) {
throw Napi::Error::New(
env,
std::string("Failed to update playback state: ") +
mpv_error_string(result)
);
}
{
std::lock_guard<std::mutex> lock(session->mutex);
session->paused = paused != 0;
if (session->loadedPath &&
session->snapshot.status != SessionStatus::Ended &&
session->snapshot.status != SessionStatus::Error) {
session->snapshot.status = session->paused
? SessionStatus::Paused
: SessionStatus::Playing;
}
}
return env.Undefined();
}
Napi::Value Seek(const Napi::CallbackInfo& info)
{
Napi::Env env = info.Env();
if (info.Length() < 2 || !info[0].IsString() || !info[1].IsNumber()) {
throw Napi::TypeError::New(env, "Expected session id and seek target.");
}
const std::string sessionId = info[0].As<Napi::String>().Utf8Value();
const auto session = getSessionOrThrow(env, sessionId);
const auto target = info[1].As<Napi::Number>().DoubleValue();
const std::string targetValue = std::to_string(target);
const char* command[] = {
"seek",
targetValue.c_str(),
"absolute+exact",
nullptr,
};
const int result = mpv_command_async(
session->handle,
nextAsyncRequestId(),
command
);
if (result < 0) {
throw Napi::Error::New(
env,
std::string("Failed to seek playback: ") +
mpv_error_string(result)
);
}
{
std::lock_guard<std::mutex> lock(session->mutex);
session->snapshot.positionSeconds = std::max(0.0, target);
}
return env.Undefined();
}
Napi::Value SetVolume(const Napi::CallbackInfo& info)
{
Napi::Env env = info.Env();
if (info.Length() < 2 || !info[0].IsString() || !info[1].IsNumber()) {
throw Napi::TypeError::New(env, "Expected session id and volume.");
}
const std::string sessionId = info[0].As<Napi::String>().Utf8Value();
const auto session = getSessionOrThrow(env, sessionId);
double volume = clampVolumePercent(
info[1].As<Napi::Number>().DoubleValue()
);
const int result = mpv_set_property_async(
session->handle,
nextAsyncRequestId(),
"volume",
MPV_FORMAT_DOUBLE,
&volume
);
if (result < 0) {
throw Napi::Error::New(
env,
std::string("Failed to update volume: ") +
mpv_error_string(result)
);
}
{
std::lock_guard<std::mutex> lock(session->mutex);
session->snapshot.volumePercent = volume;
}
return env.Undefined();
}
Napi::Value SetAudioTrack(const Napi::CallbackInfo& info)
{
Napi::Env env = info.Env();
if (info.Length() < 2 || !info[0].IsString() || !info[1].IsNumber()) {
throw Napi::TypeError::New(env, "Expected session id and audio track id.");
}
const std::string sessionId = info[0].As<Napi::String>().Utf8Value();
const auto session = getSessionOrThrow(env, sessionId);
int64_t trackId = info[1].As<Napi::Number>().Int64Value();
const int result = mpv_set_property_async(
session->handle,
nextAsyncRequestId(),
"aid",
MPV_FORMAT_INT64,
&trackId
);
if (result < 0) {
throw Napi::Error::New(
env,
std::string("Failed to update audio track: ") +
mpv_error_string(result)
);
}
{
std::lock_guard<std::mutex> lock(session->mutex);
updateSelectedAudioTrack(session->snapshot, trackId);
}
return env.Undefined();
}
Napi::Value SetSubtitleTrack(const Napi::CallbackInfo& info)
{
Napi::Env env = info.Env();
if (info.Length() < 2 || !info[0].IsString() || !info[1].IsNumber()) {
throw Napi::TypeError::New(
env,
"Expected session id and subtitle track id."
);
}
const std::string sessionId = info[0].As<Napi::String>().Utf8Value();
const auto session = getSessionOrThrow(env, sessionId);
int64_t trackId = info[1].As<Napi::Number>().Int64Value();
int result = -1;
if (trackId < 0) {
const char* disabledValue = "no";
std::string disabled = disabledValue;
result = mpv_set_property_async(
session->handle,
nextAsyncRequestId(),
"sid",
MPV_FORMAT_STRING,
const_cast<char**>(&disabledValue)
);
} else {
result = mpv_set_property_async(
session->handle,
nextAsyncRequestId(),
"sid",
MPV_FORMAT_INT64,
&trackId
);
}
if (result < 0) {
throw Napi::Error::New(
env,
std::string("Failed to update subtitle track: ") +
mpv_error_string(result)
);
}
{
std::lock_guard<std::mutex> lock(session->mutex);
updateSelectedSubtitleTrack(session->snapshot, trackId);
}
return env.Undefined();
}
Napi::Value SetSpeed(const Napi::CallbackInfo& info)
{
Napi::Env env = info.Env();
if (info.Length() < 2 || !info[0].IsString() || !info[1].IsNumber()) {
throw Napi::TypeError::New(env, "Expected session id and speed.");
}
const std::string sessionId = info[0].As<Napi::String>().Utf8Value();
const auto session = getSessionOrThrow(env, sessionId);
double speed = info[1].As<Napi::Number>().DoubleValue();
speed = std::clamp(speed, 0.25, 4.0);
const int result = mpv_set_property_async(
session->handle,
nextAsyncRequestId(),
"speed",
MPV_FORMAT_DOUBLE,
&speed
);
if (result < 0) {
throw Napi::Error::New(
env,
std::string("Failed to update playback speed: ") +
mpv_error_string(result)
);
}
{
std::lock_guard<std::mutex> lock(session->mutex);
session->snapshot.playbackSpeed = speed;
}
return env.Undefined();
}
Napi::Value SetAspect(const Napi::CallbackInfo& info)
{
Napi::Env env = info.Env();
if (info.Length() < 2 || !info[0].IsString() || !info[1].IsString()) {
throw Napi::TypeError::New(
env,
"Expected session id and aspect override."
);
}
const std::string sessionId = info[0].As<Napi::String>().Utf8Value();
const auto session = getSessionOrThrow(env, sessionId);
std::string aspect = info[1].As<Napi::String>().Utf8Value();
if (aspect.empty()) {
aspect = "no";
}
const char* aspectValue = aspect.c_str();
const int result = mpv_set_property_async(
session->handle,
nextAsyncRequestId(),
"video-aspect-override",
MPV_FORMAT_STRING,
const_cast<char**>(&aspectValue)
);
if (result < 0) {
throw Napi::Error::New(
env,
std::string("Failed to update aspect override: ") +
mpv_error_string(result)
);
}
{
std::lock_guard<std::mutex> lock(session->mutex);
session->snapshot.aspectOverride = aspect;
}
return env.Undefined();
}
Napi::Value StartRecording(const Napi::CallbackInfo& info)
{
Napi::Env env = info.Env();
if (info.Length() < 2 || !info[0].IsString() || !info[1].IsString()) {
throw Napi::TypeError::New(
env,
"Expected session id and recording target path."
);
}
const std::string sessionId = info[0].As<Napi::String>().Utf8Value();
const std::string targetPath = info[1].As<Napi::String>().Utf8Value();
if (targetPath.empty()) {
throw Napi::Error::New(env, "Recording target path is required.");
}
const auto session = getSessionOrThrow(env, sessionId);
const char* targetValue = targetPath.c_str();
const uint64_t requestId = nextAsyncRequestId();
const std::string startedAt = currentUtcTimestamp();
{
std::lock_guard<std::mutex> lock(session->mutex);
session->pendingRecordingStartRequestId = requestId;
session->pendingRecordingTargetPath = targetPath;
session->pendingRecordingStartedAt = startedAt;
session->snapshot.recordingActive = true;
session->snapshot.recordingTargetPath = targetPath;
session->snapshot.recordingStartedAt = startedAt;
session->snapshot.recordingError.clear();
}
const int result = mpv_set_property_async(
session->handle,
requestId,
"stream-record",
MPV_FORMAT_STRING,
const_cast<char**>(&targetValue)
);
if (result < 0) {
{
std::lock_guard<std::mutex> lock(session->mutex);
if (session->pendingRecordingStartRequestId == requestId) {
session->pendingRecordingStartRequestId = 0;
session->pendingRecordingTargetPath.clear();
session->pendingRecordingStartedAt.clear();
}
session->snapshot.recordingActive = false;
session->snapshot.recordingStartedAt.clear();
session->snapshot.recordingError = mpv_error_string(result);
}
throw Napi::Error::New(
env,
std::string("Failed to start stream recording: ") +
mpv_error_string(result)
);
}
return env.Undefined();
}
Napi::Value StopRecording(const Napi::CallbackInfo& info)
{
Napi::Env env = info.Env();
if (info.Length() < 1 || !info[0].IsString()) {
throw Napi::TypeError::New(env, "Expected session id.");
}
const std::string sessionId = info[0].As<Napi::String>().Utf8Value();
const auto session = getSessionOrThrow(env, sessionId);
const char* disabledValue = "";
const uint64_t requestId = nextAsyncRequestId();
{
std::lock_guard<std::mutex> lock(session->mutex);
session->pendingRecordingStopRequestId = requestId;
session->pendingRecordingStopStartedAt =
session->snapshot.recordingStartedAt;
session->snapshot.recordingActive = false;
session->snapshot.recordingStartedAt.clear();
session->snapshot.recordingError.clear();
}
const int result = mpv_set_property_async(
session->handle,
requestId,
"stream-record",
MPV_FORMAT_STRING,
const_cast<char**>(&disabledValue)
);
if (result < 0) {
{
std::lock_guard<std::mutex> lock(session->mutex);
const std::string startedAt =
session->pendingRecordingStopStartedAt;
if (session->pendingRecordingStopRequestId == requestId) {
session->pendingRecordingStopRequestId = 0;
session->pendingRecordingStopStartedAt.clear();
}
session->snapshot.recordingActive = true;
session->snapshot.recordingStartedAt = startedAt;
session->snapshot.recordingError = mpv_error_string(result);
}
throw Napi::Error::New(
env,
std::string("Failed to stop stream recording: ") +
mpv_error_string(result)
);
}
return env.Undefined();
}
Napi::Value GetSessionSnapshot(const Napi::CallbackInfo& info)
{
Napi::Env env = info.Env();
if (info.Length() < 1 || !info[0].IsString()) {
throw Napi::TypeError::New(env, "Expected session id.");
}
const std::string sessionId = info[0].As<Napi::String>().Utf8Value();
std::shared_ptr<Session> session;
{
std::lock_guard<std::mutex> sessionsLock(gSessionsMutex);
const auto iterator = gSessions.find(sessionId);
if (iterator == gSessions.end()) {
return env.Null();
}
session = iterator->second;
}
SessionSnapshot snapshot;
{
std::lock_guard<std::mutex> lock(session->mutex);
snapshot = session->snapshot;
}
auto result = Napi::Object::New(env);
result.Set("status", toStatusString(snapshot.status));
result.Set(
"positionSeconds",
Napi::Number::New(env, snapshot.positionSeconds)
);
if (snapshot.durationSeconds < 0) {
result.Set("durationSeconds", env.Null());
} else {
result.Set(
"durationSeconds",
Napi::Number::New(env, snapshot.durationSeconds)
);
}
result.Set("volume", Napi::Number::New(env, snapshot.volumePercent / 100.0));
result.Set("streamUrl", Napi::String::New(env, snapshot.streamUrl));
if (snapshot.selectedAudioTrackId >= 0) {
result.Set(
"selectedAudioTrackId",
Napi::Number::New(env, snapshot.selectedAudioTrackId)
);
} else {
result.Set("selectedAudioTrackId", env.Null());
}
auto audioTracks = Napi::Array::New(env, snapshot.audioTracks.size());
for (size_t index = 0; index < snapshot.audioTracks.size(); index += 1) {
const AudioTrack& track = snapshot.audioTracks[index];
auto trackObject = Napi::Object::New(env);
trackObject.Set("id", Napi::Number::New(env, track.id));
if (!track.title.empty()) {
trackObject.Set("title", Napi::String::New(env, track.title));
}
if (!track.language.empty()) {
trackObject.Set("language", Napi::String::New(env, track.language));
}
trackObject.Set("selected", Napi::Boolean::New(env, track.selected));
trackObject.Set(
"defaultTrack",
Napi::Boolean::New(env, track.defaultTrack)
);
trackObject.Set("forced", Napi::Boolean::New(env, track.forced));
audioTracks.Set(index, trackObject);
}
result.Set("audioTracks", audioTracks);
if (snapshot.selectedSubtitleTrackId >= 0) {
result.Set(
"selectedSubtitleTrackId",
Napi::Number::New(env, snapshot.selectedSubtitleTrackId)
);
} else {
result.Set("selectedSubtitleTrackId", env.Null());
}
auto subtitleTracks =
Napi::Array::New(env, snapshot.subtitleTracks.size());
for (size_t index = 0; index < snapshot.subtitleTracks.size();
index += 1) {
const AudioTrack& track = snapshot.subtitleTracks[index];
auto trackObject = Napi::Object::New(env);
trackObject.Set("id", Napi::Number::New(env, track.id));
if (!track.title.empty()) {
trackObject.Set("title", Napi::String::New(env, track.title));
}
if (!track.language.empty()) {
trackObject.Set("language", Napi::String::New(env, track.language));
}
trackObject.Set("selected", Napi::Boolean::New(env, track.selected));
trackObject.Set(
"defaultTrack",
Napi::Boolean::New(env, track.defaultTrack)
);
trackObject.Set("forced", Napi::Boolean::New(env, track.forced));
subtitleTracks.Set(index, trackObject);
}
result.Set("subtitleTracks", subtitleTracks);
result.Set(
"playbackSpeed",
Napi::Number::New(env, snapshot.playbackSpeed)
);
result.Set(
"aspectOverride",
Napi::String::New(env, snapshot.aspectOverride)
);
if (snapshot.recordingActive ||
!snapshot.recordingTargetPath.empty() ||
!snapshot.recordingError.empty()) {
auto recording = Napi::Object::New(env);
recording.Set(
"active",
Napi::Boolean::New(env, snapshot.recordingActive)
);
if (!snapshot.recordingTargetPath.empty()) {
recording.Set(
"targetPath",
Napi::String::New(env, snapshot.recordingTargetPath)
);
}
if (!snapshot.recordingStartedAt.empty()) {
recording.Set(
"startedAt",
Napi::String::New(env, snapshot.recordingStartedAt)
);
}
if (!snapshot.recordingError.empty()) {
recording.Set(
"error",
Napi::String::New(env, snapshot.recordingError)
);
}
result.Set("recording", recording);
}
if (!snapshot.error.empty()) {
result.Set("error", Napi::String::New(env, snapshot.error));
}
return result;
}
Napi::Value DisposeSession(const Napi::CallbackInfo& info)
{
Napi::Env env = info.Env();
if (info.Length() < 1 || !info[0].IsString()) {
throw Napi::TypeError::New(env, "Expected session id.");
}
const std::string sessionId = info[0].As<Napi::String>().Utf8Value();
std::shared_ptr<Session> session;
{
std::lock_guard<std::mutex> sessionsLock(gSessionsMutex);
const auto iterator = gSessions.find(sessionId);
if (iterator == gSessions.end()) {
return env.Undefined();
}
session = iterator->second;
gSessions.erase(iterator);
}
destroySession(session);
return env.Undefined();
}
Napi::Object Init(Napi::Env env, Napi::Object exports)
{
exports.Set("isSupported", Napi::Function::New(env, IsSupported));
exports.Set("createSession", Napi::Function::New(env, CreateSession));
exports.Set("loadPlayback", Napi::Function::New(env, LoadPlayback));
exports.Set("setBounds", Napi::Function::New(env, SetBounds));
exports.Set("setPaused", Napi::Function::New(env, SetPaused));
exports.Set("seek", Napi::Function::New(env, Seek));
exports.Set("setVolume", Napi::Function::New(env, SetVolume));
exports.Set("setAudioTrack", Napi::Function::New(env, SetAudioTrack));
exports.Set(
"setSubtitleTrack",
Napi::Function::New(env, SetSubtitleTrack)
);
exports.Set("setSpeed", Napi::Function::New(env, SetSpeed));
exports.Set("setAspect", Napi::Function::New(env, SetAspect));
exports.Set("startRecording", Napi::Function::New(env, StartRecording));
exports.Set("stopRecording", Napi::Function::New(env, StopRecording));
exports.Set(
"getSessionSnapshot",
Napi::Function::New(env, GetSessionSnapshot)
);
exports.Set("disposeSession", Napi::Function::New(env, DisposeSession));
return exports;
}
} // namespace
NODE_API_MODULE(embedded_mpv, Init)