fix: async MPV commands to prevent ANR

This commit is contained in:
edde746
2026-01-20 01:14:45 +01:00
parent e18a9457dd
commit 9721e6bf3f
12 changed files with 397 additions and 10 deletions
@@ -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<String>, 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()
@@ -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) {
+79
View File
@@ -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, Error>) -> 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, Error>) -> 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<CChar>?] = 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
+9 -2
View File
@@ -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) {
+71
View File
@@ -144,6 +144,15 @@ void MpvPlayer::Dispose() {
return; // Already disposed
}
// Cancel pending async commands
{
std::lock_guard<std::mutex> 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<std::string>& args) {
mpv_command(mpv_, c_args.data());
}
void MpvPlayer::CommandAsync(const std::vector<std::string>& args,
CommandCallback callback) {
if (disposed_ || !mpv_) {
if (callback) callback(0);
return;
}
std::vector<const char*> 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<std::mutex> 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<std::mutex> 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<std::mutex> 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<std::pair<CommandCallback, int>*>(data);
if (pair->first) pair->first(pair->second);
delete pair;
return G_SOURCE_REMOVE;
},
new std::pair<CommandCallback, int>(std::move(callback), error));
}
break;
}
case MPV_EVENT_LOG_MESSAGE: {
auto* msg = static_cast<mpv_event_log_message*>(event->data);
g_message("MPV [%s] %s: %s", msg->level, msg->prefix, msg->text);
+13
View File
@@ -48,6 +48,15 @@ class MpvPlayer {
/// @param args Command arguments (e.g., ["loadfile", "url", "replace"]).
void Command(const std::vector<std::string>& args);
/// Callback type for async command completion.
using CommandCallback = std::function<void(int error)>;
/// 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<std::string>& 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<std::string, uint64_t> observed_properties_;
// Pending async commands: request_id -> callback
std::map<uint64_t, CommandCallback> pending_commands_;
std::mutex pending_commands_mutex_;
// GSource for processing events on main thread
guint event_source_id_ = 0;
};
+15 -2
View File
@@ -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) {
@@ -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, Error>) -> 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, Error>) -> 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<CChar>?] = 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
+9 -2
View File
@@ -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) {
+62
View File
@@ -116,6 +116,15 @@ bool MpvPlayer::Initialize(HWND container, HWND flutter_window) {
void MpvPlayer::Dispose() {
StopEventLoop();
// Cancel pending async commands
{
std::lock_guard<std::mutex> 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<std::string>& args) {
mpv_command(mpv_, c_args.data());
}
void MpvPlayer::CommandAsync(const std::vector<std::string>& args,
CommandCallback callback) {
if (!mpv_) {
if (callback) callback(0);
return;
}
std::vector<const char*> 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<std::mutex> 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<std::mutex> 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<std::mutex> 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<mpv_event_log_message*>(event->data);
char log_msg[512];
+11
View File
@@ -39,6 +39,13 @@ class MpvPlayer {
// Executes an mpv command.
void Command(const std::vector<std::string>& args);
// Callback type for async command completion.
using CommandCallback = std::function<void(int error)>;
// 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<std::string>& 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<std::string, uint64_t> observed_properties_;
// Pending async commands: request_id -> callback
std::map<uint64_t, CommandCallback> 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
+11 -2
View File
@@ -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::unique_ptr<flutter::MethodResult<flutter::EncodableValue>>>(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");