mirror of
https://github.com/narrrl/proton-stream.git
synced 2026-11-03 16:41:26 +01:00
feat(app): media keys and desktop media widget, keep the screen on during a film
- MPRIS on Linux and SMTC on Windows through souvlaki: play/pause, next, previous, stop and seeking from media keys and the panel widget, with the title, episode, duration and the cached cover as a file:// URL - the screensaver is held off while a film plays on the player page, through the desktop portal with org.freedesktop.ScreenSaver as the fallback, and SetThreadExecutionState on Windows. mpv's own stop-screensaver has no window to act for under the render API
This commit is contained in:
1 parent
c60ffc7ab1
commit
959547cdc8
8 files changed
+573
-3
No files matched your search
@@ -29,3 +29,11 @@ sha2.workspace = true
|
||||
parking_lot.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
souvlaki.workspace = true
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
dbus.workspace = true
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
windows-sys = { workspace = true, features = ["Win32_System_Power"] }
|
||||
raw-window-handle = "0.6"
|
||||
@@ -428,6 +428,12 @@ pub struct App {
|
||||
crawl_progress: std::collections::BTreeMap<String, usize>,
|
||||
/// The window's size, written at exit for the next launch.
|
||||
window: crate::window::WindowMemory,
|
||||
/// Media keys and the desktop's media widget.
|
||||
media: crate::media::MediaSession,
|
||||
/// The cover handed to it, worked out once per player.
|
||||
media_cover: Option<(u64, Option<String>)>,
|
||||
/// Holds the screensaver off while a film is on screen and playing.
|
||||
inhibitor: crate::inhibit::Inhibitor,
|
||||
pub downloads: Vec<DownloadItem>,
|
||||
pub offline_files: std::collections::HashSet<DownloadKey>,
|
||||
pub confirm_partial_delete: Option<DownloadKey>,
|
||||
@@ -494,6 +500,9 @@ impl App {
|
||||
crawling: false,
|
||||
crawl_progress: Default::default(),
|
||||
window,
|
||||
media: crate::media::MediaSession::new(&cc.egui_ctx, window_handle(cc)),
|
||||
media_cover: None,
|
||||
inhibitor: crate::inhibit::Inhibitor::new(),
|
||||
downloads: Vec::new(),
|
||||
offline_files: std::collections::HashSet::new(),
|
||||
confirm_partial_delete: None,
|
||||
@@ -740,6 +749,34 @@ impl App {
|
||||
}
|
||||
}
|
||||
|
||||
/// The playing title's artwork as a `file://` URL, if it is on disk.
|
||||
///
|
||||
/// The cached file rather than the provider's URL: the desktop would
|
||||
/// otherwise fetch it from the provider itself, and a desktop shell is not
|
||||
/// something the viewer agreed would talk to one.
|
||||
fn media_cover(&mut self) -> Option<String> {
|
||||
let playback = self.playback.as_ref()?;
|
||||
if let Some((id, cover)) = &self.media_cover
|
||||
&& *id == playback.id
|
||||
{
|
||||
return cover.clone();
|
||||
}
|
||||
let cover = self
|
||||
.metadata
|
||||
.get(&playback.target.title_key)
|
||||
.and_then(|record| record.metadata.as_ref())
|
||||
.and_then(|found| {
|
||||
found
|
||||
.poster_url
|
||||
.as_deref()
|
||||
.or_else(|| found.tile_art().map(|(url, _)| url))
|
||||
})
|
||||
.and_then(|url| self.engine.poster_file(url))
|
||||
.map(|path| format!("file://{}", path.display()));
|
||||
self.media_cover = Some((playback.id, cover.clone()));
|
||||
cover
|
||||
}
|
||||
|
||||
fn share_name(&self, id: &str) -> String {
|
||||
self.shares
|
||||
.iter()
|
||||
@@ -1343,6 +1380,9 @@ impl eframe::App for App {
|
||||
self.window.observe(ctx);
|
||||
|
||||
let mut actions: Vec<Action> = Vec::new();
|
||||
self.media.drain(self.playback.as_ref(), &mut actions);
|
||||
let cover = self.media_cover();
|
||||
self.media.show(self.playback.as_ref(), cover.as_deref());
|
||||
|
||||
// A player page with nothing playing and nothing opening is a black
|
||||
// window with no way out. It should be unreachable — `pump` routes away
|
||||
@@ -1351,6 +1391,15 @@ impl eframe::App for App {
|
||||
self.page = Page::Library;
|
||||
}
|
||||
self.engine.set_picture_shown(self.page == Page::Player);
|
||||
// On screen and moving: a film paused, or playing behind another page,
|
||||
// is no reason to keep the screen awake.
|
||||
self.inhibitor.set(
|
||||
self.page == Page::Player
|
||||
&& self
|
||||
.playback
|
||||
.as_ref()
|
||||
.is_some_and(|playback| playback.loaded && !playback.paused),
|
||||
);
|
||||
|
||||
if self.page == Page::Player {
|
||||
self.overlay.observe(ctx);
|
||||
@@ -1656,6 +1705,22 @@ impl eframe::App for App {
|
||||
}
|
||||
}
|
||||
|
||||
/// The native window, for the parts of the OS that want it named — SMTC on
|
||||
/// Windows. Nothing else asks.
|
||||
#[cfg(windows)]
|
||||
fn window_handle(cc: &eframe::CreationContext<'_>) -> Option<*mut std::ffi::c_void> {
|
||||
use raw_window_handle::{HasWindowHandle as _, RawWindowHandle};
|
||||
match cc.window_handle().ok()?.as_raw() {
|
||||
RawWindowHandle::Win32(handle) => Some(handle.hwnd.get() as *mut std::ffi::c_void),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
fn window_handle(_cc: &eframe::CreationContext<'_>) -> Option<*mut std::ffi::c_void> {
|
||||
None
|
||||
}
|
||||
|
||||
/// What the top bar says while shares are being listed: one share by name,
|
||||
/// several as a count, with the files found across all of them.
|
||||
fn crawl_label(progress: &std::collections::BTreeMap<String, usize>) -> String {
|
||||
|
||||
@@ -2008,6 +2008,15 @@ impl Engine {
|
||||
});
|
||||
}
|
||||
|
||||
/// Where a provider's picture is cached, if it is.
|
||||
pub fn poster_file(&self, url: &str) -> Option<std::path::PathBuf> {
|
||||
let path = self
|
||||
.dirs
|
||||
.poster_cache()
|
||||
.join(format!("{}.img", digest(url)));
|
||||
path.is_file().then_some(path)
|
||||
}
|
||||
|
||||
/// Fetch, decode and post one title's artwork.
|
||||
///
|
||||
/// Cached on disk under a hash of the URL, so a provider that moves its CDN
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
//! Keeping the screen on while a film plays.
|
||||
//!
|
||||
//! mpv does this itself for a window of its own (`stop-screensaver`), but here
|
||||
//! the picture is drawn into the app's window through the render API, and mpv
|
||||
//! has no window to hold the screensaver off for. So the app asks.
|
||||
//!
|
||||
//! Best effort, like the media controls: a desktop that does not answer means
|
||||
//! the screen may blank during a film, which is what happened before this.
|
||||
|
||||
/// Holds the screensaver off while [`Inhibitor::set`] says so.
|
||||
pub struct Inhibitor {
|
||||
held: bool,
|
||||
#[cfg(target_os = "linux")]
|
||||
orders: Option<std::sync::mpsc::Sender<bool>>,
|
||||
}
|
||||
|
||||
impl Inhibitor {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
held: false,
|
||||
#[cfg(target_os = "linux")]
|
||||
orders: linux::spawn(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Hold or release. Called every frame; acts only on a change.
|
||||
pub fn set(&mut self, hold: bool) {
|
||||
if hold == self.held {
|
||||
return;
|
||||
}
|
||||
self.held = hold;
|
||||
#[cfg(target_os = "linux")]
|
||||
if let Some(orders) = &self.orders {
|
||||
let _ = orders.send(hold);
|
||||
}
|
||||
#[cfg(windows)]
|
||||
windows::set(hold);
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Inhibitor {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Inhibitor {
|
||||
fn drop(&mut self) {
|
||||
self.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
mod linux {
|
||||
use std::sync::mpsc::{Sender, channel};
|
||||
use std::time::Duration;
|
||||
|
||||
use dbus::Path;
|
||||
use dbus::arg::PropMap;
|
||||
use dbus::blocking::Connection;
|
||||
|
||||
const TIMEOUT: Duration = Duration::from_secs(2);
|
||||
const REASON: &str = "Playing a video";
|
||||
|
||||
/// What is holding the screensaver off, and how to let go of it.
|
||||
enum Hold {
|
||||
/// The desktop portal: released by closing the request it returned.
|
||||
Portal(Path<'static>),
|
||||
/// The older `org.freedesktop.ScreenSaver`: released by its cookie.
|
||||
ScreenSaver(u32),
|
||||
}
|
||||
|
||||
/// A thread of its own, holding the session bus connection. The hold is
|
||||
/// tied to that connection — closing it releases the hold — so it has to
|
||||
/// outlive any one call, and a D-Bus call that stalls must not stall a
|
||||
/// frame.
|
||||
pub fn spawn() -> Option<Sender<bool>> {
|
||||
let (orders, receiver) = channel::<bool>();
|
||||
std::thread::Builder::new()
|
||||
.name("inhibit".into())
|
||||
.spawn(move || {
|
||||
let connection = match Connection::new_session() {
|
||||
Ok(connection) => connection,
|
||||
Err(error) => {
|
||||
tracing::debug!("no session bus to inhibit the screensaver on: {error}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let mut hold = None;
|
||||
for wanted in receiver {
|
||||
match (wanted, hold.take()) {
|
||||
(true, None) => hold = acquire(&connection),
|
||||
(false, Some(held)) => release(&connection, held),
|
||||
(_, held) => hold = held,
|
||||
}
|
||||
}
|
||||
if let Some(held) = hold {
|
||||
release(&connection, held);
|
||||
}
|
||||
})
|
||||
.inspect_err(|error| tracing::debug!("inhibit thread: {error}"))
|
||||
.ok()?;
|
||||
Some(orders)
|
||||
}
|
||||
|
||||
fn acquire(connection: &Connection) -> Option<Hold> {
|
||||
// The portal first: it is what works under Flatpak and on GNOME, which
|
||||
// does not implement the screensaver interface's `Inhibit`.
|
||||
let portal = connection.with_proxy(
|
||||
"org.freedesktop.portal.Desktop",
|
||||
"/org/freedesktop/portal/desktop",
|
||||
TIMEOUT,
|
||||
);
|
||||
let mut options = PropMap::new();
|
||||
options.insert(
|
||||
"reason".into(),
|
||||
dbus::arg::Variant(Box::new(REASON.to_owned())),
|
||||
);
|
||||
// 8 is "idle": the screen blanking or locking for want of input.
|
||||
let answer: Result<(Path<'static>,), _> = portal.method_call(
|
||||
"org.freedesktop.portal.Inhibit",
|
||||
"Inhibit",
|
||||
("", 8u32, options),
|
||||
);
|
||||
match answer {
|
||||
Ok((request,)) => return Some(Hold::Portal(request)),
|
||||
Err(error) => tracing::debug!("portal inhibit: {error}"),
|
||||
}
|
||||
|
||||
let screensaver = connection.with_proxy(
|
||||
"org.freedesktop.ScreenSaver",
|
||||
"/org/freedesktop/ScreenSaver",
|
||||
TIMEOUT,
|
||||
);
|
||||
let answer: Result<(u32,), _> = screensaver.method_call(
|
||||
"org.freedesktop.ScreenSaver",
|
||||
"Inhibit",
|
||||
("proton-stream", REASON),
|
||||
);
|
||||
match answer {
|
||||
Ok((cookie,)) => Some(Hold::ScreenSaver(cookie)),
|
||||
Err(error) => {
|
||||
tracing::debug!("screensaver inhibit: {error}");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn release(connection: &Connection, hold: Hold) {
|
||||
let result: Result<(), dbus::Error> = match hold {
|
||||
Hold::Portal(request) => connection
|
||||
.with_proxy("org.freedesktop.portal.Desktop", request, TIMEOUT)
|
||||
.method_call("org.freedesktop.portal.Request", "Close", ()),
|
||||
Hold::ScreenSaver(cookie) => connection
|
||||
.with_proxy(
|
||||
"org.freedesktop.ScreenSaver",
|
||||
"/org/freedesktop/ScreenSaver",
|
||||
TIMEOUT,
|
||||
)
|
||||
.method_call("org.freedesktop.ScreenSaver", "UnInhibit", (cookie,)),
|
||||
};
|
||||
if let Err(error) = result {
|
||||
tracing::debug!("release the screensaver: {error}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
mod windows {
|
||||
use windows_sys::Win32::System::Power::{
|
||||
ES_CONTINUOUS, ES_DISPLAY_REQUIRED, ES_SYSTEM_REQUIRED, SetThreadExecutionState,
|
||||
};
|
||||
|
||||
pub fn set(hold: bool) {
|
||||
let flags = if hold {
|
||||
ES_CONTINUOUS | ES_DISPLAY_REQUIRED | ES_SYSTEM_REQUIRED
|
||||
} else {
|
||||
ES_CONTINUOUS
|
||||
};
|
||||
// SAFETY: takes flags by value and touches nothing of ours.
|
||||
unsafe {
|
||||
SetThreadExecutionState(flags);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,8 @@
|
||||
|
||||
pub mod app;
|
||||
pub mod engine;
|
||||
pub mod inhibit;
|
||||
pub mod media;
|
||||
pub mod pacing;
|
||||
pub mod playback;
|
||||
pub mod theme;
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
//! The desktop's view of what is playing: media keys, the media widget in the
|
||||
//! panel, the lock screen.
|
||||
//!
|
||||
//! MPRIS on Linux, the System Media Transport Controls on Windows, through
|
||||
//! `souvlaki`. Both are optional in the strongest sense — a session with no
|
||||
//! D-Bus, or a desktop with nothing listening, plays exactly as before — so
|
||||
//! every failure here is a debug line and nothing more.
|
||||
|
||||
use std::sync::mpsc::{Receiver, channel};
|
||||
use std::time::Duration;
|
||||
|
||||
use souvlaki::{
|
||||
MediaControlEvent, MediaControls, MediaMetadata, MediaPlayback, MediaPosition, PlatformConfig,
|
||||
SeekDirection,
|
||||
};
|
||||
|
||||
use crate::app::{Action, Adjacent};
|
||||
use crate::playback::{Command, Playback};
|
||||
|
||||
/// How far one press of a seek key moves, when the desktop does not say.
|
||||
const SEEK_SECONDS: f64 = 10.0;
|
||||
|
||||
/// What was last told to the desktop, so it is told again only on a change.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
struct Told {
|
||||
/// What the metadata was built from: the player, whether its duration was
|
||||
/// known yet, and the cover. Any of them changing means sending it again.
|
||||
item: Option<(u64, bool, Option<String>)>,
|
||||
paused: bool,
|
||||
/// Where the playhead was, to the second. A desktop interpolates between
|
||||
/// updates, so this only has to be sent when the playhead jumps.
|
||||
second: i64,
|
||||
}
|
||||
|
||||
pub struct MediaSession {
|
||||
controls: Option<MediaControls>,
|
||||
events: Receiver<MediaControlEvent>,
|
||||
told: Option<Told>,
|
||||
}
|
||||
|
||||
impl MediaSession {
|
||||
/// Register with the desktop. `window` is the handle SMTC needs on
|
||||
/// Windows; elsewhere it is not used.
|
||||
pub fn new(ctx: &egui::Context, window: Option<*mut std::ffi::c_void>) -> Self {
|
||||
let (sender, events) = channel();
|
||||
let config = PlatformConfig {
|
||||
display_name: "proton-stream",
|
||||
dbus_name: "proton_stream",
|
||||
hwnd: window,
|
||||
};
|
||||
let ctx = ctx.clone();
|
||||
let controls = MediaControls::new(config)
|
||||
.and_then(|mut controls| {
|
||||
controls.attach(move |event| {
|
||||
// The press arrives on the D-Bus thread; it is acted on in
|
||||
// the next frame, which this is what asks for.
|
||||
let _ = sender.send(event);
|
||||
ctx.request_repaint();
|
||||
})?;
|
||||
Ok(controls)
|
||||
})
|
||||
.inspect_err(|error| tracing::debug!("media controls unavailable: {error:?}"))
|
||||
.ok();
|
||||
Self {
|
||||
controls,
|
||||
events,
|
||||
told: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Turn the presses since the last frame into actions.
|
||||
pub fn drain(&mut self, playback: Option<&Playback>, actions: &mut Vec<Action>) {
|
||||
while let Ok(event) = self.events.try_recv() {
|
||||
if let Some(action) = action_for(event, playback) {
|
||||
actions.push(action);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Tell the desktop what is playing, if that changed since the last frame.
|
||||
///
|
||||
/// `cover` is a `file://` URL to the title's artwork when it is on disk.
|
||||
pub fn show(&mut self, playback: Option<&Playback>, cover: Option<&str>) {
|
||||
let Some(controls) = self.controls.as_mut() else {
|
||||
return;
|
||||
};
|
||||
let told = Told {
|
||||
item: playback.map(|playback| {
|
||||
(
|
||||
playback.id,
|
||||
playback.duration.is_some(),
|
||||
cover.map(str::to_owned),
|
||||
)
|
||||
}),
|
||||
paused: playback.is_none_or(|playback| playback.paused),
|
||||
second: playback.map_or(0, |playback| playback.position as i64),
|
||||
};
|
||||
let previous = self.told.replace(told.clone());
|
||||
// Within the second, or one second on from it: playback carrying on,
|
||||
// which the desktop already shows without being told.
|
||||
if let Some(previous) = &previous
|
||||
&& previous.item == told.item
|
||||
&& previous.paused == told.paused
|
||||
&& (0..=1).contains(&(told.second - previous.second))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
let Some(playback) = playback else {
|
||||
if let Err(error) = controls.set_playback(MediaPlayback::Stopped) {
|
||||
tracing::debug!("media controls: {error:?}");
|
||||
}
|
||||
return;
|
||||
};
|
||||
if previous.is_none_or(|previous| previous.item != told.item) {
|
||||
let target = &playback.target;
|
||||
let episode = target.caption();
|
||||
let metadata = MediaMetadata {
|
||||
title: Some(if episode.is_empty() {
|
||||
&target.title_name
|
||||
} else {
|
||||
&episode
|
||||
}),
|
||||
album: Some(&target.title_name),
|
||||
artist: None,
|
||||
cover_url: cover,
|
||||
duration: playback.duration.map(Duration::from_secs_f64),
|
||||
};
|
||||
if let Err(error) = controls.set_metadata(metadata) {
|
||||
tracing::debug!("media controls: {error:?}");
|
||||
}
|
||||
}
|
||||
let progress = Some(MediaPosition(Duration::from_secs_f64(
|
||||
playback.position.max(0.0),
|
||||
)));
|
||||
let state = if playback.paused {
|
||||
MediaPlayback::Paused { progress }
|
||||
} else {
|
||||
MediaPlayback::Playing { progress }
|
||||
};
|
||||
if let Err(error) = controls.set_playback(state) {
|
||||
tracing::debug!("media controls: {error:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What a press means here. `None` for the ones with nothing to act on.
|
||||
fn action_for(event: MediaControlEvent, playback: Option<&Playback>) -> Option<Action> {
|
||||
let playback = playback?;
|
||||
let seek = |direction: SeekDirection, seconds: f64| {
|
||||
Action::Player(Command::SeekBy(match direction {
|
||||
SeekDirection::Forward => seconds,
|
||||
SeekDirection::Backward => -seconds,
|
||||
}))
|
||||
};
|
||||
Some(match event {
|
||||
MediaControlEvent::Toggle => Action::Player(Command::TogglePause),
|
||||
MediaControlEvent::Play if playback.paused => Action::Player(Command::TogglePause),
|
||||
MediaControlEvent::Pause if !playback.paused => Action::Player(Command::TogglePause),
|
||||
MediaControlEvent::Next => Action::PlayAdjacent(Adjacent::Next),
|
||||
MediaControlEvent::Previous => Action::PlayAdjacent(Adjacent::Previous),
|
||||
MediaControlEvent::Stop => Action::LeavePlayer,
|
||||
MediaControlEvent::Seek(direction) => seek(direction, SEEK_SECONDS),
|
||||
MediaControlEvent::SeekBy(direction, by) => seek(direction, by.as_secs_f64()),
|
||||
MediaControlEvent::SetPosition(MediaPosition(at)) => {
|
||||
Action::Player(Command::SeekTo(at.as_secs_f64()))
|
||||
}
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user