mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-08-01 17:16:11 +00:00
feat: macos mpv bridging
This commit is contained in:
parent
6222c10ee9
commit
7fed385da2
8 changed files with 1007 additions and 2 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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() }
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
661
composeApp/src/desktopMain/native/macos/player_bridge.mm
Normal file
661
composeApp/src/desktopMain/native/macos/player_bridge.mm
Normal 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, ¤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<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];
|
||||
}
|
||||
Loading…
Reference in a new issue