mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-10 10:06:15 -08:00
* fix(playback): seek Embedded MPV steps relative to mpv's own position Arrow keys and the ±10 s buttons in the Embedded MPV player advanced only about a second per press when pressed repeatedly or held. The shortcuts already asked for 5 s steps, but `EmbeddedMpvCommandRunner.seekBy` turned each step into an absolute `seek` computed from `session.positionSeconds`, which is floored to whole seconds, polled every 500 ms (helper snapshots at most every 250 ms) and not refreshed by the seek reply. Every press inside that window therefore landed on the same target. Steps now go through a new `EMBEDDED_MPV_SEEK_BY` IPC / `seekEmbeddedMpvBy` bridge method that every backend forwards as mpv `seek <delta> relative+exact`: `seekBy` exports in the macOS addon and the Windows/Linux `wid` addon (Linux over its JSON IPC socket), and a `seek-by` stdin command in the frame-copy helper. mpv resolves the delta against its own position and merges queued relative seeks, so presses accumulate as in mpv itself. The absolute form survives only as a fallback for a preload without the method or an addon binary without `seekBy`; the timeline scrub still commits an absolute target. Validated with a real mpv 0.39 IPC probe: three relative seeks in a burst advance +15 s, three absolute seeks from one stale base advance +5 s. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(playback): drop speculative position update from relative Embedded MPV seeks Review follow-up for the relative seek path. The macOS and Windows/Linux `seekBy` exports advanced `snapshot.positionSeconds` by the delta after dispatching the mpv command. That is not idempotent the way the absolute seek's optimistic write is: the observer (mpv event thread, or the Linux IPC poll) can already have stored the post-seek `time-pos` under the same mutex, so adding the delta on top counted the step twice, and while paused nothing corrected it. On Linux it also advertised a position that a failed socket delivery never reached. Relative steps now leave the snapshot alone; only the observed `time-pos` updates the position. The packaged Linux frame-copy smoke now drives `seekEmbeddedMpvBy` through the built app: a burst of three +2 s steps issued without waiting for snapshots has to land on 6 s, and a -60 s step has to clamp at 0. The generated Y4M fixture grows from 2 s to 12 s (about 415 KB) so the burst and the playing section that follows stay inside the clip. Replayed against a local mpv 0.39 with the same fixture and media server: burst -> 6.0, -60 -> 0. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * docs(agents): mirror the Embedded MPV relative-seek contract into AGENTS.md Review follow-up: the Shared Player Controls section documents the frame-copy commands and shortcuts, so the relative seekEmbeddedMpvBy invariant lives there too, next to the CLAUDE.md note. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(playback): reject a Linux relative seek the mpv IPC socket did not accept Review follow-up: the Linux branch of SeekBy discarded the socket transaction result and returned normally, so a step that never reached mpv looked like a seek still awaiting observation. It now throws like a failed mpv_command_async on the in-process engines; the renderer swallows the rejection and resyncs from the next snapshot, and the main process logs it. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2669 lines
83 KiB
Plaintext
2669 lines
83 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 "embedded_mpv_extra_options.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;
|
|
// True when `error` is the engine's own failure (render context, fatal
|
|
// libmpv log) rather than the stream's: the app must not reload for it.
|
|
bool engineError = false;
|
|
// Keys of session options libmpv refused at creation (names only, never
|
|
// values: a value may carry credentials). Reported once by the service.
|
|
std::vector<std::string> rejectedOptionKeys;
|
|
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 engineError = false
|
|
);
|
|
|
|
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),
|
|
true
|
|
);
|
|
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),
|
|
true
|
|
);
|
|
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,
|
|
bool engineError
|
|
)
|
|
{
|
|
std::lock_guard<std::mutex> lock(session->mutex);
|
|
session->snapshot.status = SessionStatus::Error;
|
|
session->snapshot.error = error;
|
|
session->snapshot.engineError = engineError;
|
|
}
|
|
|
|
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.engineError = false;
|
|
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->snapshot.engineError = false;
|
|
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;
|
|
session->snapshot.engineError = true;
|
|
}
|
|
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());
|
|
|
|
// Session options from Settings (network defaults first, then the
|
|
// user's lines). Applied last so a user value overrides a built-in one,
|
|
// and before mpv_initialize because most of them are init-only.
|
|
for (const auto& option : iptvnator::readEmbeddedMpvExtraOptions(info, 4)) {
|
|
const int optionResult = mpv_set_option_string(
|
|
session->handle,
|
|
option.first.c_str(),
|
|
option.second.c_str()
|
|
);
|
|
if (optionResult < 0) {
|
|
traceEmbeddedMpv(
|
|
session,
|
|
"rejected session option " + option.first + ": " +
|
|
mpv_error_string(optionResult)
|
|
);
|
|
session->snapshot.rejectedOptionKeys.push_back(option.first);
|
|
}
|
|
}
|
|
|
|
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.engineError = false;
|
|
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;
|
|
// Optimistic only while playing/paused: a load in flight keeps
|
|
// `loading` until MPV_EVENT_FILE_LOADED, otherwise a reconnect's
|
|
// unpause right after loadfile would report the replacement stream
|
|
// as playing before it opened (and clear the attempt indicator).
|
|
if (session->loadedPath &&
|
|
(session->snapshot.status == SessionStatus::Playing ||
|
|
session->snapshot.status == SessionStatus::Paused)) {
|
|
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 SeekBy(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 delta.");
|
|
}
|
|
|
|
const std::string sessionId = info[0].As<Napi::String>().Utf8Value();
|
|
const auto session = getSessionOrThrow(env, sessionId);
|
|
const auto delta = info[1].As<Napi::Number>().DoubleValue();
|
|
const std::string deltaValue = std::to_string(delta);
|
|
// Relative step (arrow keys, ±10 s buttons): mpv resolves the delta
|
|
// against its own playback position and merges relative seeks that are
|
|
// still queued, so a burst of presses accumulates instead of collapsing
|
|
// onto one target computed from the renderer's stale snapshot.
|
|
//
|
|
// Unlike the absolute Seek above, the snapshot is deliberately NOT
|
|
// advanced here: the event thread may already have stored the observed
|
|
// post-seek `time-pos` under the same mutex, and adding the delta on top
|
|
// of that would count the step twice with nothing to correct it while
|
|
// paused. Only the observed `time-pos` updates the position.
|
|
const char* command[] = {
|
|
"seek",
|
|
deltaValue.c_str(),
|
|
"relative+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)
|
|
);
|
|
}
|
|
|
|
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));
|
|
if (snapshot.engineError) {
|
|
result.Set("errorOrigin", Napi::String::New(env, "engine"));
|
|
}
|
|
}
|
|
if (!snapshot.rejectedOptionKeys.empty()) {
|
|
Napi::Array keys =
|
|
Napi::Array::New(env, snapshot.rejectedOptionKeys.size());
|
|
for (size_t i = 0; i < snapshot.rejectedOptionKeys.size(); ++i) {
|
|
keys.Set(
|
|
static_cast<uint32_t>(i),
|
|
Napi::String::New(env, snapshot.rejectedOptionKeys[i])
|
|
);
|
|
}
|
|
result.Set("rejectedOptionKeys", keys);
|
|
}
|
|
|
|
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("seekBy", Napi::Function::New(env, SeekBy));
|
|
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)
|