From bc75392872c474256aac6d4c80acda75a342f2b7 Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Tue, 9 Jun 2026 19:55:57 +0530 Subject: [PATCH] perf: warm desktop player controls loading --- .../player/desktop/NativePlayerBridge.kt | 117 +++++--- .../player/desktop/NativePlayerController.kt | 2 +- .../desktopMain/native/macos/player_bridge.mm | 34 ++- .../native/windows/player_bridge.cpp | 253 ++++++++++++++++-- 4 files changed, 344 insertions(+), 62 deletions(-) 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 index 2665f7dba..10517c167 100644 --- 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 @@ -2,7 +2,6 @@ package com.nuvio.app.features.player.desktop import java.io.File import java.nio.file.Files -import java.util.Base64 import java.util.concurrent.atomic.AtomicBoolean internal fun interface NativePlayerEventSink { @@ -22,7 +21,7 @@ internal object NativePlayerBridge { headerLines: Array, playWhenReady: Boolean, initialPositionMs: Long, - controlsHtml: String, + controlsPageUrl: String, eventSink: NativePlayerEventSink, ): Long @@ -66,24 +65,35 @@ internal object NativePlayerBridge { fontSize: Float, subPos: Int, ) + external fun warmupWebView2(controlsPageUrl: String): Boolean + external fun shutdownWebView2Warmup() - val controlsHtml: String by lazy { - val resource = "/player-ui/controls.html" - val input = NativePlayerBridge::class.java.getResourceAsStream(resource) - ?: error("Missing native player controls resource: $resource") - input.bufferedReader().use { it.readText() } - .replace("/* __NUVIO_PLAYER_FONT_FACES__ */", nativePlayerFontFaces()) - } + val controlsPageUrl: String by lazy { controlsPageAssets.url } + private val controlsPageAssets: ControlsPageAssets by lazy { exportControlsPageAssets() } fun preloadAsync() { if (!preloadStarted.compareAndSet(false, true)) return Thread { - runCatching { controlsHtml } + val controlsPage = runCatching { controlsPageAssets } + .getOrNull() + ?: return@Thread + if (DesktopHostOs.current == DesktopHostOs.WINDOWS) { + runCatching { warmupWebView2(controlsPage.url) } + } }.apply { name = "nuvio-native-player-preload" isDaemon = true start() } + if (DesktopHostOs.current == DesktopHostOs.WINDOWS) { + Runtime.getRuntime().addShutdownHook( + Thread { + runCatching { shutdownWebView2Warmup() } + }.apply { + name = "nuvio-webview2-warmup-shutdown" + } + ) + } } private fun loadNativeLibrary() { @@ -184,38 +194,77 @@ internal object NativePlayerBridge { DesktopHostOs.UNKNOWN -> "player_bridge" } - private fun nativePlayerFontFaces(): String = - listOfNotNull( - nativePlayerFontFace( - fileName = "jetbrains_sans_regular.ttf", - weight = "400", - ), - nativePlayerFontFace( - fileName = "jetbrains_sans_semibold.ttf", - weight = "600", - ), - nativePlayerFontFace( - fileName = "jetbrains_sans_bold.ttf", - weight = "700 900", - ), - ).joinToString(separator = "\n") + private fun exportControlsPageAssets(): ControlsPageAssets { + val root = File(System.getProperty("java.io.tmpdir"), "nuvio-player-ui").apply { mkdirs() } + val fontsDir = root.resolve("fonts").apply { mkdirs() } + val htmlResource = "/player-ui/controls.html" + val html = NativePlayerBridge::class.java.getResourceAsStream(htmlResource) + ?.bufferedReader() + ?.use { it.readText() } + ?: error("Missing native player controls resource: $htmlResource") + val resolvedHtml = html.replace("/* __NUVIO_PLAYER_FONT_FACES__ */", nativePlayerFontFaces()) + val htmlFile = root.resolve("controls.html") + writeTextIfChanged(htmlFile, resolvedHtml) + copyResourceIfChanged( + resource = "/composeResources/nuvio.composeapp.generated.resources/font/jetbrains_sans_regular.ttf", + target = fontsDir.resolve("jetbrains_sans_regular.ttf"), + ) + copyResourceIfChanged( + resource = "/composeResources/nuvio.composeapp.generated.resources/font/jetbrains_sans_semibold.ttf", + target = fontsDir.resolve("jetbrains_sans_semibold.ttf"), + ) + copyResourceIfChanged( + resource = "/composeResources/nuvio.composeapp.generated.resources/font/jetbrains_sans_bold.ttf", + target = fontsDir.resolve("jetbrains_sans_bold.ttf"), + ) + return ControlsPageAssets( + url = htmlFile.toURI().toASCIIString(), + ) + } - private fun nativePlayerFontFace(fileName: String, weight: String): String? { - val resource = "/composeResources/nuvio.composeapp.generated.resources/font/$fileName" - val bytes = NativePlayerBridge::class.java.getResourceAsStream(resource) - ?.use { it.readBytes() } - ?: return null - val encoded = Base64.getEncoder().encodeToString(bytes) - return """ + private fun nativePlayerFontFaces(): String = + """ @font-face { font-family: "Nuvio JetBrains Sans"; - src: url("data:font/ttf;base64,$encoded") format("truetype"); - font-weight: $weight; + src: url("fonts/jetbrains_sans_regular.ttf") format("truetype"); + font-weight: 400; + font-style: normal; + font-display: block; + } + @font-face { + font-family: "Nuvio JetBrains Sans"; + src: url("fonts/jetbrains_sans_semibold.ttf") format("truetype"); + font-weight: 600; + font-style: normal; + font-display: block; + } + @font-face { + font-family: "Nuvio JetBrains Sans"; + src: url("fonts/jetbrains_sans_bold.ttf") format("truetype"); + font-weight: 700 900; font-style: normal; font-display: block; } """.trimIndent() + + private fun writeTextIfChanged(target: File, text: String) { + val bytes = text.toByteArray(Charsets.UTF_8) + if (target.exists() && target.readBytes().contentEquals(bytes)) return + target.writeBytes(bytes) } + + private fun copyResourceIfChanged(resource: String, target: File) { + val bytes = NativePlayerBridge::class.java.getResourceAsStream(resource) + ?.use { it.readBytes() } + ?: error("Missing native player controls resource: $resource") + if (target.exists() && target.readBytes().contentEquals(bytes)) return + Files.createDirectories(target.parentFile.toPath()) + target.writeBytes(bytes) + } + + private data class ControlsPageAssets( + val url: String, + ) } internal fun preloadNativePlayerBridgeAsync() { diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/desktop/NativePlayerController.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/desktop/NativePlayerController.kt index 885f8a59a..6665482c2 100644 --- a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/desktop/NativePlayerController.kt +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/desktop/NativePlayerController.kt @@ -85,7 +85,7 @@ internal class NativePlayerController( headerLines = pending.headerLines.toTypedArray(), playWhenReady = pending.playWhenReady, initialPositionMs = pending.initialPositionMs, - controlsHtml = NativePlayerBridge.controlsHtml, + controlsPageUrl = NativePlayerBridge.controlsPageUrl, eventSink = eventSink, ) if (handle == 0L) error("Native player did not return a handle.") diff --git a/composeApp/src/desktopMain/native/macos/player_bridge.mm b/composeApp/src/desktopMain/native/macos/player_bridge.mm index 9cacc3db0..7a70e367d 100644 --- a/composeApp/src/desktopMain/native/macos/player_bridge.mm +++ b/composeApp/src/desktopMain/native/macos/player_bridge.mm @@ -86,10 +86,10 @@ void mpv_set_wakeup_callback(mpv_handle *ctx, void (*cb)(void *d), void *d); @interface MpvWebPlayer : NSObject - (instancetype)initWithHostView:(NSView *)hostView sourceUrl:(NSString *)sourceUrl - headerLines:(NSArray *)headerLines - playWhenReady:(BOOL)playWhenReady - initialPositionMs:(long long)initialPositionMs - controlsHtml:(NSString *)controlsHtml + headerLines:(NSArray *)headerLines + playWhenReady:(BOOL)playWhenReady + initialPositionMs:(long long)initialPositionMs + controlsUrl:(NSString *)controlsUrl javaVm:(JavaVM *)javaVm eventSink:(jobject)eventSink eventMethod:(jmethodID)eventMethod; @@ -750,10 +750,10 @@ static NSString *redactUrlsInText(NSString *text) { - (instancetype)initWithHostView:(NSView *)hostView sourceUrl:(NSString *)sourceUrl - headerLines:(NSArray *)headerLines - playWhenReady:(BOOL)playWhenReady - initialPositionMs:(long long)initialPositionMs - controlsHtml:(NSString *)controlsHtml + headerLines:(NSArray *)headerLines + playWhenReady:(BOOL)playWhenReady + initialPositionMs:(long long)initialPositionMs + controlsUrl:(NSString *)controlsUrl javaVm:(JavaVM *)javaVm eventSink:(jobject)eventSink eventMethod:(jmethodID)eventMethod { @@ -790,7 +790,17 @@ static NSString *redactUrlsInText(NSString *text) { _webView.wantsLayer = YES; [_webView setValue:@NO forKey:@"drawsBackground"]; [_hostView addSubview:_webView positioned:NSWindowAbove relativeTo:_videoView]; - [_webView loadHTMLString:controlsHtml baseURL:nil]; + NSURL *controlsURL = [NSURL URLWithString:controlsUrl ?: @""]; + if (!controlsURL) { + @throw [NSException exceptionWithName:@"PlayerBridgeError" + reason:@"Invalid native player controls URL." + userInfo:nil]; + } + if (controlsURL.isFileURL) { + [_webView loadFileURL:controlsURL allowingReadAccessToURL:[controlsURL URLByDeletingLastPathComponent]]; + } else { + [_webView loadRequest:[NSURLRequest requestWithURL:controlsURL]]; + } [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(hostViewFrameDidChange:) name:NSViewFrameDidChangeNotification @@ -1994,7 +2004,7 @@ Java_com_nuvio_app_features_player_desktop_NativePlayerBridge_create( jobjectArray headerLines, jboolean playWhenReady, jlong initialPositionMs, - jstring controlsHtml, + jstring controlsPageUrl, jobject eventSink ) { NSView *hostView = (__bridge NSView *)(void *)(intptr_t)hostViewPtr; @@ -2022,7 +2032,7 @@ Java_com_nuvio_app_features_player_desktop_NativePlayerBridge_create( } std::string source = jstringToString(env, sourceUrl); - std::string controls = jstringToString(env, controlsHtml); + std::string controls = jstringToString(env, controlsPageUrl); NSArray *headers = jstringArrayToNSArray(env, headerLines); __block MpvWebPlayer *player = nil; __block NSString *error = nil; @@ -2034,7 +2044,7 @@ Java_com_nuvio_app_features_player_desktop_NativePlayerBridge_create( headerLines:headers playWhenReady:playWhenReady == JNI_TRUE initialPositionMs:initialPositionMs - controlsHtml:[NSString stringWithUTF8String:controls.c_str()] + controlsUrl:[NSString stringWithUTF8String:controls.c_str()] javaVm:javaVm eventSink:eventSinkRef eventMethod:eventMethod]; diff --git a/composeApp/src/desktopMain/native/windows/player_bridge.cpp b/composeApp/src/desktopMain/native/windows/player_bridge.cpp index 187585e96..249009a5f 100644 --- a/composeApp/src/desktopMain/native/windows/player_bridge.cpp +++ b/composeApp/src/desktopMain/native/windows/player_bridge.cpp @@ -9,6 +9,7 @@ #include #include +#include #include #include #include @@ -369,6 +370,217 @@ void registerWindowClasses() { }); } +std::mutex gWebView2WarmupMutex; +std::condition_variable gWebView2WarmupCv; +std::thread gWebView2WarmupThread; +DWORD gWebView2WarmupThreadId = 0; +bool gWebView2WarmupStarted = false; +bool gWebView2WarmupReady = false; +bool gWebView2WarmupSucceeded = false; + +void notifyWebView2WarmupReady(bool succeeded) { + { + std::lock_guard lock(gWebView2WarmupMutex); + if (!gWebView2WarmupReady) { + gWebView2WarmupReady = true; + gWebView2WarmupSucceeded = succeeded; + } + } + gWebView2WarmupCv.notify_all(); +} + +void runWebView2WarmupThread(std::string controlsUrl) { + { + std::lock_guard lock(gWebView2WarmupMutex); + gWebView2WarmupThreadId = GetCurrentThreadId(); + } + gWebView2WarmupCv.notify_all(); + + MSG queueProbe = {}; + PeekMessageW(&queueProbe, nullptr, WM_USER, WM_USER, PM_NOREMOVE); + + bool didOleInitialize = false; + ComPtr environment; + ComPtr controller; + ComPtr webView; + EventRegistrationToken messageToken = {}; + EventRegistrationToken navigationToken = {}; + + HRESULT oleResult = OleInitialize(nullptr); + didOleInitialize = SUCCEEDED(oleResult); + if (FAILED(oleResult)) { + notifyWebView2WarmupReady(false); + return; + } + + std::wstring userDataDir = tempUserDataDirectory(); + HRESULT envCallResult = CreateCoreWebView2EnvironmentWithOptions( + nullptr, + userDataDir.c_str(), + nullptr, + Callback( + [&](HRESULT envResult, ICoreWebView2Environment *createdEnvironment) -> HRESULT { + if (FAILED(envResult) || !createdEnvironment) { + notifyWebView2WarmupReady(false); + PostQuitMessage(0); + return S_OK; + } + + environment = createdEnvironment; + HRESULT controllerCallResult = createdEnvironment->CreateCoreWebView2Controller( + HWND_MESSAGE, + Callback( + [&](HRESULT controllerResult, ICoreWebView2Controller *createdController) -> HRESULT { + if (FAILED(controllerResult) || !createdController) { + notifyWebView2WarmupReady(false); + PostQuitMessage(0); + return S_OK; + } + + controller = createdController; + controller->put_IsVisible(FALSE); + createdController->get_CoreWebView2(&webView); + if (!webView) { + notifyWebView2WarmupReady(false); + PostQuitMessage(0); + return S_OK; + } + + webView->add_WebMessageReceived( + Callback( + [&](ICoreWebView2 *, ICoreWebView2WebMessageReceivedEventArgs *args) -> HRESULT { + if (!args) return S_OK; + PWSTR messageJson = nullptr; + if (SUCCEEDED(args->get_WebMessageAsJson(&messageJson)) && messageJson) { + std::wstring message(messageJson); + CoTaskMemFree(messageJson); + if (message.find(L"controlsReady") != std::wstring::npos) { + notifyWebView2WarmupReady(true); + } + } + return S_OK; + } + ).Get(), + &messageToken + ); + + webView->add_NavigationCompleted( + Callback( + [&](ICoreWebView2 *, ICoreWebView2NavigationCompletedEventArgs *args) -> HRESULT { + BOOL navigationSucceeded = FALSE; + if (args) { + args->get_IsSuccess(&navigationSucceeded); + } + notifyWebView2WarmupReady(navigationSucceeded == TRUE); + return S_OK; + } + ).Get(), + &navigationToken + ); + + std::wstring url = toWide(controlsUrl); + HRESULT navigateResult = webView->Navigate(url.c_str()); + if (FAILED(navigateResult)) { + notifyWebView2WarmupReady(false); + PostQuitMessage(0); + } + return S_OK; + } + ).Get() + ); + if (FAILED(controllerCallResult)) { + notifyWebView2WarmupReady(false); + PostQuitMessage(0); + } + return S_OK; + } + ).Get() + ); + if (FAILED(envCallResult)) { + notifyWebView2WarmupReady(false); + } else { + MSG msg = {}; + while (GetMessageW(&msg, nullptr, 0, 0) > 0) { + TranslateMessage(&msg); + DispatchMessageW(&msg); + } + } + + if (webView && messageToken.value != 0) { + webView->remove_WebMessageReceived(messageToken); + } + if (webView && navigationToken.value != 0) { + webView->remove_NavigationCompleted(navigationToken); + } + if (controller) { + controller->Close(); + controller.Reset(); + } + webView.Reset(); + environment.Reset(); + if (didOleInitialize) { + OleUninitialize(); + } +} + +bool startWebView2Warmup(const std::string &controlsUrl) { + { + std::lock_guard lock(gWebView2WarmupMutex); + if (!gWebView2WarmupStarted) { + gWebView2WarmupStarted = true; + gWebView2WarmupReady = false; + gWebView2WarmupSucceeded = false; + gWebView2WarmupThread = std::thread(runWebView2WarmupThread, controlsUrl); + } + } + + std::unique_lock waitLock(gWebView2WarmupMutex); + bool completed = gWebView2WarmupCv.wait_for( + waitLock, + std::chrono::seconds(5), + []() { return gWebView2WarmupReady; } + ); + if (!completed) return false; + return gWebView2WarmupSucceeded; +} + +void stopWebView2Warmup() { + std::thread threadToJoin; + DWORD threadId = 0; + { + std::unique_lock lock(gWebView2WarmupMutex); + if (!gWebView2WarmupStarted) return; + gWebView2WarmupCv.wait_for( + lock, + std::chrono::seconds(1), + []() { return gWebView2WarmupThreadId != 0; } + ); + threadId = gWebView2WarmupThreadId; + } + + if (threadId != 0) { + PostThreadMessageW(threadId, WM_QUIT, 0, 0); + } + + { + std::lock_guard lock(gWebView2WarmupMutex); + if (gWebView2WarmupThread.joinable()) { + threadToJoin = std::move(gWebView2WarmupThread); + } + } + if (threadToJoin.joinable()) { + threadToJoin.join(); + } + + { + std::lock_guard lock(gWebView2WarmupMutex); + gWebView2WarmupStarted = false; + gWebView2WarmupReady = false; + gWebView2WarmupSucceeded = false; + gWebView2WarmupThreadId = 0; + } +} + class WindowsMpvWebPlayer : public std::enable_shared_from_this { struct InitializationState { std::mutex mutex; @@ -384,7 +596,7 @@ public: const std::vector &headerLines, bool playWhenReady, long long initialPositionMs, - const std::string &controlsHtml, + const std::string &controlsUrl, JavaVM *vm, jobject sink, jmethodID method @@ -401,8 +613,8 @@ public: auto initState = std::make_shared(); auto self = shared_from_this(); uiThread = std::thread( - [self, sourceUrl, headerLines, playWhenReady, initialPositionMs, controlsHtml, initState]() { - self->runNativeUiThread(sourceUrl, headerLines, playWhenReady, initialPositionMs, controlsHtml, initState); + [self, sourceUrl, headerLines, playWhenReady, initialPositionMs, controlsUrl, initState]() { + self->runNativeUiThread(sourceUrl, headerLines, playWhenReady, initialPositionMs, controlsUrl, initState); } ); @@ -693,12 +905,12 @@ private: std::vector headerLines, bool playWhenReady, long long initialPositionMs, - std::string controlsHtml, + std::string controlsUrl, std::shared_ptr initState ) { std::string failure; try { - initializeOnNativeUiThread(sourceUrl, headerLines, playWhenReady, initialPositionMs, controlsHtml); + initializeOnNativeUiThread(sourceUrl, headerLines, playWhenReady, initialPositionMs, controlsUrl); } catch (const std::exception &error) { failure = error.what(); cleanupUiResources(); @@ -727,7 +939,7 @@ private: const std::vector &headerLines, bool playWhenReady, long long initialPositionMs, - const std::string &controlsHtml + const std::string &controlsUrl ) { registerWindowClasses(); uiThreadId = GetCurrentThreadId(); @@ -777,7 +989,7 @@ private: throw std::runtime_error("Unable to create native player container window."); } - startWebView(controlsHtml); + startWebView(controlsUrl); startMpv(sourceUrl, headerLines, playWhenReady, initialPositionMs); layoutNativeSubviews(); if (!SetTimer(messageHwnd, NUVIO_TIMER_ID, 500, nullptr)) { @@ -852,7 +1064,7 @@ private: doneCv->wait(waitLock, [&]() { return *done; }); } - void startWebView(const std::string &controlsHtml) { + void startWebView(const std::string &controlsUrl) { std::wstring userDataDir = tempUserDataDirectory(); auto weakSelf = weak_from_this(); HRESULT result = CreateCoreWebView2EnvironmentWithOptions( @@ -860,7 +1072,7 @@ private: userDataDir.c_str(), nullptr, Callback( - [weakSelf, controlsHtml](HRESULT envResult, ICoreWebView2Environment *createdEnvironment) -> HRESULT { + [weakSelf, controlsUrl](HRESULT envResult, ICoreWebView2Environment *createdEnvironment) -> HRESULT { auto self = weakSelf.lock(); if (!self || self->shuttingDown.load()) return S_OK; if (FAILED(envResult) || !createdEnvironment) { @@ -871,7 +1083,7 @@ private: HRESULT controllerResult = createdEnvironment->CreateCoreWebView2Controller( self->containerHwnd, Callback( - [controllerWeakSelf, controlsHtml](HRESULT controllerResult, ICoreWebView2Controller *createdController) -> HRESULT { + [controllerWeakSelf, controlsUrl](HRESULT controllerResult, ICoreWebView2Controller *createdController) -> HRESULT { auto controllerSelf = controllerWeakSelf.lock(); if (!controllerSelf || controllerSelf->shuttingDown.load()) return S_OK; if (FAILED(controllerResult) || !createdController) { @@ -910,8 +1122,8 @@ private: &controllerSelf->messageToken ); controllerSelf->layoutNativeSubviews(); - std::wstring html = toWide(controlsHtml); - controllerSelf->webView->NavigateToString(html.c_str()); + std::wstring url = toWide(controlsUrl); + controllerSelf->webView->Navigate(url.c_str()); createdController->MoveFocus(COREWEBVIEW2_MOVE_FOCUS_REASON_PROGRAMMATIC); } return S_OK; @@ -1512,13 +1724,13 @@ Java_com_nuvio_app_features_player_desktop_NativePlayerBridge_create( jobjectArray headerLines, jboolean playWhenReady, jlong initialPositionMs, - jstring controlsHtml, + jstring controlsPageUrl, jobject eventSink ) { HWND hostHwnd = (HWND)(intptr_t)hostViewPtr; std::string sourceUrlText = jstringToUtf8(env, sourceUrl); std::vector headerLineValues = jstringArrayToVector(env, headerLines); - std::string controlsHtmlText = jstringToUtf8(env, controlsHtml); + std::string controlsPageUrlText = jstringToUtf8(env, controlsPageUrl); JavaVM *javaVm = nullptr; env->GetJavaVM(&javaVm); @@ -1544,7 +1756,7 @@ Java_com_nuvio_app_features_player_desktop_NativePlayerBridge_create( headerLineValues, playWhenReady == JNI_TRUE, initialPositionMs, - controlsHtmlText, + controlsPageUrlText, javaVm, eventSinkRef, eventMethod @@ -1560,6 +1772,17 @@ Java_com_nuvio_app_features_player_desktop_NativePlayerBridge_create( return handle; } +extern "C" JNIEXPORT jboolean JNICALL +Java_com_nuvio_app_features_player_desktop_NativePlayerBridge_warmupWebView2(JNIEnv *env, jobject, jstring controlsPageUrl) { + std::string controlsPageUrlText = jstringToUtf8(env, controlsPageUrl); + return startWebView2Warmup(controlsPageUrlText) ? JNI_TRUE : JNI_FALSE; +} + +extern "C" JNIEXPORT void JNICALL +Java_com_nuvio_app_features_player_desktop_NativePlayerBridge_shutdownWebView2Warmup(JNIEnv *, jobject) { + stopWebView2Warmup(); +} + extern "C" JNIEXPORT void JNICALL Java_com_nuvio_app_features_player_desktop_NativePlayerBridge_dispose(JNIEnv *, jobject, jlong handle) { if (handle == 0) return;