From 2e4ba352f4a967865e1761c6e0b8d67370b44332 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Sun, 5 Jul 2026 06:49:38 +0200 Subject: [PATCH] fix: multi-trigger audio recovery after Windows sleep/wake MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The two prior attempts at #783 failed for lack of a reliable trigger: re-setting audio-device to its own value is a no-op, and gating recovery on audio-device-list changes while current-ao is null never fires in the real flows (a paused player only discovers the dead WASAPI session at unpause, when the device list is already stable; a zombie session that consumes samples silently never goes null). Replace the single gate with a recovery scheduler on the mpv event thread, ticked every wait-event timeout: - WM_POWERBROADCAST resume (both PBT variants, via the plugin's existing window-proc delegate) requests unconditional ao-reloads at +1.5s and +6s after wake — the only trigger that reaches zombie sessions; the second shot covers a first reload landing while the audio stack is still restoring. The handoff is a single atomic flag: no mpv calls or timers from the platform thread, so there is nothing to clean up on dispose. - current-ao falling to null arms clock-driven retries (budget 5, x2 backoff capped at 8s); retries must be clock-driven because a failed ao-reload falls back to null without a property change event. A device-list change while null refreshes the budget, and audio-device-list is now observed natively so recovery does not depend on the Dart side. Every trigger, attempt, and outcome logs as [mpv:audio-recovery] at warn level so exported logs from the reporter are actionable if this still misses. Verified with synthetic PBT_APMRESUMEAUTOMATIC while playing and while paused: full WASAPI reinit both times, audio restarts on unpause. Ref #783 --- windows/runner/mpv/mpv_player.cpp | 132 +++++++++++++++++++++++++++--- windows/runner/mpv/mpv_player.h | 21 ++++- windows/runner/mpv/mpv_plugin.cpp | 11 +++ 3 files changed, 151 insertions(+), 13 deletions(-) diff --git a/windows/runner/mpv/mpv_player.cpp b/windows/runner/mpv/mpv_player.cpp index 7968bea1..44fce6b3 100644 --- a/windows/runner/mpv/mpv_player.cpp +++ b/windows/runner/mpv/mpv_player.cpp @@ -2,12 +2,29 @@ #include +#include + #include "sanitize_utf8.h" namespace mpv { namespace { +// Audio recovery schedule (issue #783: silent WASAPI after wake from sleep). +// Resume reloads fire unconditionally — a post-wake WASAPI session can stay +// "healthy" from mpv's point of view while producing no sound, so there is no +// property to gate on; the second shot covers a first reload that lands while +// the audio stack is still restoring and creates another silent session. +// Null-fallback retries are clock-driven because a failed ao-reload falls +// back to null again without emitting a current-ao change event. +constexpr int kResumeReloadAttempts = 2; +constexpr std::chrono::milliseconds kResumeFirstDelay{1500}; +constexpr std::chrono::milliseconds kResumeRetryDelay{4500}; +constexpr int kNullRetryBudget = 5; +constexpr std::chrono::milliseconds kNullFirstDelay{500}; +constexpr std::chrono::milliseconds kNullBackoffCap{8000}; +constexpr std::chrono::milliseconds kDeviceListDebounce{250}; + // DComp-mode input forwarding. mpv's inner window lives on mpv's own thread // and consumes the mouse input over the video (WS_EX_TRANSPARENT hit-test // skipping is same-thread-only, and disabling the subtree makes the system @@ -114,7 +131,7 @@ bool MpvPlayer::Initialize(HWND view) { // When WASAPI becomes unavailable (sleep, device unplug), fall back to null // audio output instead of permanently dropping the audio track. Recovery is - // handled in the event loop when audio-device-list changes. + // handled by MaybeRunAudioRecovery in the event loop. mpv_set_option_string(mpv_, "audio-fallback-to-null", "yes"); // Default to warn-level logging; Dart side can raise to "v" if debug logging is enabled. @@ -133,6 +150,9 @@ bool MpvPlayer::Initialize(HWND view) { // Observe video-params/sig-peak for HDR detection mpv_observe_property(mpv_, 0, "video-params/sig-peak", MPV_FORMAT_DOUBLE); mpv_observe_property(mpv_, 0, "current-ao", MPV_FORMAT_STRING); + // Native observation so audio recovery doesn't depend on the Dart side + // choosing to observe the device list. + mpv_observe_property(mpv_, 0, "audio-device-list", MPV_FORMAT_NONE); // Start event loop. StartEventLoop(); @@ -340,10 +360,72 @@ void MpvPlayer::SetEventCallback(EventCallback callback) { event_callback_ = std::move(callback); } -void MpvPlayer::ReloadAudioOutput() { +void MpvPlayer::NotifyPowerSuspend() { LogRecovery("system suspending"); } + +void MpvPlayer::NotifyPowerResume() { resume_reload_requested_.store(true); } + +void MpvPlayer::LogRecovery(const std::string& text) { + char log_msg[512]; + snprintf(log_msg, sizeof(log_msg), "MPV [warn] audio-recovery: %s", text.c_str()); + OutputDebugStringA(log_msg); + + // Emitted as a synthetic log-message event so it reaches the app logs + // regardless of the mpv log level. + flutter::EncodableMap data; + data[flutter::EncodableValue("prefix")] = flutter::EncodableValue("audio-recovery"); + data[flutter::EncodableValue("level")] = flutter::EncodableValue("warn"); + data[flutter::EncodableValue("text")] = flutter::EncodableValue(text); + SendEvent("log-message", data); +} + +void MpvPlayer::TryAudioReload(const char* reason, int attempt) { if (audio_reload_pending_) return; audio_reload_pending_ = true; - CommandAsync({"ao-reload"}, [this](int) { audio_reload_pending_ = false; }); + LogRecovery("issuing ao-reload (reason=" + std::string(reason) + ", attempt " + std::to_string(attempt) + ")"); + std::string reason_str = reason; + CommandAsync({"ao-reload"}, [this, reason_str, attempt](int error) { + audio_reload_pending_ = false; + LogRecovery("ao-reload completed (reason=" + reason_str + ", attempt " + std::to_string(attempt) + + ", error=" + std::to_string(error) + ")"); + }); +} + +void MpvPlayer::MaybeRunAudioRecovery() { + const auto now = std::chrono::steady_clock::now(); + + if (resume_reload_requested_.exchange(false)) { + if (file_loaded_) { + resume_attempts_left_ = kResumeReloadAttempts; + resume_next_attempt_ = now + kResumeFirstDelay; + LogRecovery("power resume detected; scheduling ao-reload in " + std::to_string(kResumeFirstDelay.count()) + + "ms"); + } else { + LogRecovery("power resume detected; no file loaded, nothing to recover"); + } + } + + if (resume_attempts_left_ > 0 && now >= resume_next_attempt_) { + int attempt = kResumeReloadAttempts - resume_attempts_left_ + 1; + resume_attempts_left_--; + resume_next_attempt_ = now + kResumeRetryDelay; + TryAudioReload("resume", attempt); + } + + if (null_attempts_left_ > 0 && now >= null_next_attempt_) { + if (!current_ao_is_null_) { + null_attempts_left_ = 0; + LogRecovery("audio recovered (current-ao no longer null)"); + } else { + int attempt = kNullRetryBudget - null_attempts_left_ + 1; + null_attempts_left_--; + null_next_attempt_ = now + null_backoff_; + null_backoff_ = std::min(null_backoff_ * 2, kNullBackoffCap); + TryAudioReload("null-fallback", attempt); + if (null_attempts_left_ == 0) { + LogRecovery("audio recovery budget exhausted; waiting for device list change or power resume"); + } + } + } } void MpvPlayer::StartEventLoop() { @@ -365,13 +447,15 @@ void MpvPlayer::StopEventLoop() { void MpvPlayer::EventLoop() { while (running_) { mpv_event* event = mpv_wait_event(mpv_, 0.1); - if (event->event_id == MPV_EVENT_NONE) { - continue; - } if (event->event_id == MPV_EVENT_SHUTDOWN) { break; } - HandleMpvEvent(event); + if (event->event_id != MPV_EVENT_NONE) { + HandleMpvEvent(event); + } + // Runs on every iteration including wait timeouts: this ~100ms tick is + // the clock that drives scheduled audio reload attempts. + MaybeRunAudioRecovery(); } } @@ -455,19 +539,42 @@ void MpvPlayer::HandleMpvEvent(mpv_event* event) { if (prop->format == MPV_FORMAT_STRING && prop->data) { current_ao = *static_cast(prop->data); } - current_ao_is_null_ = current_ao && strcmp(current_ao, "null") == 0; + bool is_null = current_ao && strcmp(current_ao, "null") == 0; + if (is_null && !current_ao_is_null_) { + // AO fell back to null (audio-fallback-to-null); start recovery. + null_attempts_left_ = kNullRetryBudget; + null_backoff_ = kNullFirstDelay; + null_next_attempt_ = std::chrono::steady_clock::now() + kNullFirstDelay; + LogRecovery("current-ao fell back to null; starting recovery (budget " + std::to_string(kNullRetryBudget) + + ")"); + } else if (!is_null && current_ao_is_null_) { + null_attempts_left_ = 0; + LogRecovery(std::string("current-ao is now '") + (current_ao ? current_ao : "") + "'"); + } + current_ao_is_null_ = is_null; } - // Audio recovery for sleep/wake or unplugged devices. - // Mirrors mpv's TOOLS/lua/ao-null-reload.lua for embedded libmpv. - if (strcmp(prop->name, "audio-device-list") == 0 && current_ao_is_null_) { - ReloadAudioOutput(); + // A device (re)appearing while the AO sits on the null fallback is a + // fresh recovery opportunity: refresh the retry budget and pull the next + // attempt close. Gated on the native observation (userdata 0) so the + // Dart-side observation of the same property doesn't double-trigger. + if (strcmp(prop->name, "audio-device-list") == 0 && event->reply_userdata == 0 && current_ao_is_null_) { + auto candidate = std::chrono::steady_clock::now() + kDeviceListDebounce; + if (null_attempts_left_ <= 0 || candidate < null_next_attempt_) { + null_next_attempt_ = candidate; + } + null_attempts_left_ = kNullRetryBudget; + null_backoff_ = kNullFirstDelay; + LogRecovery("audio-device-list changed while ao=null; rescheduling ao-reload"); } SendPropertyChange(prop->name, &node); break; } case MPV_EVENT_END_FILE: { + file_loaded_ = false; + resume_attempts_left_ = 0; + null_attempts_left_ = 0; auto* end = static_cast(event->data); flutter::EncodableMap data; data[flutter::EncodableValue("reason")] = flutter::EncodableValue(static_cast(end->reason)); @@ -479,6 +586,7 @@ void MpvPlayer::HandleMpvEvent(mpv_event* event) { break; } case MPV_EVENT_FILE_LOADED: { + file_loaded_ = true; SendEvent("file-loaded"); break; } diff --git a/windows/runner/mpv/mpv_player.h b/windows/runner/mpv/mpv_player.h index 0a89a550..44494c21 100644 --- a/windows/runner/mpv/mpv_player.h +++ b/windows/runner/mpv/mpv_player.h @@ -6,6 +6,7 @@ #include #include +#include #include #include #include @@ -75,6 +76,12 @@ class MpvPlayer { // Sets the event callback for property changes and events. void SetEventCallback(EventCallback callback); + // Power notifications, called from the platform thread (window proc). + // NotifyPowerResume only sets an atomic flag consumed by the event thread — + // no mpv calls, no timers — so it cannot race Dispose and needs no cleanup. + void NotifyPowerSuspend(); + void NotifyPowerResume(); + private: void StartEventLoop(); void StopEventLoop(); @@ -82,7 +89,9 @@ class MpvPlayer { void HandleMpvEvent(mpv_event* event); void SendPropertyChange(const char* name, mpv_node* data); void SendEvent(const std::string& name, const flutter::EncodableMap& data = {}); - void ReloadAudioOutput(); + void MaybeRunAudioRecovery(); + void TryAudioReload(const char* reason, int attempt); + void LogRecovery(const std::string& text); uint64_t RegisterStatusRequest(StatusCallback callback); StatusCallback TakeStatusRequest(uint64_t request_id); uint64_t RegisterGetPropertyRequest(GetPropertyCallback callback); @@ -98,6 +107,16 @@ class MpvPlayer { bool current_ao_is_null_ = false; bool audio_reload_pending_ = false; + // Audio recovery state. Event thread only, except |resume_reload_requested_| + // which the platform thread sets on WM_POWERBROADCAST resume. + std::atomic resume_reload_requested_{false}; + bool file_loaded_ = false; + int resume_attempts_left_ = 0; + std::chrono::steady_clock::time_point resume_next_attempt_{}; + int null_attempts_left_ = 0; + std::chrono::steady_clock::time_point null_next_attempt_{}; + std::chrono::milliseconds null_backoff_{}; + uint64_t next_reply_userdata_ = 1; std::map observed_properties_; std::map name_to_id_; diff --git a/windows/runner/mpv/mpv_plugin.cpp b/windows/runner/mpv/mpv_plugin.cpp index 71b01f94..ca9b7d44 100644 --- a/windows/runner/mpv/mpv_plugin.cpp +++ b/windows/runner/mpv/mpv_plugin.cpp @@ -137,6 +137,17 @@ void MpvPlayerPlugin::HandleMethodCall( DrainPlatformTasks(); return std::optional(0); } + // Both resume variants are handled: which of the two arrives (or + // both) depends on S3 vs Modern Standby; re-arming is idempotent. + // player_ create/reset also happens on this thread, so the + // null-check suffices. Never consume the message. + if (message == WM_POWERBROADCAST && player_ && player_->IsInitialized()) { + if (wparam == PBT_APMSUSPEND) { + player_->NotifyPowerSuspend(); + } else if (wparam == PBT_APMRESUMEAUTOMATIC || wparam == PBT_APMRESUMESUSPEND) { + player_->NotifyPowerResume(); + } + } return std::optional(std::nullopt); });