perf: warm desktop player controls loading

This commit is contained in:
tapframe 2026-06-09 19:55:57 +05:30
parent 5b8a4aff9b
commit bc75392872
4 changed files with 344 additions and 62 deletions

View file

@ -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<String>,
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() {

View file

@ -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.")

View file

@ -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<NSString *> *)headerLines
playWhenReady:(BOOL)playWhenReady
initialPositionMs:(long long)initialPositionMs
controlsHtml:(NSString *)controlsHtml
headerLines:(NSArray<NSString *> *)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<NSString *> *)headerLines
playWhenReady:(BOOL)playWhenReady
initialPositionMs:(long long)initialPositionMs
controlsHtml:(NSString *)controlsHtml
headerLines:(NSArray<NSString *> *)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<NSString *> *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];

View file

@ -9,6 +9,7 @@
#include <algorithm>
#include <atomic>
#include <chrono>
#include <cmath>
#include <condition_variable>
#include <cctype>
@ -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<std::mutex> lock(gWebView2WarmupMutex);
if (!gWebView2WarmupReady) {
gWebView2WarmupReady = true;
gWebView2WarmupSucceeded = succeeded;
}
}
gWebView2WarmupCv.notify_all();
}
void runWebView2WarmupThread(std::string controlsUrl) {
{
std::lock_guard<std::mutex> lock(gWebView2WarmupMutex);
gWebView2WarmupThreadId = GetCurrentThreadId();
}
gWebView2WarmupCv.notify_all();
MSG queueProbe = {};
PeekMessageW(&queueProbe, nullptr, WM_USER, WM_USER, PM_NOREMOVE);
bool didOleInitialize = false;
ComPtr<ICoreWebView2Environment> environment;
ComPtr<ICoreWebView2Controller> controller;
ComPtr<ICoreWebView2> 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<ICoreWebView2CreateCoreWebView2EnvironmentCompletedHandler>(
[&](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<ICoreWebView2CreateCoreWebView2ControllerCompletedHandler>(
[&](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<ICoreWebView2WebMessageReceivedEventHandler>(
[&](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<ICoreWebView2NavigationCompletedEventHandler>(
[&](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<std::mutex> lock(gWebView2WarmupMutex);
if (!gWebView2WarmupStarted) {
gWebView2WarmupStarted = true;
gWebView2WarmupReady = false;
gWebView2WarmupSucceeded = false;
gWebView2WarmupThread = std::thread(runWebView2WarmupThread, controlsUrl);
}
}
std::unique_lock<std::mutex> 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<std::mutex> 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<std::mutex> lock(gWebView2WarmupMutex);
if (gWebView2WarmupThread.joinable()) {
threadToJoin = std::move(gWebView2WarmupThread);
}
}
if (threadToJoin.joinable()) {
threadToJoin.join();
}
{
std::lock_guard<std::mutex> lock(gWebView2WarmupMutex);
gWebView2WarmupStarted = false;
gWebView2WarmupReady = false;
gWebView2WarmupSucceeded = false;
gWebView2WarmupThreadId = 0;
}
}
class WindowsMpvWebPlayer : public std::enable_shared_from_this<WindowsMpvWebPlayer> {
struct InitializationState {
std::mutex mutex;
@ -384,7 +596,7 @@ public:
const std::vector<std::string> &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<InitializationState>();
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<std::string> headerLines,
bool playWhenReady,
long long initialPositionMs,
std::string controlsHtml,
std::string controlsUrl,
std::shared_ptr<InitializationState> 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<std::string> &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<ICoreWebView2CreateCoreWebView2EnvironmentCompletedHandler>(
[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<ICoreWebView2CreateCoreWebView2ControllerCompletedHandler>(
[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<std::string> 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;