diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index 8e975de0..53a082cf 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -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("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("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().configureEach { + if (isMacHost && name == "desktopJar") { + dependsOn(buildMacosPlayerBridge) + from(macosPlayerBridgeOutput) { + into("native/macos") + } + } +} + tasks.withType>().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) diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/Main.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/Main.kt index d5d106ca..a5384a5f 100644 --- a/composeApp/src/desktopMain/kotlin/com/nuvio/app/Main.kt +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/Main.kt @@ -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(null) } + PlatformPlayerSurface( + sourceUrl = smokePlayerUrl, + modifier = Modifier.fillMaxSize(), + onControllerReady = {}, + onSnapshot = {}, + onError = { error = it }, + ) + error?.let { println("Nuvio desktop player smoke error: $it") } + } } } diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/PlayerEngine.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/PlayerEngine.desktop.kt index 7a6ed480..8fd657c7 100644 --- a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/PlayerEngine.desktop.kt +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/PlayerEngine.desktop.kt @@ -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, + 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() } diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/desktop/DesktopHostOs.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/desktop/DesktopHostOs.kt new file mode 100644 index 00000000..fb63eae7 --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/desktop/DesktopHostOs.kt @@ -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 + } + } + } +} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/desktop/MacosAwtViewResolver.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/desktop/MacosAwtViewResolver.kt new file mode 100644 index 00000000..9007af2d --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/desktop/MacosAwtViewResolver.kt @@ -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() +} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/desktop/NativePlayerBridge.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/desktop/NativePlayerBridge.kt new file mode 100644 index 00000000..813ef06f --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/desktop/NativePlayerBridge.kt @@ -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, + 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" + } +} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/desktop/NativePlayerHost.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/desktop/NativePlayerHost.kt new file mode 100644 index 00000000..2c12ad04 --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/desktop/NativePlayerHost.kt @@ -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() + } +} diff --git a/composeApp/src/desktopMain/native/macos/player_bridge.mm b/composeApp/src/desktopMain/native/macos/player_bridge.mm new file mode 100644 index 00000000..e182ed19 --- /dev/null +++ b/composeApp/src/desktopMain/native/macos/player_bridge.mm @@ -0,0 +1,661 @@ +#import +#import +#import +#import + +#include + +#include +#include +#include +#include + +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 +@property(nonatomic, weak) MpvWebPlayer *player; +@end + +@interface MpvWebPlayer : NSObject +- (instancetype)initWithHostView:(NSView *)hostView + sourceUrl:(NSString *)sourceUrl + headerLines:(NSArray *)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, ¤tFbo); + + 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 *)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 *)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 @"" + "" + "" + "
Native macOS player
" + "
0:00 / 0:00
" + ""; +} + +- (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 *jstringArrayToNSArray(JNIEnv *env, jobjectArray values) { + NSMutableArray *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 *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]; +}