feat: libmpv bridge for windows

This commit is contained in:
tapframe 2026-06-09 14:38:56 +05:30
parent ab9fdc571a
commit 64193ea410
6 changed files with 2031 additions and 4 deletions

View file

@ -5,8 +5,11 @@ import org.gradle.api.file.RegularFileProperty
import org.gradle.api.provider.Property
import org.gradle.api.tasks.Input
import org.gradle.api.tasks.InputFile
import org.gradle.api.tasks.InputDirectory
import org.gradle.api.tasks.Optional
import org.gradle.api.tasks.OutputDirectory
import org.gradle.api.tasks.OutputFile
import org.gradle.api.tasks.Sync
import org.gradle.api.tasks.TaskAction
import org.gradle.jvm.tasks.Jar
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
@ -151,6 +154,21 @@ fun readXcconfigValue(file: File, key: String): String? {
fun shellQuote(value: String): String = "'${value.replace("'", "'\"'\"'")}'"
fun cmdQuote(value: String): String = "\"${value.replace("\"", "\"\"")}\""
fun psSingleQuote(value: String): String = "'${value.replace("'", "''")}'"
fun semanticVersionSortKey(value: String): String =
value.split('.', '-', '_')
.joinToString(".") { part ->
part.toIntOrNull()?.toString()?.padStart(8, '0') ?: part
}
fun newestDirectory(root: File): File? =
root.takeIf(File::exists)
?.listFiles(File::isDirectory)
?.maxByOrNull { semanticVersionSortKey(it.name) }
plugins {
alias(libs.plugins.kotlinMultiplatform)
alias(libs.plugins.androidApplication)
@ -211,6 +229,7 @@ val generateRuntimeConfigs = tasks.register<GenerateRuntimeConfigsTask>("generat
}
val isMacHost = System.getProperty("os.name").contains("mac", ignoreCase = true)
val isWindowsHost = System.getProperty("os.name").contains("win", ignoreCase = true)
val mpvKitDir = providers.gradleProperty("nuvio.mpvkit.dir")
.orElse(rootProject.layout.projectDirectory.dir("MPVKit").asFile.absolutePath)
val macosPlayerBridgeSource = layout.projectDirectory.file("src/desktopMain/native/macos/player_bridge.mm")
@ -312,6 +331,204 @@ val buildMacosPlayerBridge = tasks.register<Exec>("buildMacosPlayerBridge") {
commandLine(macosPlayerBridgeCommand)
}
val windowsPlayerBridgeArch = when (System.getProperty("os.arch").lowercase()) {
"aarch64", "arm64" -> "arm64"
"x86" -> "x86"
else -> "x64"
}
val windowsPlayerBridgeSource = layout.projectDirectory.file("src/desktopMain/native/windows/player_bridge.cpp")
val windowsPlayerBridgeOutput = layout.buildDirectory.file("native/windows/player_bridge.dll")
val windowsPlayerBridgeImportLib = layout.buildDirectory.file("native/windows/player_bridge.lib")
val windowsPlayerBridgePdb = layout.buildDirectory.file("native/windows/player_bridge.pdb")
val windowsPlayerBridgeObj = layout.buildDirectory.file("native/windows/player_bridge.obj")
val windowsPlayerBridgeScript = layout.buildDirectory.file("native/windows/build-player-bridge.bat")
val windowsPlayerRuntimeOutput = layout.buildDirectory.dir("native/windows-runtime")
if (isWindowsHost) {
windowsPlayerBridgeOutput.get().asFile.parentFile.mkdirs()
}
val windowsWebView2Root = providers.gradleProperty("nuvio.webview2.dir").orNull
?.takeIf { it.isNotBlank() }
?.let(::File)
?: newestDirectory(File(System.getProperty("user.home"), ".nuget/packages/microsoft.web.webview2"))
?: File("__missing_webview2__")
val windowsWebView2IncludeDir = File(windowsWebView2Root, "build/native/include")
val windowsWebView2NativeDir = File(windowsWebView2Root, "build/native/$windowsPlayerBridgeArch")
val windowsWebView2LoaderLib = File(windowsWebView2NativeDir, "WebView2Loader.dll.lib")
val windowsWebView2LoaderDll = File(windowsWebView2NativeDir, "WebView2Loader.dll")
val windowsLibmpvRuntimeDir = providers.gradleProperty("nuvio.windows.libmpv.runtimeDir").orNull
?.takeIf { it.isNotBlank() }
?.let(::File)
?: listOf(
File("C:/Program Files (x86)/Nuvio/app/native"),
File("C:/Program Files/Nuvio/app/native"),
).firstOrNull { File(it, "libmpv-2.dll").exists() }
val windowsLibmpvDll = providers.gradleProperty("nuvio.windows.libmpv.dll").orNull
?.takeIf { it.isNotBlank() }
?.let(::File)
?: windowsLibmpvRuntimeDir?.resolve("libmpv-2.dll")
?: listOf(
File("C:/msys64/ucrt64/bin/libmpv-2.dll"),
File("C:/msys64/mingw64/bin/libmpv-2.dll"),
).firstOrNull(File::exists)
val windowsVsWhere = File("C:/Program Files (x86)/Microsoft Visual Studio/Installer/vswhere.exe")
val windowsVcvarsRelativePath = when (windowsPlayerBridgeArch) {
"x86" -> "VC\\Auxiliary\\Build\\vcvars32.bat"
"arm64" -> "VC\\Auxiliary\\Build\\vcvarsarm64.bat"
else -> "VC\\Auxiliary\\Build\\vcvars64.bat"
}
val windowsVcvarsPath = providers.gradleProperty("nuvio.windows.vcvars.path").orNull
?.takeIf { it.isNotBlank() }
val windowsPlayerBridgeJavaHome = providers.systemProperty("java.home").get()
val missingWindowsPlayerBridgeInputs = listOfNotNull(
"WebView2.h".takeUnless { windowsWebView2IncludeDir.resolve("WebView2.h").exists() },
"WebView2Loader.dll.lib".takeUnless { windowsWebView2LoaderLib.exists() },
)
val missingWindowsPlayerBridgeMessage = """
Windows desktop player bridge inputs are missing: ${missingWindowsPlayerBridgeInputs.joinToString()}.
Install the Microsoft.Web.WebView2 NuGet package or pass -Pnuvio.webview2.dir=C:/path/to/microsoft.web.webview2/version.
libmpv is loaded at runtime; pass -Pnuvio.windows.libmpv.runtimeDir=C:/path/to/mpv-dlls to bundle it.
""".trimIndent()
val windowsPlayerBridgeCommand = if (missingWindowsPlayerBridgeInputs.isNotEmpty()) {
listOf(
"cmd",
"/c",
"echo ${missingWindowsPlayerBridgeMessage.replace("\n", " ")} 1>&2 && exit /b 1",
)
} else {
val sourceFile = windowsPlayerBridgeSource.asFile
val outputFile = windowsPlayerBridgeOutput.get().asFile
val importLibFile = windowsPlayerBridgeImportLib.get().asFile
val pdbFile = windowsPlayerBridgePdb.get().asFile
val objFile = windowsPlayerBridgeObj.get().asFile
val javaIncludeDir = File(windowsPlayerBridgeJavaHome, "include")
val javaWin32IncludeDir = File(javaIncludeDir, "win32")
val compileCommand = listOf(
"cl",
"/nologo",
"/EHsc",
"/std:c++17",
"/LD",
"/DUNICODE",
"/D_UNICODE",
"/DNOMINMAX",
"/DWIN32_LEAN_AND_MEAN",
"/permissive-",
cmdQuote(sourceFile.absolutePath),
"/I${cmdQuote(javaIncludeDir.absolutePath)}",
"/I${cmdQuote(javaWin32IncludeDir.absolutePath)}",
"/I${cmdQuote(windowsWebView2IncludeDir.absolutePath)}",
"/Fo${cmdQuote(objFile.absolutePath)}",
"/Fd${cmdQuote(pdbFile.absolutePath)}",
"/Fe${cmdQuote(outputFile.absolutePath)}",
"/link",
"/NOLOGO",
"/INCREMENTAL:NO",
"/IMPLIB:${cmdQuote(importLibFile.absolutePath)}",
cmdQuote(windowsWebView2LoaderLib.absolutePath),
"Ole32.lib",
"User32.lib",
"Gdi32.lib",
).joinToString(" ")
val powershellCompileCommand = compileCommand.replace("\"", "__DQ__")
val powershellCommand = """
${'$'}ErrorActionPreference = 'Stop'
${'$'}dq = [char]34
${'$'}vcvars = ${psSingleQuote(windowsVcvarsPath.orEmpty())}
if ([string]::IsNullOrWhiteSpace(${'$'}vcvars)) {
${'$'}vswhere = ${psSingleQuote(windowsVsWhere.absolutePath)}
if (Test-Path -LiteralPath ${'$'}vswhere) {
${'$'}vcvars = & ${'$'}vswhere -latest -products '*' -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -find ${psSingleQuote(windowsVcvarsRelativePath)} | Select-Object -First 1
}
}
if ([string]::IsNullOrWhiteSpace(${'$'}vcvars) -or -not (Test-Path -LiteralPath ${'$'}vcvars)) {
Write-Error 'Visual Studio C++ toolchain was not found. Install MSVC or pass -Pnuvio.windows.vcvars.path=C:\path\to\vcvars64.bat.'
exit 1
}
${'$'}vcvars = ([string]${'$'}vcvars).Trim()
${'$'}bat = ${psSingleQuote(windowsPlayerBridgeScript.get().asFile.absolutePath)}
${'$'}compile = ${psSingleQuote(powershellCompileCommand)}.Replace('__DQ__', ${'$'}dq)
${'$'}lines = @(
'@echo off',
('set {0}VCVARS={1}{0}' -f ${'$'}dq, ${'$'}vcvars),
('call {0}%VCVARS%{0} >nul' -f ${'$'}dq),
'if errorlevel 1 exit /b %errorlevel%',
${'$'}compile,
'exit /b %ERRORLEVEL%'
)
Set-Content -LiteralPath ${'$'}bat -Value ${'$'}lines -Encoding ASCII
& cmd.exe /d /c ${'$'}bat
${'$'}code = ${'$'}LASTEXITCODE
if (${'$'}code -ne 0) { exit ${'$'}code }
""".trimIndent()
listOf(
"powershell",
"-NoProfile",
"-ExecutionPolicy",
"Bypass",
"-Command",
powershellCommand,
)
}
val buildWindowsPlayerBridge = tasks.register<Exec>("buildWindowsPlayerBridge") {
notCompatibleWithConfigurationCache("Builds a host-local player bridge against WebView2 and libmpv for Windows.")
enabled = isWindowsHost
inputs.file(windowsPlayerBridgeSource)
if (windowsWebView2IncludeDir.exists()) {
inputs.dir(windowsWebView2IncludeDir)
}
if (windowsWebView2LoaderLib.exists()) {
inputs.file(windowsWebView2LoaderLib)
}
outputs.file(windowsPlayerBridgeOutput)
outputs.file(windowsPlayerBridgeImportLib)
outputs.file(windowsPlayerBridgePdb)
commandLine(windowsPlayerBridgeCommand)
}
val prepareWindowsPlayerRuntime = tasks.register<Sync>("prepareWindowsPlayerRuntime") {
enabled = isWindowsHost
into(windowsPlayerRuntimeOutput)
if (windowsWebView2LoaderDll.exists()) {
from(windowsWebView2LoaderDll)
}
when {
windowsLibmpvRuntimeDir?.exists() == true -> {
from(windowsLibmpvRuntimeDir) {
include("*.dll")
}
}
windowsLibmpvDll?.exists() == true -> {
from(windowsLibmpvDll)
}
}
}
val generateWindowsPlayerRuntimeIndex = tasks.register<GenerateNativeRuntimeIndexTask>("generateWindowsPlayerRuntimeIndex") {
enabled = isWindowsHost
dependsOn(prepareWindowsPlayerRuntime)
runtimeDir.set(windowsPlayerRuntimeOutput)
indexFile.set(windowsPlayerRuntimeOutput.map { it.file("runtime-files.txt") })
}
abstract class GenerateNativeRuntimeIndexTask : DefaultTask() {
@get:InputDirectory
abstract val runtimeDir: DirectoryProperty
@get:OutputFile
abstract val indexFile: RegularFileProperty
@TaskAction
fun generate() {
val dir = runtimeDir.get().asFile
val files = dir
.listFiles { file -> file.isFile && file.name != indexFile.get().asFile.name }
.orEmpty()
.map { it.name }
.sorted()
indexFile.get().asFile.writeText(files.joinToString(separator = "\n", postfix = "\n"))
}
}
tasks.withType<Jar>().configureEach {
if (isMacHost && name == "desktopJar") {
dependsOn(buildMacosPlayerBridge)
@ -319,6 +536,43 @@ tasks.withType<Jar>().configureEach {
into("native/macos")
}
}
if (isWindowsHost && name == "desktopJar") {
dependsOn(buildWindowsPlayerBridge, prepareWindowsPlayerRuntime, generateWindowsPlayerRuntimeIndex)
from(windowsPlayerBridgeOutput) {
into("native/windows")
}
from(windowsPlayerRuntimeOutput) {
into("native/windows")
}
}
}
if (isWindowsHost) {
val desktopNativePlayerTasks = setOf(
"run",
"runRelease",
"desktopRun",
"runDistributable",
"runReleaseDistributable",
"desktopRunHot",
"hotRunDesktop",
"hotRunDesktopAsync",
"hotDevDesktop",
"hotDevDesktopAsync",
"createDistributable",
"createReleaseDistributable",
"createRuntimeImage",
"package",
"packageDistributionForCurrentOS",
"packageMsi",
"packageUberJarForCurrentOS",
"packageReleaseDistributionForCurrentOS",
"packageReleaseMsi",
"packageReleaseUberJarForCurrentOS",
)
tasks.matching { it.name in desktopNativePlayerTasks }.configureEach {
dependsOn(buildWindowsPlayerBridge, prepareWindowsPlayerRuntime, generateWindowsPlayerRuntimeIndex)
}
}
tasks.withType<KotlinCompilationTask<*>>().configureEach {
@ -446,6 +700,7 @@ compose.desktop {
"--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",
"--add-opens=java.desktop/sun.awt.windows=ALL-UNNAMED",
smokePlayerUrl?.takeIf { it.isNotBlank() }?.let { "-Dnuvio.desktop.smokePlayerUrl=$it" },
)

View file

@ -40,7 +40,7 @@ actual fun PlatformPlayerSurface(
onSnapshot: (PlayerPlaybackSnapshot) -> Unit,
onError: (String?) -> Unit,
) {
if (DesktopHostOs.current == DesktopHostOs.MACOS) {
if (DesktopHostOs.current == DesktopHostOs.MACOS || DesktopHostOs.current == DesktopHostOs.WINDOWS) {
NativePlayerSurface(
sourceUrl = sourceUrl,
sourceHeaders = sourceHeaders,

View file

@ -8,6 +8,7 @@ internal object AwtNativeViewResolver {
fun resolveNativeViewPointer(component: Component): Long =
when (DesktopHostOs.current) {
DesktopHostOs.MACOS -> MacosAwtViewResolver.resolveNativeViewPointer(component)
DesktopHostOs.WINDOWS -> WindowsAwtViewResolver.resolveNativeViewPointer(component)
else -> error("Native desktop playback is not implemented for ${DesktopHostOs.current}.")
}
}
@ -48,3 +49,34 @@ private object MacosAwtViewResolver {
private fun invokeLong(target: Any, methodName: String): Long =
(findMethod(target.javaClass, methodName).invoke(target) as Number).toLong()
}
private object WindowsAwtViewResolver {
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 pointer = invokeLong(peer, "getHWnd")
if (pointer == 0L) {
error("Windows AWT HWND 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 invokeLong(target: Any, methodName: String): Long =
(findMethod(target.javaClass, methodName).invoke(target) as Number).toLong()
}

View file

@ -80,13 +80,14 @@ internal object NativePlayerBridge {
private fun loadNativeLibrary() {
val platform = DesktopHostOs.current
require(platform == DesktopHostOs.MACOS) {
require(platform == DesktopHostOs.MACOS || platform == DesktopHostOs.WINDOWS) {
"Native desktop playback is not implemented for $platform yet."
}
val libraryName = nativeLibraryName(platform)
val platformDir = nativeDirectoryName(platform)
findLocalBuildLibrary(platformDir, libraryName)?.let { localLibrary ->
copyLocalRuntimeResources(platformDir, localLibrary.parentFile)
System.load(localLibrary.absolutePath)
return
}
@ -98,12 +99,44 @@ internal object NativePlayerBridge {
val suffix = libraryName.substringAfter("player_bridge", ".dylib")
val file = Files.createTempFile(dir.toPath(), "player-bridge-", suffix).toFile()
file.deleteOnExit()
extractBundledRuntimeResources(platformDir, dir)
input.use { source ->
file.outputStream().use { target -> source.copyTo(target) }
}
System.load(file.absolutePath)
}
private fun extractBundledRuntimeResources(platformDir: String, dir: File) {
val runtimeNames = bundledRuntimeResourceNames(platformDir)
runtimeNames.forEach { name ->
val resource = "/native/$platformDir/$name"
val input = NativePlayerBridge::class.java.getResourceAsStream(resource) ?: return@forEach
val target = dir.resolve(name)
input.use { source ->
target.outputStream().use { output -> source.copyTo(output) }
}
target.deleteOnExit()
}
}
private fun bundledRuntimeResourceNames(platformDir: String): List<String> {
val indexResource = "/native/$platformDir/runtime-files.txt"
val indexed = NativePlayerBridge::class.java.getResourceAsStream(indexResource)
?.bufferedReader()
?.useLines { lines ->
lines.map(String::trim)
.filter { it.isNotEmpty() && !it.startsWith("#") }
.toList()
}
.orEmpty()
if (indexed.isNotEmpty()) return indexed
return when (platformDir) {
"windows" -> listOf("libmpv-2.dll")
else -> emptyList()
}
}
private fun findLocalBuildLibrary(platformDir: String, libraryName: String): File? {
val candidates = listOf(
File("composeApp/build/native/$platformDir/$libraryName"),
@ -112,6 +145,21 @@ internal object NativePlayerBridge {
return candidates.firstOrNull { it.exists() }
}
private fun copyLocalRuntimeResources(platformDir: String, targetDir: File) {
val runtimeDirs = listOf(
File("composeApp/build/native/$platformDir-runtime"),
File("build/native/$platformDir-runtime"),
)
runtimeDirs.firstOrNull(File::isDirectory)
?.listFiles { file -> file.isFile }
?.forEach { runtimeFile ->
val target = targetDir.resolve(runtimeFile.name)
if (runtimeFile.absolutePath != target.absolutePath) {
runCatching { runtimeFile.copyTo(target, overwrite = true) }
}
}
}
private fun nativeDirectoryName(platform: DesktopHostOs): String =
when (platform) {
DesktopHostOs.MACOS -> "macos"
@ -163,7 +211,7 @@ internal object NativePlayerBridge {
}
internal fun preloadNativePlayerBridgeAsync() {
if (DesktopHostOs.current == DesktopHostOs.MACOS) {
if (DesktopHostOs.current == DesktopHostOs.MACOS || DesktopHostOs.current == DesktopHostOs.WINDOWS) {
NativePlayerBridge.preloadAsync()
}
}

File diff suppressed because it is too large Load diff

View file

@ -2446,7 +2446,12 @@
const send = (type, value = 0) => {
const bridge = window.webkit && window.webkit.messageHandlers && window.webkit.messageHandlers.player;
if (bridge) bridge.postMessage({ type, value });
if (bridge) {
bridge.postMessage({ type, value });
return;
}
const webViewBridge = window.chrome && window.chrome.webview;
if (webViewBridge) webViewBridge.postMessage({ type, value });
};
const animationDelay = ms => new Promise(resolve => {