feat(windows): keep the system awake while a track plays
Linux holds a logind inhibitor fd; Windows has SetThreadExecutionState, and nothing was calling it. The plan asked for the call to sit in `set_playback`. It cannot: that API is PER-THREAD -- Windows drops a thread's requests when it exits -- and this module's own contract says outbound calls are "free from any thread" (media_controls_qt.rs:34). In practice they arrive on the mpris-server thread, a tokio worker, or the GUI thread, whichever pushed. A request made from a pool worker would evaporate with it, silently. So the state is owned by one thread that lives as long as the process and receives a bool. ES_SYSTEM_REQUIRED without ES_DISPLAY_REQUIRED: an audio player keeps the MACHINE awake, not the screen. No ES_AWAYMODE_REQUIRED either -- that is for media-centre appliances and it suppresses the sleep the user asked for. Exposed as a free function rather than a `PlatformHandle` method, and called BEFORE the `handle()` early return on both publishers. Hanging it off the handle tied it to SMTC: a failed souvlaki init, or an HWND that never arrived, would have left playback working and the machine falling asleep mid-track -- two unrelated features failing together for no reason. `held` is updated only after the API succeeds, so a failed release cannot leave Windows inhibited while later `false` messages are skipped as no-ops. cc
This commit is contained in:
4 files changed
+111
No files matched your search
@@ -31,6 +31,11 @@ urlencoding = "2"
|
||||
# org.mpris.MediaPlayer2.DesktopEntry property (RootInterface::desktop_entry),
|
||||
# which is the only way GNOME Shell resolves the app icon. souvlaki never sets
|
||||
# it. tokio feature so the server shares the app's tokio runtime.
|
||||
[target.'cfg(target_os = "windows")'.dependencies]
|
||||
# SetThreadExecutionState: keep the machine awake while a track plays.
|
||||
# 0.61 is already in the workspace lock.
|
||||
windows-sys = { version = "0.61", features = ["Win32_System_Power"] }
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
mpris-server = "0.8"
|
||||
async-channel = "2"
|
||||
|
||||
@@ -33,6 +33,15 @@ mod platform;
|
||||
/// **macOS:** souvlaki's command callbacks fire on the app run loop, so this is
|
||||
/// safe to call from any thread, but the run loop (Slint's winit loop) must be
|
||||
/// running for inbound events to arrive.
|
||||
/// Keep the machine awake while playing. No-op off Windows; see
|
||||
/// `platform::set_sleep_inhibit` for why it is not a handle method.
|
||||
#[cfg(any(target_os = "macos", target_os = "windows"))]
|
||||
pub use platform::set_sleep_inhibit;
|
||||
|
||||
/// Keep the machine awake while playing. No-op on this platform.
|
||||
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
|
||||
pub fn set_sleep_inhibit(_playing: bool) {}
|
||||
|
||||
/// The platform window identity the OS integration needs.
|
||||
///
|
||||
/// Windows SMTC is registered against a window: souvlaki's backend calls
|
||||
|
||||
@@ -19,6 +19,98 @@ const SEEK_STEP_MICROS: i64 = 5_000_000;
|
||||
|
||||
type EventCb = Arc<dyn Fn(MediaEvent) + Send + Sync>;
|
||||
|
||||
/// Keeps Windows from sleeping while a track plays.
|
||||
///
|
||||
/// `SetThreadExecutionState` sets state on the CALLING THREAD, and this
|
||||
/// module's own contract says outbound `set_playback` is "free from any
|
||||
/// thread" -- in practice the mpris-server thread, a tokio worker, or the Qt
|
||||
/// GUI thread, whichever pushed the update. Calling the API from there would
|
||||
/// pin the flag to a thread that may be a pool worker with a short life, and
|
||||
/// the request would silently evaporate with it.
|
||||
///
|
||||
/// So the state is owned by ONE thread that lives as long as the process, and
|
||||
/// callers only send it a bool. Windows drops every request a thread made when
|
||||
/// that thread exits, which is exactly why this thread never does.
|
||||
#[cfg(target_os = "windows")]
|
||||
mod sleep {
|
||||
use std::sync::mpsc::{channel, Sender};
|
||||
use std::sync::OnceLock;
|
||||
|
||||
fn worker() -> &'static Sender<bool> {
|
||||
static TX: OnceLock<Sender<bool>> = OnceLock::new();
|
||||
TX.get_or_init(|| {
|
||||
let (tx, rx) = channel::<bool>();
|
||||
std::thread::Builder::new()
|
||||
.name("qbz-sleep-inhibit".to_string())
|
||||
.spawn(move || {
|
||||
use windows_sys::Win32::System::Power::{
|
||||
SetThreadExecutionState, ES_CONTINUOUS, ES_SYSTEM_REQUIRED,
|
||||
};
|
||||
let mut held = false;
|
||||
while let Ok(playing) = rx.recv() {
|
||||
if playing == held {
|
||||
continue;
|
||||
}
|
||||
// ES_SYSTEM_REQUIRED without ES_DISPLAY_REQUIRED: an
|
||||
// audio player must keep the MACHINE awake, not the
|
||||
// screen. No ES_AWAYMODE_REQUIRED either -- that is for
|
||||
// media-centre appliances and it suppresses the sleep
|
||||
// the user asked for.
|
||||
let flags = if playing {
|
||||
ES_CONTINUOUS | ES_SYSTEM_REQUIRED
|
||||
} else {
|
||||
ES_CONTINUOUS
|
||||
};
|
||||
// SAFETY: a plain flags call with no pointers. Always on
|
||||
// this thread, which is the whole point of the module.
|
||||
let previous = unsafe { SetThreadExecutionState(flags) };
|
||||
if previous == 0 {
|
||||
// The call FAILED (it returns the previous state,
|
||||
// or zero). Leave `held` alone: recording the new
|
||||
// value here would make every later message with
|
||||
// the same bool a no-op, so a failed RELEASE would
|
||||
// pin the machine awake for the rest of the
|
||||
// session with nothing left to undo it.
|
||||
log::warn!(
|
||||
"[inhibit] SetThreadExecutionState failed; execution state unchanged"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
held = playing;
|
||||
log::debug!(
|
||||
"[inhibit] Windows execution state -> {}",
|
||||
if playing { "system required" } else { "continuous" }
|
||||
);
|
||||
}
|
||||
})
|
||||
.expect("spawn sleep-inhibit thread");
|
||||
tx
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn set_playing(playing: bool) {
|
||||
let _ = worker().send(playing);
|
||||
}
|
||||
}
|
||||
|
||||
/// Keep the machine awake while playing, INDEPENDENTLY of whether the media
|
||||
/// integration started.
|
||||
///
|
||||
/// Deliberately a free function and not a `PlatformHandle` method. Hanging it
|
||||
/// off the handle tied it to SMTC: every caller returns early when
|
||||
/// `handle()` is `None`, so a failed souvlaki init or an HWND that never
|
||||
/// arrived would leave playback working and the machine falling asleep
|
||||
/// mid-track -- two unrelated features failing together for no reason.
|
||||
///
|
||||
/// A no-op everywhere but Windows: Linux hangs its logind inhibitor off the
|
||||
/// MPRIS update (`inhibit.rs`) and macOS needs none.
|
||||
pub fn set_sleep_inhibit(playing: bool) {
|
||||
#[cfg(target_os = "windows")]
|
||||
sleep::set_playing(playing);
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
let _ = playing;
|
||||
}
|
||||
|
||||
pub struct PlatformHandle {
|
||||
controls: Arc<Mutex<Option<MediaControls>>>,
|
||||
}
|
||||
|
||||
@@ -217,6 +217,10 @@ pub(crate) fn push_now_playing(track: &qbz_models::QueueTrack, title: &str, albu
|
||||
// smaller set of users.
|
||||
maybe_notify(track, title, album);
|
||||
push_metadata(track, title, album);
|
||||
// BEFORE the early return, and for the same reason `maybe_notify` is:
|
||||
// keeping the machine awake has nothing to do with whether the OS media
|
||||
// integration started, and a track is about to play either way.
|
||||
qbz_media_controls::set_sleep_inhibit(true);
|
||||
let Some(mc) = handle() else {
|
||||
return;
|
||||
};
|
||||
@@ -299,6 +303,7 @@ pub(crate) fn clear_now_playing() {
|
||||
/// transitions the MPRIS `Position` getter answers from the last value pushed
|
||||
/// here — the same behaviour as the Slint build.
|
||||
pub(crate) fn push_playback_state(is_playing: bool, position_secs: u64) {
|
||||
qbz_media_controls::set_sleep_inhibit(is_playing);
|
||||
let Some(mc) = handle() else {
|
||||
return;
|
||||
};
|
||||
|
||||
Reference in new issue
Block a user