feat: macos mpv bridging

This commit is contained in:
tapframe 2026-06-08 03:49:56 +05:30
parent 6222c10ee9
commit 7fed385da2
8 changed files with 1007 additions and 2 deletions

View file

@ -8,6 +8,7 @@ import org.gradle.api.tasks.InputFile
import org.gradle.api.tasks.Optional
import org.gradle.api.tasks.OutputDirectory
import org.gradle.api.tasks.TaskAction
import org.gradle.jvm.tasks.Jar
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
import org.jetbrains.kotlin.gradle.tasks.KotlinCompilationTask
import java.util.Properties
@ -207,6 +208,63 @@ val generateRuntimeConfigs = tasks.register<GenerateRuntimeConfigsTask>("generat
appVersionCode.set(releaseAppVersionCode)
}
val isMacHost = System.getProperty("os.name").contains("mac", ignoreCase = true)
val stremioMacosDir = providers.gradleProperty("nuvio.stremio.macos.dir")
.orElse("/Applications/Stremio.app/Contents/MacOS")
val macosPlayerBridgeSource = layout.projectDirectory.file("src/desktopMain/native/macos/player_bridge.mm")
val macosPlayerBridgeOutput = layout.buildDirectory.file("native/macos/libplayer_bridge.dylib")
val buildMacosPlayerBridge = tasks.register<Exec>("buildMacosPlayerBridge") {
notCompatibleWithConfigurationCache("Builds a host-local player bridge against the installed Stremio libmpv.")
enabled = isMacHost
inputs.file(macosPlayerBridgeSource)
inputs.file(stremioMacosDir.map { "$it/libmpv.dylib" })
outputs.file(macosPlayerBridgeOutput)
doFirst {
val stremioDir = stremioMacosDir.get()
val stremioMpv = File(stremioDir, "libmpv.dylib")
require(stremioMpv.exists()) {
"Stremio libmpv was not found at ${stremioMpv.path}. Set -Pnuvio.stremio.macos.dir=/path/to/Stremio.app/Contents/MacOS."
}
macosPlayerBridgeOutput.get().asFile.parentFile.mkdirs()
}
val javaHome = providers.systemProperty("java.home")
commandLine(
"xcrun",
"clang++",
"-std=c++17",
"-dynamiclib",
"-fobjc-arc",
"-ObjC++",
macosPlayerBridgeSource.asFile.absolutePath,
"-o",
macosPlayerBridgeOutput.get().asFile.absolutePath,
"-I${javaHome.get()}/include",
"-I${javaHome.get()}/include/darwin",
"-L${stremioMacosDir.get()}",
"-lmpv",
"-Wl,-rpath,${stremioMacosDir.get()}",
"-framework",
"AppKit",
"-framework",
"WebKit",
"-framework",
"QuartzCore",
"-framework",
"OpenGL",
)
}
tasks.withType<Jar>().configureEach {
if (isMacHost && name == "desktopJar") {
dependsOn(buildMacosPlayerBridge)
from(macosPlayerBridgeOutput) {
into("native/macos")
}
}
}
tasks.withType<KotlinCompilationTask<*>>().configureEach {
dependsOn(generateRuntimeConfigs)
}
@ -325,6 +383,14 @@ kotlin {
compose.desktop {
application {
mainClass = "com.nuvio.app.MainKt"
val smokePlayerUrl = providers.gradleProperty("nuvio.desktop.smokePlayerUrl").orNull
?: System.getenv("NUVIO_DESKTOP_SMOKE_PLAYER_URL")
jvmArgs += listOfNotNull(
"--add-opens=java.desktop/java.awt=ALL-UNNAMED",
"--add-opens=java.desktop/sun.lwawt=ALL-UNNAMED",
"--add-opens=java.desktop/sun.lwawt.macosx=ALL-UNNAMED",
smokePlayerUrl?.takeIf { it.isNotBlank() }?.let { "-Dnuvio.desktop.smokePlayerUrl=$it" },
)
nativeDistributions {
targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb)

View file

@ -1,16 +1,41 @@
package com.nuvio.app
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.window.Window
import androidx.compose.ui.window.WindowState
import androidx.compose.ui.window.application
import androidx.compose.ui.unit.dp
import com.nuvio.app.features.player.PlatformPlayerSurface
fun main() = application {
val smokePlayerUrl = (
System.getProperty("nuvio.desktop.smokePlayerUrl")
?: System.getenv("NUVIO_DESKTOP_SMOKE_PLAYER_URL")
)
?.takeIf { it.isNotBlank() }
Window(
onCloseRequest = ::exitApplication,
title = "Nuvio",
title = if (smokePlayerUrl == null) "Nuvio" else "Nuvio Player Smoke",
state = WindowState(width = 1280.dp, height = 820.dp),
) {
App()
if (smokePlayerUrl == null) {
App()
} else {
var error by remember { mutableStateOf<String?>(null) }
PlatformPlayerSurface(
sourceUrl = smokePlayerUrl,
modifier = Modifier.fillMaxSize(),
onControllerReady = {},
onSnapshot = {},
onError = { error = it },
)
error?.let { println("Nuvio desktop player smoke error: $it") }
}
}
}

View file

@ -6,11 +6,17 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.awt.SwingPanel
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import com.nuvio.app.features.player.desktop.DesktopHostOs
import com.nuvio.app.features.player.desktop.NativePlayerController
import com.nuvio.app.features.player.desktop.NativePlayerHost
import kotlinx.coroutines.delay
@Composable
actual fun PlatformPlayerSurface(
@ -26,6 +32,81 @@ actual fun PlatformPlayerSurface(
onControllerReady: (PlayerEngineController) -> Unit,
onSnapshot: (PlayerPlaybackSnapshot) -> Unit,
onError: (String?) -> Unit,
) {
if (DesktopHostOs.current == DesktopHostOs.MACOS) {
NativePlayerSurface(
sourceUrl = sourceUrl,
sourceHeaders = sourceHeaders,
modifier = modifier,
playWhenReady = playWhenReady,
onControllerReady = onControllerReady,
onSnapshot = onSnapshot,
onError = onError,
)
return
}
DesktopStubPlayerSurface(
modifier = modifier,
onControllerReady = onControllerReady,
onSnapshot = onSnapshot,
)
}
@Composable
private fun NativePlayerSurface(
sourceUrl: String,
sourceHeaders: Map<String, String>,
modifier: Modifier,
playWhenReady: Boolean,
onControllerReady: (PlayerEngineController) -> Unit,
onSnapshot: (PlayerPlaybackSnapshot) -> Unit,
onError: (String?) -> Unit,
) {
val host = remember { NativePlayerHost() }
val controller = remember(host) { NativePlayerController(host) }
val playbackHeaders = remember(sourceHeaders) { sanitizePlaybackHeaders(sourceHeaders) }
LaunchedEffect(controller) {
onControllerReady(controller)
}
DisposableEffect(controller, sourceUrl, playbackHeaders) {
controller.attach(
sourceUrl = sourceUrl,
sourceHeaders = playbackHeaders,
playWhenReady = playWhenReady,
onError = onError,
)
onDispose { controller.dispose() }
}
LaunchedEffect(controller, playWhenReady) {
if (playWhenReady) {
controller.play()
} else {
controller.pause()
}
}
LaunchedEffect(controller) {
while (true) {
onSnapshot(controller.snapshot())
delay(500L)
}
}
SwingPanel(
factory = { host },
modifier = modifier.fillMaxSize(),
)
}
@Composable
private fun DesktopStubPlayerSurface(
modifier: Modifier,
onControllerReady: (PlayerEngineController) -> Unit,
onSnapshot: (PlayerPlaybackSnapshot) -> Unit,
) {
val controller = remember { DesktopStubPlayerController() }

View file

@ -0,0 +1,22 @@
package com.nuvio.app.features.player.desktop
import java.util.Locale
internal enum class DesktopHostOs {
MACOS,
WINDOWS,
LINUX,
UNKNOWN;
companion object {
val current: DesktopHostOs by lazy {
val osName = System.getProperty("os.name").orEmpty().lowercase(Locale.ROOT)
when {
osName.contains("mac") -> MACOS
osName.contains("win") -> WINDOWS
osName.contains("linux") -> LINUX
else -> UNKNOWN
}
}
}
}

View file

@ -0,0 +1,50 @@
package com.nuvio.app.features.player.desktop
import java.awt.Component
import java.lang.reflect.Field
import java.lang.reflect.Method
internal object AwtNativeViewResolver {
fun resolveNativeViewPointer(component: Component): Long =
when (DesktopHostOs.current) {
DesktopHostOs.MACOS -> MacosAwtViewResolver.resolveNativeViewPointer(component)
else -> error("Native desktop playback is not implemented for ${DesktopHostOs.current}.")
}
}
private object MacosAwtViewResolver {
private val componentPeerField: Field by lazy {
Component::class.java.getDeclaredField("peer").apply { isAccessible = true }
}
fun resolveNativeViewPointer(component: Component): Long {
val peer = componentPeerField.get(component)
?: error("AWT component peer is not ready for native playback.")
val platformWindow = invokeObject(peer, "getPlatformWindow")
val contentView = invokeObject(platformWindow, "getContentView")
val pointer = invokeLong(contentView, "getAWTView")
if (pointer == 0L) {
error("macOS AWT view pointer was zero.")
}
return pointer
}
private fun findMethod(type: Class<*>, name: String): Method {
var current: Class<*>? = type
while (current != null) {
runCatching {
return current.getDeclaredMethod(name).apply { isAccessible = true }
}
current = current.superclass
}
error("Method $name was not found on ${type.name}.")
}
private fun invokeObject(target: Any, methodName: String): Any =
findMethod(target.javaClass, methodName).invoke(target)
?: error("$methodName returned null.")
private fun invokeLong(target: Any, methodName: String): Long =
(findMethod(target.javaClass, methodName).invoke(target) as Number).toLong()
}

View file

@ -0,0 +1,77 @@
package com.nuvio.app.features.player.desktop
import java.io.File
import java.nio.file.Files
internal object NativePlayerBridge {
init {
loadNativeLibrary()
}
external fun create(
hostViewPtr: Long,
sourceUrl: String,
headerLines: Array<String>,
playWhenReady: Boolean,
): Long
external fun dispose(handle: Long)
external fun setPaused(handle: Long, paused: Boolean)
external fun seekTo(handle: Long, positionMs: Long)
external fun seekBy(handle: Long, offsetMs: Long)
external fun setSpeed(handle: Long, speed: Float)
external fun durationMs(handle: Long): Long
external fun positionMs(handle: Long): Long
external fun isPaused(handle: Long): Boolean
external fun speed(handle: Long): Float
private fun loadNativeLibrary() {
val platform = DesktopHostOs.current
require(platform == DesktopHostOs.MACOS) {
"Native desktop playback is not implemented for $platform yet."
}
val libraryName = nativeLibraryName(platform)
val platformDir = nativeDirectoryName(platform)
findLocalBuildLibrary(platformDir, libraryName)?.let { localLibrary ->
System.load(localLibrary.absolutePath)
return
}
val resource = "/native/$platformDir/$libraryName"
val input = NativePlayerBridge::class.java.getResourceAsStream(resource)
?: error("Missing bundled native player bridge: $resource")
val dir = File(System.getProperty("java.io.tmpdir"), "native-player-bridge").apply { mkdirs() }
val suffix = libraryName.substringAfter("player_bridge", ".dylib")
val file = Files.createTempFile(dir.toPath(), "player-bridge-", suffix).toFile()
file.deleteOnExit()
input.use { source ->
file.outputStream().use { target -> source.copyTo(target) }
}
System.load(file.absolutePath)
}
private fun findLocalBuildLibrary(platformDir: String, libraryName: String): File? {
val candidates = listOf(
File("composeApp/build/native/$platformDir/$libraryName"),
File("build/native/$platformDir/$libraryName"),
)
return candidates.firstOrNull { it.exists() }
}
private fun nativeDirectoryName(platform: DesktopHostOs): String =
when (platform) {
DesktopHostOs.MACOS -> "macos"
DesktopHostOs.WINDOWS -> "windows"
DesktopHostOs.LINUX -> "linux"
DesktopHostOs.UNKNOWN -> "unknown"
}
private fun nativeLibraryName(platform: DesktopHostOs): String =
when (platform) {
DesktopHostOs.MACOS -> "libplayer_bridge.dylib"
DesktopHostOs.WINDOWS -> "player_bridge.dll"
DesktopHostOs.LINUX -> "libplayer_bridge.so"
DesktopHostOs.UNKNOWN -> "player_bridge"
}
}

View file

@ -0,0 +1,23 @@
package com.nuvio.app.features.player.desktop
import java.awt.Canvas
import java.awt.Color
internal class NativePlayerHost : Canvas() {
var onPeerReady: (() -> Unit)? = null
init {
background = Color.BLACK
ignoreRepaint = true
}
override fun addNotify() {
super.addNotify()
onPeerReady?.invoke()
}
override fun removeNotify() {
onPeerReady = null
super.removeNotify()
}
}

View file

@ -0,0 +1,661 @@
#import <Cocoa/Cocoa.h>
#import <OpenGL/gl3.h>
#import <QuartzCore/QuartzCore.h>
#import <WebKit/WebKit.h>
#include <jni.h>
#include <cmath>
#include <dlfcn.h>
#include <string>
#include <vector>
extern "C" {
typedef struct mpv_handle mpv_handle;
typedef struct mpv_render_context mpv_render_context;
typedef void *(*mpv_render_opengl_get_proc_address_fn)(void *ctx, const char *name);
typedef enum mpv_format {
MPV_FORMAT_NONE = 0,
MPV_FORMAT_STRING = 1,
MPV_FORMAT_OSD_STRING = 2,
MPV_FORMAT_FLAG = 3,
MPV_FORMAT_INT64 = 4,
MPV_FORMAT_DOUBLE = 5,
} mpv_format;
typedef enum mpv_render_param_type {
MPV_RENDER_PARAM_INVALID = 0,
MPV_RENDER_PARAM_API_TYPE = 1,
MPV_RENDER_PARAM_OPENGL_INIT_PARAMS = 2,
MPV_RENDER_PARAM_OPENGL_FBO = 3,
MPV_RENDER_PARAM_FLIP_Y = 4,
MPV_RENDER_PARAM_DEPTH = 5,
MPV_RENDER_PARAM_ICC_PROFILE = 6,
MPV_RENDER_PARAM_AMBIENT_LIGHT = 7,
MPV_RENDER_PARAM_X11_DISPLAY = 8,
MPV_RENDER_PARAM_WL_DISPLAY = 9,
MPV_RENDER_PARAM_ADVANCED_CONTROL = 10,
MPV_RENDER_PARAM_NEXT_FRAME_INFO = 11,
MPV_RENDER_PARAM_BLOCK_FOR_TARGET_TIME = 12,
MPV_RENDER_PARAM_SKIP_RENDERING = 13,
} mpv_render_param_type;
typedef struct mpv_render_param {
mpv_render_param_type type;
void *data;
} mpv_render_param;
typedef struct mpv_opengl_init_params {
mpv_render_opengl_get_proc_address_fn get_proc_address;
void *get_proc_address_ctx;
const char *extra_exts;
} mpv_opengl_init_params;
typedef struct mpv_opengl_fbo {
int fbo;
int w;
int h;
int internal_format;
} mpv_opengl_fbo;
mpv_handle *mpv_create(void);
int mpv_initialize(mpv_handle *ctx);
void mpv_terminate_destroy(mpv_handle *ctx);
int mpv_set_option(mpv_handle *ctx, const char *name, mpv_format format, void *data);
int mpv_set_option_string(mpv_handle *ctx, const char *name, const char *data);
int mpv_set_property(mpv_handle *ctx, const char *name, mpv_format format, void *data);
int mpv_get_property(mpv_handle *ctx, const char *name, mpv_format format, void *data);
int mpv_command(mpv_handle *ctx, const char **args);
const char *mpv_error_string(int error);
int mpv_render_context_create(mpv_render_context **res, mpv_handle *mpv, mpv_render_param *params);
void mpv_render_context_free(mpv_render_context *ctx);
void mpv_render_context_render(mpv_render_context *ctx, mpv_render_param *params);
void mpv_render_context_report_swap(mpv_render_context *ctx);
void mpv_render_context_set_update_callback(
mpv_render_context *ctx,
void (*callback)(void *callback_ctx),
void *callback_ctx
);
uint64_t mpv_render_context_update(mpv_render_context *ctx);
}
@class PlayerOpenGLView;
@class MpvWebPlayer;
@interface PlayerOpenGLView : NSOpenGLView
@property(nonatomic, assign) mpv_render_context *renderContext;
- (void)requestRender;
@end
@interface PlayerScriptHandler : NSObject <WKScriptMessageHandler>
@property(nonatomic, weak) MpvWebPlayer *player;
@end
@interface MpvWebPlayer : NSObject
- (instancetype)initWithHostView:(NSView *)hostView
sourceUrl:(NSString *)sourceUrl
headerLines:(NSArray<NSString *> *)headerLines
playWhenReady:(BOOL)playWhenReady;
- (void)shutdown;
- (void)setPaused:(BOOL)paused;
- (BOOL)isPaused;
- (void)seekToMilliseconds:(long long)positionMs;
- (void)seekByMilliseconds:(long long)offsetMs;
- (void)setSpeed:(double)speed;
- (double)speed;
- (long long)durationMs;
- (long long)positionMs;
- (void)handleScriptMessage:(NSDictionary *)message;
@end
static void *getOpenGLProcAddress(void * /* ctx */, const char *name) {
static void *openGlHandle = dlopen(
"/System/Library/Frameworks/OpenGL.framework/OpenGL",
RTLD_LAZY | RTLD_LOCAL
);
return openGlHandle ? dlsym(openGlHandle, name) : nullptr;
}
static void renderUpdateCallback(void *callbackContext) {
PlayerOpenGLView *view = (__bridge PlayerOpenGLView *)callbackContext;
dispatch_async(dispatch_get_main_queue(), ^{
[view requestRender];
});
}
@implementation PlayerOpenGLView
+ (NSOpenGLPixelFormat *)defaultPixelFormat {
NSOpenGLPixelFormatAttribute attributes[] = {
NSOpenGLPFAOpenGLProfile, NSOpenGLProfileVersion3_2Core,
NSOpenGLPFAAccelerated,
NSOpenGLPFADoubleBuffer,
NSOpenGLPFAColorSize, 24,
NSOpenGLPFAAlphaSize, 8,
NSOpenGLPFADepthSize, 0,
0
};
return [[NSOpenGLPixelFormat alloc] initWithAttributes:attributes];
}
- (instancetype)initWithFrame:(NSRect)frameRect {
self = [super initWithFrame:frameRect pixelFormat:[PlayerOpenGLView defaultPixelFormat]];
if (!self) {
return nil;
}
self.autoresizingMask = NSViewWidthSizable | NSViewHeightSizable;
self.wantsBestResolutionOpenGLSurface = YES;
return self;
}
- (BOOL)isOpaque {
return YES;
}
- (void)prepareOpenGL {
[super prepareOpenGL];
[[self openGLContext] makeCurrentContext];
GLint swapInterval = 1;
[[self openGLContext] setValues:&swapInterval forParameter:NSOpenGLContextParameterSwapInterval];
glClearColor(0.0f, 0.0f, 0.0f, 1.0f);
}
- (void)reshape {
[super reshape];
[[self openGLContext] update];
[self requestRender];
}
- (void)requestRender {
if (self.window) {
self.needsDisplay = YES;
}
}
- (void)drawRect:(NSRect)dirtyRect {
(void)dirtyRect;
[[self openGLContext] makeCurrentContext];
if (!self.renderContext) {
glClear(GL_COLOR_BUFFER_BIT);
[[self openGLContext] flushBuffer];
return;
}
NSRect backingBounds = [self convertRectToBacking:self.bounds];
GLint currentFbo = 0;
glGetIntegerv(GL_FRAMEBUFFER_BINDING, &currentFbo);
mpv_opengl_fbo fbo = {
(int)currentFbo,
(int)backingBounds.size.width,
(int)backingBounds.size.height,
GL_RGBA8
};
int flipY = 1;
mpv_render_param params[] = {
{MPV_RENDER_PARAM_OPENGL_FBO, &fbo},
{MPV_RENDER_PARAM_FLIP_Y, &flipY},
{MPV_RENDER_PARAM_INVALID, nullptr},
};
mpv_render_context_update(self.renderContext);
mpv_render_context_render(self.renderContext, params);
[[self openGLContext] flushBuffer];
mpv_render_context_report_swap(self.renderContext);
}
@end
@implementation PlayerScriptHandler
- (void)userContentController:(WKUserContentController *)userContentController
didReceiveScriptMessage:(WKScriptMessage *)message {
if ([message.body isKindOfClass:[NSDictionary class]]) {
[self.player handleScriptMessage:(NSDictionary *)message.body];
}
}
@end
@implementation MpvWebPlayer {
NSView *_hostView;
PlayerOpenGLView *_videoView;
WKWebView *_webView;
PlayerScriptHandler *_scriptHandler;
mpv_handle *_mpv;
mpv_render_context *_renderContext;
NSTimer *_timer;
}
- (instancetype)initWithHostView:(NSView *)hostView
sourceUrl:(NSString *)sourceUrl
headerLines:(NSArray<NSString *> *)headerLines
playWhenReady:(BOOL)playWhenReady {
self = [super init];
if (!self) {
return nil;
}
_hostView = hostView;
_hostView.wantsLayer = YES;
_hostView.layer.backgroundColor = NSColor.blackColor.CGColor;
_videoView = [[PlayerOpenGLView alloc] initWithFrame:_hostView.bounds];
_videoView.wantsLayer = YES;
_videoView.layer.backgroundColor = NSColor.blackColor.CGColor;
[_hostView addSubview:_videoView];
_scriptHandler = [PlayerScriptHandler new];
_scriptHandler.player = self;
WKUserContentController *contentController = [WKUserContentController new];
[contentController addScriptMessageHandler:_scriptHandler name:@"player"];
WKWebViewConfiguration *configuration = [WKWebViewConfiguration new];
configuration.userContentController = contentController;
_webView = [[WKWebView alloc] initWithFrame:_hostView.bounds configuration:configuration];
_webView.autoresizingMask = NSViewWidthSizable | NSViewHeightSizable;
_webView.wantsLayer = YES;
[_webView setValue:@NO forKey:@"drawsBackground"];
[_hostView addSubview:_webView positioned:NSWindowAbove relativeTo:_videoView];
[_webView loadHTMLString:[self controlsHtml] baseURL:nil];
[self startMpvWithSource:sourceUrl headerLines:headerLines playWhenReady:playWhenReady];
_timer = [NSTimer scheduledTimerWithTimeInterval:0.5
target:self
selector:@selector(syncControls)
userInfo:nil
repeats:YES];
return self;
}
- (void)startMpvWithSource:(NSString *)sourceUrl
headerLines:(NSArray<NSString *> *)headerLines
playWhenReady:(BOOL)playWhenReady {
_mpv = mpv_create();
if (!_mpv) {
@throw [NSException exceptionWithName:@"PlayerBridgeError"
reason:@"mpv_create failed"
userInfo:nil];
}
mpv_set_option_string(_mpv, "config", "no");
mpv_set_option_string(_mpv, "osc", "no");
mpv_set_option_string(_mpv, "input-default-bindings", "yes");
mpv_set_option_string(_mpv, "input-vo-keyboard", "no");
mpv_set_option_string(_mpv, "keep-open", "yes");
mpv_set_option_string(_mpv, "vo", "libmpv");
if (headerLines.count > 0) {
NSString *headers = [headerLines componentsJoinedByString:@","];
mpv_set_option_string(_mpv, "http-header-fields", headers.UTF8String);
}
int initResult = mpv_initialize(_mpv);
if (initResult < 0) {
NSString *reason = [NSString stringWithFormat:@"mpv_initialize failed: %s", mpv_error_string(initResult)];
@throw [NSException exceptionWithName:@"PlayerBridgeError" reason:reason userInfo:nil];
}
[[_videoView openGLContext] makeCurrentContext];
mpv_opengl_init_params glInit = {
getOpenGLProcAddress,
nullptr,
nullptr
};
const char *apiType = "opengl";
int advancedControl = 1;
mpv_render_param renderParams[] = {
{MPV_RENDER_PARAM_API_TYPE, (void *)apiType},
{MPV_RENDER_PARAM_OPENGL_INIT_PARAMS, &glInit},
{MPV_RENDER_PARAM_ADVANCED_CONTROL, &advancedControl},
{MPV_RENDER_PARAM_INVALID, nullptr},
};
int renderResult = mpv_render_context_create(&_renderContext, _mpv, renderParams);
if (renderResult < 0) {
NSString *reason = [NSString stringWithFormat:@"mpv render context failed: %s", mpv_error_string(renderResult)];
@throw [NSException exceptionWithName:@"PlayerBridgeError" reason:reason userInfo:nil];
}
_videoView.renderContext = _renderContext;
mpv_render_context_set_update_callback(_renderContext, renderUpdateCallback, (__bridge void *)_videoView);
const char *command[] = {"loadfile", sourceUrl.UTF8String, NULL};
int commandResult = mpv_command(_mpv, command);
if (commandResult < 0) {
NSString *reason = [NSString stringWithFormat:@"mpv loadfile failed: %s", mpv_error_string(commandResult)];
@throw [NSException exceptionWithName:@"PlayerBridgeError" reason:reason userInfo:nil];
}
[self setPaused:!playWhenReady];
}
- (NSString *)controlsHtml {
return @"<!doctype html>"
"<html><head><meta name='viewport' content='width=device-width,initial-scale=1'>"
"<style>"
"html,body{margin:0;width:100%;height:100%;background:transparent;color:#fff;font-family:-apple-system,BlinkMacSystemFont,Inter,sans-serif;overflow:hidden;}"
"#shade{position:absolute;inset:auto 0 0 0;height:132px;background:linear-gradient(transparent,rgba(0,0,0,.78));pointer-events:none;}"
"#bar{position:absolute;left:22px;right:22px;bottom:20px;display:flex;align-items:center;gap:14px;padding:12px 14px;border-radius:14px;background:rgba(10,10,12,.66);backdrop-filter:blur(22px);box-shadow:0 10px 30px rgba(0,0,0,.35);}"
"button{appearance:none;border:0;border-radius:999px;background:#fff;color:#09090b;width:42px;height:42px;font-size:16px;font-weight:800;cursor:pointer;}"
"#time{font-variant-numeric:tabular-nums;font-size:12px;min-width:92px;text-align:right;color:rgba(255,255,255,.82);}"
"input[type=range]{flex:1;accent-color:#fff;cursor:pointer;}"
"#badge{position:absolute;left:18px;top:14px;padding:7px 10px;border-radius:999px;background:rgba(0,0,0,.45);font-size:12px;color:rgba(255,255,255,.78);}"
"</style></head><body>"
"<div id='badge'>Native macOS player</div><div id='shade'></div>"
"<div id='bar'><button id='toggle'>||</button><input id='seek' type='range' min='0' max='1000' value='0'><span id='time'>0:00 / 0:00</span></div>"
"<script>"
"const send=(type,value)=>window.webkit.messageHandlers.player.postMessage({type,value});"
"const fmt=s=>{if(!isFinite(s)||s<0)s=0;const h=Math.floor(s/3600),m=Math.floor((s%3600)/60),x=Math.floor(s%60);return h?`${h}:${String(m).padStart(2,'0')}:${String(x).padStart(2,'0')}`:`${m}:${String(x).padStart(2,'0')}`};"
"const seek=document.getElementById('seek'),toggle=document.getElementById('toggle'),time=document.getElementById('time');"
"toggle.onclick=()=>send('toggle',0);"
"seek.oninput=()=>send('seekPercent',Number(seek.value)/1000);"
"window.playerUpdate=s=>{toggle.textContent=s.paused?'▶':'||';seek.value=s.duration>0?Math.max(0,Math.min(1000,(s.position/s.duration)*1000)):0;time.textContent=`${fmt(s.position)} / ${fmt(s.duration)}`;};"
"</script></body></html>";
}
- (void)syncControls {
if (!_webView || !_mpv) {
return;
}
double duration = [self doubleProperty:"duration" fallback:0.0];
double position = [self doubleProperty:"time-pos" fallback:0.0];
BOOL paused = [self isPaused];
NSString *script = [NSString stringWithFormat:
@"window.playerUpdate({duration:%0.3f,position:%0.3f,paused:%@})",
duration,
position,
paused ? @"true" : @"false"];
[_webView evaluateJavaScript:script completionHandler:nil];
}
- (void)shutdown {
[_timer invalidate];
_timer = nil;
if (_renderContext) {
mpv_render_context_set_update_callback(_renderContext, nullptr, nullptr);
_videoView.renderContext = nullptr;
mpv_render_context_free(_renderContext);
_renderContext = nullptr;
}
if (_mpv) {
mpv_terminate_destroy(_mpv);
_mpv = NULL;
}
[_webView.configuration.userContentController removeScriptMessageHandlerForName:@"player"];
[_webView removeFromSuperview];
[_videoView removeFromSuperview];
_webView = nil;
_videoView = nil;
_scriptHandler = nil;
}
- (void)setPaused:(BOOL)paused {
if (!_mpv) return;
int flag = paused ? 1 : 0;
mpv_set_property(_mpv, "pause", MPV_FORMAT_FLAG, &flag);
}
- (BOOL)isPaused {
if (!_mpv) return YES;
int flag = 1;
mpv_get_property(_mpv, "pause", MPV_FORMAT_FLAG, &flag);
return flag != 0;
}
- (void)seekToMilliseconds:(long long)positionMs {
if (!_mpv) return;
std::string seconds = std::to_string((double)positionMs / 1000.0);
const char *command[] = {"seek", seconds.c_str(), "absolute", NULL};
mpv_command(_mpv, command);
}
- (void)seekByMilliseconds:(long long)offsetMs {
if (!_mpv) return;
std::string seconds = std::to_string((double)offsetMs / 1000.0);
const char *command[] = {"seek", seconds.c_str(), "relative", NULL};
mpv_command(_mpv, command);
}
- (void)setSpeed:(double)speed {
if (!_mpv) return;
double clamped = fmax(0.25, fmin(4.0, speed));
mpv_set_property(_mpv, "speed", MPV_FORMAT_DOUBLE, &clamped);
}
- (double)speed {
return [self doubleProperty:"speed" fallback:1.0];
}
- (long long)durationMs {
return (long long)llround([self doubleProperty:"duration" fallback:0.0] * 1000.0);
}
- (long long)positionMs {
return (long long)llround([self doubleProperty:"time-pos" fallback:0.0] * 1000.0);
}
- (double)doubleProperty:(const char *)name fallback:(double)fallback {
if (!_mpv) return fallback;
double value = fallback;
if (mpv_get_property(_mpv, name, MPV_FORMAT_DOUBLE, &value) < 0) {
return fallback;
}
return value;
}
- (void)handleScriptMessage:(NSDictionary *)message {
NSString *type = message[@"type"];
if ([type isEqualToString:@"toggle"]) {
[self setPaused:![self isPaused]];
[self syncControls];
} else if ([type isEqualToString:@"seekPercent"]) {
NSNumber *value = message[@"value"];
double duration = [self doubleProperty:"duration" fallback:0.0];
if (duration > 0.0 && value) {
[self seekToMilliseconds:(long long)llround(duration * value.doubleValue * 1000.0)];
}
}
}
@end
static void runOnMainSync(dispatch_block_t block) {
if ([NSThread isMainThread]) {
block();
} else {
dispatch_sync(dispatch_get_main_queue(), block);
}
}
static void runOnMainAsync(dispatch_block_t block) {
if ([NSThread isMainThread]) {
block();
} else {
dispatch_async(dispatch_get_main_queue(), block);
}
}
static void throwJavaError(JNIEnv *env, NSString *message) {
jclass exceptionClass = env->FindClass("java/lang/IllegalStateException");
if (exceptionClass) {
env->ThrowNew(exceptionClass, message.UTF8String);
}
}
static std::string jstringToString(JNIEnv *env, jstring value) {
if (!value) return std::string();
const char *chars = env->GetStringUTFChars(value, nullptr);
std::string result = chars ? chars : "";
if (chars) {
env->ReleaseStringUTFChars(value, chars);
}
return result;
}
static NSArray<NSString *> *jstringArrayToNSArray(JNIEnv *env, jobjectArray values) {
NSMutableArray<NSString *> *result = [NSMutableArray array];
if (!values) {
return result;
}
jsize count = env->GetArrayLength(values);
for (jsize index = 0; index < count; index++) {
jstring item = (jstring)env->GetObjectArrayElement(values, index);
std::string value = jstringToString(env, item);
if (!value.empty()) {
[result addObject:[NSString stringWithUTF8String:value.c_str()]];
}
env->DeleteLocalRef(item);
}
return result;
}
extern "C" JNIEXPORT jlong JNICALL
Java_com_nuvio_app_features_player_desktop_NativePlayerBridge_create(
JNIEnv *env,
jobject /* bridge */,
jlong hostViewPtr,
jstring sourceUrl,
jobjectArray headerLines,
jboolean playWhenReady
) {
NSView *hostView = (__bridge NSView *)(void *)(intptr_t)hostViewPtr;
if (!hostView) {
throwJavaError(env, @"Unable to resolve the AWT host NSView for native playback.");
return 0;
}
std::string source = jstringToString(env, sourceUrl);
NSArray<NSString *> *headers = jstringArrayToNSArray(env, headerLines);
__block MpvWebPlayer *player = nil;
__block NSString *error = nil;
runOnMainSync(^{
@try {
player = [[MpvWebPlayer alloc]
initWithHostView:hostView
sourceUrl:[NSString stringWithUTF8String:source.c_str()]
headerLines:headers
playWhenReady:playWhenReady == JNI_TRUE];
} @catch (NSException *exception) {
error = exception.reason ?: exception.name;
}
});
if (error) {
throwJavaError(env, error);
return 0;
}
return (jlong)(intptr_t)CFBridgingRetain(player);
}
extern "C" JNIEXPORT void JNICALL
Java_com_nuvio_app_features_player_desktop_NativePlayerBridge_dispose(
JNIEnv * /* env */,
jobject /* bridge */,
jlong handle
) {
if (handle == 0) return;
MpvWebPlayer *player = (__bridge_transfer MpvWebPlayer *)(void *)(intptr_t)handle;
runOnMainSync(^{
[player shutdown];
});
}
extern "C" JNIEXPORT void JNICALL
Java_com_nuvio_app_features_player_desktop_NativePlayerBridge_setPaused(
JNIEnv * /* env */,
jobject /* bridge */,
jlong handle,
jboolean paused
) {
if (handle == 0) return;
MpvWebPlayer *player = (__bridge MpvWebPlayer *)(void *)(intptr_t)handle;
runOnMainAsync(^{
[player setPaused:paused == JNI_TRUE];
});
}
extern "C" JNIEXPORT void JNICALL
Java_com_nuvio_app_features_player_desktop_NativePlayerBridge_seekTo(
JNIEnv * /* env */,
jobject /* bridge */,
jlong handle,
jlong positionMs
) {
if (handle == 0) return;
MpvWebPlayer *player = (__bridge MpvWebPlayer *)(void *)(intptr_t)handle;
runOnMainAsync(^{
[player seekToMilliseconds:positionMs];
});
}
extern "C" JNIEXPORT void JNICALL
Java_com_nuvio_app_features_player_desktop_NativePlayerBridge_seekBy(
JNIEnv * /* env */,
jobject /* bridge */,
jlong handle,
jlong offsetMs
) {
if (handle == 0) return;
MpvWebPlayer *player = (__bridge MpvWebPlayer *)(void *)(intptr_t)handle;
runOnMainAsync(^{
[player seekByMilliseconds:offsetMs];
});
}
extern "C" JNIEXPORT void JNICALL
Java_com_nuvio_app_features_player_desktop_NativePlayerBridge_setSpeed(
JNIEnv * /* env */,
jobject /* bridge */,
jlong handle,
jfloat speed
) {
if (handle == 0) return;
MpvWebPlayer *player = (__bridge MpvWebPlayer *)(void *)(intptr_t)handle;
runOnMainAsync(^{
[player setSpeed:speed];
});
}
extern "C" JNIEXPORT jlong JNICALL
Java_com_nuvio_app_features_player_desktop_NativePlayerBridge_durationMs(
JNIEnv * /* env */,
jobject /* bridge */,
jlong handle
) {
if (handle == 0) return 0;
MpvWebPlayer *player = (__bridge MpvWebPlayer *)(void *)(intptr_t)handle;
return [player durationMs];
}
extern "C" JNIEXPORT jlong JNICALL
Java_com_nuvio_app_features_player_desktop_NativePlayerBridge_positionMs(
JNIEnv * /* env */,
jobject /* bridge */,
jlong handle
) {
if (handle == 0) return 0;
MpvWebPlayer *player = (__bridge MpvWebPlayer *)(void *)(intptr_t)handle;
return [player positionMs];
}
extern "C" JNIEXPORT jboolean JNICALL
Java_com_nuvio_app_features_player_desktop_NativePlayerBridge_isPaused(
JNIEnv * /* env */,
jobject /* bridge */,
jlong handle
) {
if (handle == 0) return JNI_TRUE;
MpvWebPlayer *player = (__bridge MpvWebPlayer *)(void *)(intptr_t)handle;
return [player isPaused] ? JNI_TRUE : JNI_FALSE;
}
extern "C" JNIEXPORT jfloat JNICALL
Java_com_nuvio_app_features_player_desktop_NativePlayerBridge_speed(
JNIEnv * /* env */,
jobject /* bridge */,
jlong handle
) {
if (handle == 0) return 1.0f;
MpvWebPlayer *player = (__bridge MpvWebPlayer *)(void *)(intptr_t)handle;
return (jfloat)[player speed];
}