From 9721e6bf3fbfc00bb12031f9f248d60f9d14d806 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Tue, 20 Jan 2026 01:14:45 +0100 Subject: [PATCH] fix: async MPV commands to prevent ANR --- .../com/edde746/plezy/mpv/MpvPlayerCore.kt | 34 ++++++++ .../com/edde746/plezy/mpv/MpvPlayerPlugin.kt | 6 +- ios/Runner/MpvPlayer/MpvPlayerCore.swift | 79 +++++++++++++++++++ ios/Runner/MpvPlayer/MpvPlayerPlugin.swift | 11 ++- linux/runner/mpv/mpv_player.cc | 71 +++++++++++++++++ linux/runner/mpv/mpv_player.h | 13 +++ linux/runner/mpv/mpv_plugin.cc | 17 +++- macos/Runner/MpvPlayer/MpvPlayerCore.swift | 79 +++++++++++++++++++ macos/Runner/MpvPlayer/MpvPlayerPlugin.swift | 11 ++- windows/runner/mpv/mpv_player.cpp | 62 +++++++++++++++ windows/runner/mpv/mpv_player.h | 11 +++ windows/runner/mpv/mpv_plugin.cpp | 13 ++- 12 files changed, 397 insertions(+), 10 deletions(-) diff --git a/android/app/src/main/kotlin/com/edde746/plezy/mpv/MpvPlayerCore.kt b/android/app/src/main/kotlin/com/edde746/plezy/mpv/MpvPlayerCore.kt index ffb65482..02cc1746 100644 --- a/android/app/src/main/kotlin/com/edde746/plezy/mpv/MpvPlayerCore.kt +++ b/android/app/src/main/kotlin/com/edde746/plezy/mpv/MpvPlayerCore.kt @@ -22,8 +22,10 @@ import android.view.TextureView import android.view.WindowManager import androidx.annotation.RequiresApi import dev.jdtech.mpv.MPVLib +import io.flutter.plugin.common.MethodChannel import java.math.BigDecimal import java.math.RoundingMode +import java.util.concurrent.Executors interface MpvPlayerDelegate { fun onPropertyChange(name: String, value: Any?) @@ -48,6 +50,9 @@ class MpvPlayerCore(private val activity: Activity) : var isInitialized: Boolean = false private set + // Executor for running MPV commands off the UI thread to prevent ANR + private val commandExecutor = Executors.newSingleThreadExecutor() + // Frame rate matching private var currentVideoFps: Float = 0f private var displayListener: DisplayManager.DisplayListener? = null @@ -451,6 +456,32 @@ class MpvPlayerCore(private val activity: Activity) : MPVLib.command(args) } + /** + * Execute an MPV command asynchronously off the UI thread. + * This prevents ANR when commands like loadfile block waiting for network I/O. + * The result is called back on the UI thread when the command completes. + */ + fun commandAsync(args: Array, result: MethodChannel.Result) { + if (!isInitialized || args.isEmpty()) { + result.success(null) + return + } + + commandExecutor.execute { + try { + MPVLib.command(args) + activity.runOnUiThread { + result.success(null) + } + } catch (e: Exception) { + Log.e(TAG, "Async command failed: ${e.message}", e) + activity.runOnUiThread { + result.error("COMMAND_FAILED", e.message, null) + } + } + } + } + fun setVisible(visible: Boolean) { activity.runOnUiThread { surfaceView?.visibility = if (visible) View.VISIBLE else View.INVISIBLE @@ -652,6 +683,9 @@ class MpvPlayerCore(private val activity: Activity) : fun dispose() { Log.d(TAG, "Disposing") + // Shutdown command executor + commandExecutor.shutdown() + // Clean up frame rate listener clearVideoFrameRate() diff --git a/android/app/src/main/kotlin/com/edde746/plezy/mpv/MpvPlayerPlugin.kt b/android/app/src/main/kotlin/com/edde746/plezy/mpv/MpvPlayerPlugin.kt index 7c75e7bb..eb815b36 100644 --- a/android/app/src/main/kotlin/com/edde746/plezy/mpv/MpvPlayerPlugin.kt +++ b/android/app/src/main/kotlin/com/edde746/plezy/mpv/MpvPlayerPlugin.kt @@ -190,8 +190,10 @@ class MpvPlayerPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, return } - playerCore?.command(args.toTypedArray()) - result.success(null) + // Use async command to prevent ANR - command executes off UI thread + // and result is called back when complete + playerCore?.commandAsync(args.toTypedArray(), result) + ?: result.success(null) } private fun handleSetVisible(call: MethodCall, result: MethodChannel.Result) { diff --git a/ios/Runner/MpvPlayer/MpvPlayerCore.swift b/ios/Runner/MpvPlayer/MpvPlayerCore.swift index 12cbd2a6..f520a2ff 100644 --- a/ios/Runner/MpvPlayer/MpvPlayerCore.swift +++ b/ios/Runner/MpvPlayer/MpvPlayerCore.swift @@ -55,6 +55,11 @@ class MpvPlayerCore: NSObject { private var hdrEnabled = true // User preference for HDR private var lastSigPeak: Double = 0.0 // Last known sig-peak for re-evaluation + // Async command tracking to prevent UI blocking + private var pendingCommands: [UInt64: (Result) -> Void] = [:] + private var pendingCommandsLock = NSLock() + private var nextRequestId: UInt64 = 1 + // MARK: - Initialization func initialize(in window: UIWindow) -> Bool { @@ -251,6 +256,50 @@ class MpvPlayerCore: NSObject { command(args[0], args: Array(args.dropFirst())) } + /// Execute an MPV command asynchronously to prevent UI blocking. + /// Uses mpv_command_async which returns immediately; the completion is called + /// when MPV_EVENT_COMMAND_REPLY is received. + func commandAsync(_ args: [String], completion: @escaping (Result) -> Void) { + guard let mpv = mpv, !args.isEmpty else { + completion(.success(())) + return + } + + // Generate unique request ID + pendingCommandsLock.lock() + let requestId = nextRequestId + nextRequestId += 1 + pendingCommands[requestId] = completion + pendingCommandsLock.unlock() + + // Build array of C strings for mpv_command_async + var cargs: [UnsafeMutablePointer?] = args.map { strdup($0) } + cargs.append(nil) // null-terminate + + // mpv_command_async returns immediately + cargs.withUnsafeBufferPointer { buffer in + var constPtrs = buffer.map { UnsafePointer($0) } + let result = mpv_command_async(mpv, requestId, &constPtrs) + if result < 0 { + // Command submission failed, complete immediately with error + pendingCommandsLock.lock() + if let pending = pendingCommands.removeValue(forKey: requestId) { + pendingCommandsLock.unlock() + let error = NSError(domain: "mpv", code: Int(result), + userInfo: [NSLocalizedDescriptionKey: String(cString: mpv_error_string(result))]) + DispatchQueue.main.async { pending(.failure(error)) } + } else { + pendingCommandsLock.unlock() + } + } + } + + // Free the C strings + for ptr in cargs { + free(ptr) + } + } + // MARK: - Visibility func setVisible(_ visible: Bool) { @@ -333,6 +382,23 @@ class MpvPlayerCore: NSObject { let name = String(cString: property.name) handlePropertyChange(name: name, property: property) + case MPV_EVENT_COMMAND_REPLY: + // Handle async command completion + let requestId = event.reply_userdata + pendingCommandsLock.lock() + let completion = pendingCommands.removeValue(forKey: requestId) + pendingCommandsLock.unlock() + + if let completion = completion { + if event.error < 0 { + let error = NSError(domain: "mpv", code: Int(event.error), + userInfo: [NSLocalizedDescriptionKey: String(cString: mpv_error_string(event.error))]) + DispatchQueue.main.async { completion(.failure(error)) } + } else { + DispatchQueue.main.async { completion(.success(())) } + } + } + case MPV_EVENT_FILE_LOADED: DispatchQueue.main.async { self.delegate?.onEvent(name: "file-loaded", data: nil) @@ -494,6 +560,19 @@ class MpvPlayerCore: NSObject { NotificationCenter.default.removeObserver(self) + // Cancel any pending async commands + pendingCommandsLock.lock() + let pending = pendingCommands + pendingCommands.removeAll() + pendingCommandsLock.unlock() + + // Complete pending commands with cancellation error + let cancelError = NSError(domain: "mpv", code: -1, + userInfo: [NSLocalizedDescriptionKey: "Player disposed"]) + for (_, completion) in pending { + DispatchQueue.main.async { completion(.failure(cancelError)) } + } + let mpvHandle = mpv mpv = nil diff --git a/ios/Runner/MpvPlayer/MpvPlayerPlugin.swift b/ios/Runner/MpvPlayer/MpvPlayerPlugin.swift index 3e81464d..d8bb9945 100644 --- a/ios/Runner/MpvPlayer/MpvPlayerPlugin.swift +++ b/ios/Runner/MpvPlayer/MpvPlayerPlugin.swift @@ -178,8 +178,15 @@ class MpvPlayerPlugin: NSObject, FlutterPlugin, FlutterStreamHandler, MpvPlayerD return } - playerCore?.command(commandArgs) - result(nil) + // Use async command to prevent UI blocking during network operations + playerCore?.commandAsync(commandArgs) { commandResult in + switch commandResult { + case .success: + result(nil) + case .failure(let error): + result(FlutterError(code: "COMMAND_FAILED", message: error.localizedDescription, details: nil)) + } + } ?? result(nil) } private func handleSetVisible(call: FlutterMethodCall, result: @escaping FlutterResult) { diff --git a/linux/runner/mpv/mpv_player.cc b/linux/runner/mpv/mpv_player.cc index 462c2a77..ecf55475 100644 --- a/linux/runner/mpv/mpv_player.cc +++ b/linux/runner/mpv/mpv_player.cc @@ -144,6 +144,15 @@ void MpvPlayer::Dispose() { return; // Already disposed } + // Cancel pending async commands + { + std::lock_guard cmd_lock(pending_commands_mutex_); + for (auto& pair : pending_commands_) { + if (pair.second) pair.second(-1); // Call with error + } + pending_commands_.clear(); + } + // Clear mpv callbacks BEFORE freeing to prevent new callbacks being scheduled if (mpv_gl_) { mpv_render_context_set_update_callback(mpv_gl_, nullptr, nullptr); @@ -187,6 +196,42 @@ void MpvPlayer::Command(const std::vector& args) { mpv_command(mpv_, c_args.data()); } +void MpvPlayer::CommandAsync(const std::vector& args, + CommandCallback callback) { + if (disposed_ || !mpv_) { + if (callback) callback(0); + return; + } + + std::vector c_args; + c_args.reserve(args.size() + 1); + for (const auto& arg : args) { + c_args.push_back(arg.c_str()); + } + c_args.push_back(nullptr); + + // Generate unique request ID and store callback + uint64_t request_id; + { + std::lock_guard lock(pending_commands_mutex_); + request_id = next_reply_userdata_++; + pending_commands_[request_id] = std::move(callback); + } + + // mpv_command_async returns immediately + int result = mpv_command_async(mpv_, request_id, c_args.data()); + if (result < 0) { + // Submission failed, complete immediately with error + std::lock_guard lock(pending_commands_mutex_); + auto it = pending_commands_.find(request_id); + if (it != pending_commands_.end()) { + auto cb = std::move(it->second); + pending_commands_.erase(it); + if (cb) cb(result); + } + } +} + void MpvPlayer::SetProperty(const std::string& name, const std::string& value) { if (disposed_ || !mpv_) return; mpv_set_property_string(mpv_, name.c_str(), value.c_str()); @@ -329,6 +374,32 @@ bool MpvPlayer::ProcessEvents() { void MpvPlayer::HandleMpvEvent(mpv_event* event) { switch (event->event_id) { + case MPV_EVENT_COMMAND_REPLY: { + // Handle async command completion + uint64_t request_id = event->reply_userdata; + CommandCallback callback; + { + std::lock_guard lock(pending_commands_mutex_); + auto it = pending_commands_.find(request_id); + if (it != pending_commands_.end()) { + callback = std::move(it->second); + pending_commands_.erase(it); + } + } + if (callback) { + // Call callback on main thread + int error = event->error; + g_idle_add( + [](gpointer data) -> gboolean { + auto* pair = static_cast*>(data); + if (pair->first) pair->first(pair->second); + delete pair; + return G_SOURCE_REMOVE; + }, + new std::pair(std::move(callback), error)); + } + break; + } case MPV_EVENT_LOG_MESSAGE: { auto* msg = static_cast(event->data); g_message("MPV [%s] %s: %s", msg->level, msg->prefix, msg->text); diff --git a/linux/runner/mpv/mpv_player.h b/linux/runner/mpv/mpv_player.h index 926fc0ff..6746706f 100644 --- a/linux/runner/mpv/mpv_player.h +++ b/linux/runner/mpv/mpv_player.h @@ -48,6 +48,15 @@ class MpvPlayer { /// @param args Command arguments (e.g., ["loadfile", "url", "replace"]). void Command(const std::vector& args); + /// Callback type for async command completion. + using CommandCallback = std::function; + + /// Executes an mpv command asynchronously to prevent UI blocking. + /// The callback is called on the main thread when the command completes. + /// @param args Command arguments. + /// @param callback Callback called with error code (0 = success). + void CommandAsync(const std::vector& args, CommandCallback callback); + /// Sets an mpv property by name. /// @param name Property name. /// @param value Property value as string. @@ -125,6 +134,10 @@ class MpvPlayer { uint64_t next_reply_userdata_ = 1; std::map observed_properties_; + // Pending async commands: request_id -> callback + std::map pending_commands_; + std::mutex pending_commands_mutex_; + // GSource for processing events on main thread guint event_source_id_ = 0; }; diff --git a/linux/runner/mpv/mpv_plugin.cc b/linux/runner/mpv/mpv_plugin.cc index 2c18ec32..f49196de 100644 --- a/linux/runner/mpv/mpv_plugin.cc +++ b/linux/runner/mpv/mpv_plugin.cc @@ -342,8 +342,21 @@ static void mpv_plugin_handle_method_call(FlMethodChannel* channel, command_args.push_back(fl_value_get_string(item)); } } - self->player->Command(command_args); - response = FL_METHOD_RESPONSE(fl_method_success_response_new(nullptr)); + // Use async command to prevent UI blocking during network operations + // Take ownership of method_call to respond asynchronously + g_object_ref(method_call); + self->player->CommandAsync(command_args, [method_call](int error) { + g_autoptr(FlMethodResponse) async_response = nullptr; + if (error < 0) { + async_response = FL_METHOD_RESPONSE(fl_method_error_response_new( + "COMMAND_FAILED", "MPV command failed", nullptr)); + } else { + async_response = FL_METHOD_RESPONSE(fl_method_success_response_new(nullptr)); + } + fl_method_call_respond(method_call, async_response, nullptr); + g_object_unref(method_call); + }); + return; // Response will be sent asynchronously } } } else if (strcmp(method, "setProperty") == 0) { diff --git a/macos/Runner/MpvPlayer/MpvPlayerCore.swift b/macos/Runner/MpvPlayer/MpvPlayerCore.swift index aa1b2000..2dcad706 100644 --- a/macos/Runner/MpvPlayer/MpvPlayerCore.swift +++ b/macos/Runner/MpvPlayer/MpvPlayerCore.swift @@ -52,6 +52,11 @@ class MpvPlayerCore: NSObject { private var hdrEnabled = true // User preference for HDR private var lastSigPeak: Double = 0.0 // Last known sig-peak for re-evaluation + // Async command tracking to prevent UI blocking + private var pendingCommands: [UInt64: (Result) -> Void] = [:] + private var pendingCommandsLock = NSLock() + private var nextRequestId: UInt64 = 1 + // MARK: - Initialization func initialize(in window: NSWindow) -> Bool { @@ -204,6 +209,50 @@ class MpvPlayerCore: NSObject { command(args[0], args: Array(args.dropFirst())) } + /// Execute an MPV command asynchronously to prevent UI blocking. + /// Uses mpv_command_async which returns immediately; the completion is called + /// when MPV_EVENT_COMMAND_REPLY is received. + func commandAsync(_ args: [String], completion: @escaping (Result) -> Void) { + guard let mpv = mpv, !args.isEmpty else { + completion(.success(())) + return + } + + // Generate unique request ID + pendingCommandsLock.lock() + let requestId = nextRequestId + nextRequestId += 1 + pendingCommands[requestId] = completion + pendingCommandsLock.unlock() + + // Build array of C strings for mpv_command_async + var cargs: [UnsafeMutablePointer?] = args.map { strdup($0) } + cargs.append(nil) // null-terminate + + // mpv_command_async returns immediately + cargs.withUnsafeBufferPointer { buffer in + var constPtrs = buffer.map { UnsafePointer($0) } + let result = mpv_command_async(mpv, requestId, &constPtrs) + if result < 0 { + // Command submission failed, complete immediately with error + pendingCommandsLock.lock() + if let pending = pendingCommands.removeValue(forKey: requestId) { + pendingCommandsLock.unlock() + let error = NSError(domain: "mpv", code: Int(result), + userInfo: [NSLocalizedDescriptionKey: String(cString: mpv_error_string(result))]) + DispatchQueue.main.async { pending(.failure(error)) } + } else { + pendingCommandsLock.unlock() + } + } + } + + // Free the C strings + for ptr in cargs { + free(ptr) + } + } + // MARK: - Visibility func setVisible(_ visible: Bool) { @@ -288,6 +337,23 @@ class MpvPlayerCore: NSObject { let name = String(cString: property.name) handlePropertyChange(name: name, property: property) + case MPV_EVENT_COMMAND_REPLY: + // Handle async command completion + let requestId = event.reply_userdata + pendingCommandsLock.lock() + let completion = pendingCommands.removeValue(forKey: requestId) + pendingCommandsLock.unlock() + + if let completion = completion { + if event.error < 0 { + let error = NSError(domain: "mpv", code: Int(event.error), + userInfo: [NSLocalizedDescriptionKey: String(cString: mpv_error_string(event.error))]) + DispatchQueue.main.async { completion(.failure(error)) } + } else { + DispatchQueue.main.async { completion(.success(())) } + } + } + case MPV_EVENT_FILE_LOADED: DispatchQueue.main.async { self.delegate?.onEvent(name: "file-loaded", data: nil) @@ -441,6 +507,19 @@ class MpvPlayerCore: NSObject { // MARK: - Cleanup func dispose() { + // Cancel any pending async commands + pendingCommandsLock.lock() + let pending = pendingCommands + pendingCommands.removeAll() + pendingCommandsLock.unlock() + + // Complete pending commands with cancellation error + let cancelError = NSError(domain: "mpv", code: -1, + userInfo: [NSLocalizedDescriptionKey: "Player disposed"]) + for (_, completion) in pending { + DispatchQueue.main.async { completion(.failure(cancelError)) } + } + // Capture handle before clearing to avoid weak captures during deinit let mpvHandle = mpv mpv = nil diff --git a/macos/Runner/MpvPlayer/MpvPlayerPlugin.swift b/macos/Runner/MpvPlayer/MpvPlayerPlugin.swift index 38f7eb9c..a516e470 100644 --- a/macos/Runner/MpvPlayer/MpvPlayerPlugin.swift +++ b/macos/Runner/MpvPlayer/MpvPlayerPlugin.swift @@ -178,8 +178,15 @@ class MpvPlayerPlugin: NSObject, FlutterPlugin, FlutterStreamHandler, MpvPlayerD return } - playerCore?.command(commandArgs) - result(nil) + // Use async command to prevent UI blocking during network operations + playerCore?.commandAsync(commandArgs) { commandResult in + switch commandResult { + case .success: + result(nil) + case .failure(let error): + result(FlutterError(code: "COMMAND_FAILED", message: error.localizedDescription, details: nil)) + } + } ?? result(nil) } private func handleSetVisible(call: FlutterMethodCall, result: @escaping FlutterResult) { diff --git a/windows/runner/mpv/mpv_player.cpp b/windows/runner/mpv/mpv_player.cpp index d390c12c..3f36d5d9 100644 --- a/windows/runner/mpv/mpv_player.cpp +++ b/windows/runner/mpv/mpv_player.cpp @@ -116,6 +116,15 @@ bool MpvPlayer::Initialize(HWND container, HWND flutter_window) { void MpvPlayer::Dispose() { StopEventLoop(); + // Cancel pending async commands + { + std::lock_guard lock(pending_commands_mutex_); + for (auto& pair : pending_commands_) { + if (pair.second) pair.second(-1); // Call with error + } + pending_commands_.clear(); + } + if (mpv_) { mpv_terminate_destroy(mpv_); mpv_ = nullptr; @@ -142,6 +151,42 @@ void MpvPlayer::Command(const std::vector& args) { mpv_command(mpv_, c_args.data()); } +void MpvPlayer::CommandAsync(const std::vector& args, + CommandCallback callback) { + if (!mpv_) { + if (callback) callback(0); + return; + } + + std::vector c_args; + c_args.reserve(args.size() + 1); + for (const auto& arg : args) { + c_args.push_back(arg.c_str()); + } + c_args.push_back(nullptr); + + // Generate unique request ID and store callback + uint64_t request_id; + { + std::lock_guard lock(pending_commands_mutex_); + request_id = next_reply_userdata_++; + pending_commands_[request_id] = std::move(callback); + } + + // mpv_command_async returns immediately + int result = mpv_command_async(mpv_, request_id, c_args.data()); + if (result < 0) { + // Submission failed, complete immediately with error + std::lock_guard lock(pending_commands_mutex_); + auto it = pending_commands_.find(request_id); + if (it != pending_commands_.end()) { + auto cb = std::move(it->second); + pending_commands_.erase(it); + if (cb) cb(result); + } + } +} + void MpvPlayer::SetProperty(const std::string& name, const std::string& value) { if (!mpv_) return; @@ -270,6 +315,23 @@ void MpvPlayer::EventLoop() { void MpvPlayer::HandleMpvEvent(mpv_event* event) { switch (event->event_id) { + case MPV_EVENT_COMMAND_REPLY: { + // Handle async command completion + uint64_t request_id = event->reply_userdata; + CommandCallback callback; + { + std::lock_guard lock(pending_commands_mutex_); + auto it = pending_commands_.find(request_id); + if (it != pending_commands_.end()) { + callback = std::move(it->second); + pending_commands_.erase(it); + } + } + if (callback) { + callback(event->error); + } + break; + } case MPV_EVENT_LOG_MESSAGE: { auto* msg = static_cast(event->data); char log_msg[512]; diff --git a/windows/runner/mpv/mpv_player.h b/windows/runner/mpv/mpv_player.h index 5954eb73..750f020f 100644 --- a/windows/runner/mpv/mpv_player.h +++ b/windows/runner/mpv/mpv_player.h @@ -39,6 +39,13 @@ class MpvPlayer { // Executes an mpv command. void Command(const std::vector& args); + // Callback type for async command completion. + using CommandCallback = std::function; + + // Executes an mpv command asynchronously to prevent UI blocking. + // The callback is called on the main thread when the command completes. + void CommandAsync(const std::vector& args, CommandCallback callback); + // Sets an mpv property. void SetProperty(const std::string& name, const std::string& value); @@ -84,6 +91,10 @@ class MpvPlayer { uint64_t next_reply_userdata_ = 1; std::map observed_properties_; + // Pending async commands: request_id -> callback + std::map pending_commands_; + std::mutex pending_commands_mutex_; + // HDR state bool hdr_enabled_ = true; // User preference double last_sig_peak_ = 0.0; // Last known sig-peak for HDR content detection diff --git a/windows/runner/mpv/mpv_plugin.cpp b/windows/runner/mpv/mpv_plugin.cpp index 6fbe9102..0c7fff87 100644 --- a/windows/runner/mpv/mpv_plugin.cpp +++ b/windows/runner/mpv/mpv_plugin.cpp @@ -205,8 +205,17 @@ void MpvPlayerPlugin::HandleMethodCall( } } - player_->Command(command_args); - result->Success(); + // Use async command to prevent UI blocking during network operations + // Move result into shared_ptr for safe capture in callback + auto result_ptr = std::make_shared>>(std::move(result)); + player_->CommandAsync(command_args, [result_ptr](int error) { + if (error < 0) { + (*result_ptr)->Error("COMMAND_FAILED", "MPV command failed"); + } else { + (*result_ptr)->Success(); + } + }); + return; // Response will be sent asynchronously } else if (method == "setProperty") { if (!player_ || !player_->IsInitialized()) { result->Error("NOT_INITIALIZED", "Player not initialized");