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:
vicrodh committed 2026-08-27 13:55:45 -06:00
1 parent 9b0d8d7e0f
commit c0664030ee
4 files changed
+111

No files matched your search

+5
View File
@@ -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"
+9
View File
@@ -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
+92
View File
@@ -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>>>,
}
+5
View File
@@ -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;
};