feat: mpris support for audio applet

This commit is contained in:
Ashley Wulber 2023-10-23 21:12:35 -04:00 committed by Ashley Wulber
parent 3f0f632d41
commit 557a43517d
5 changed files with 460 additions and 20 deletions

View file

@ -12,11 +12,13 @@ libpulse-glib-binding = "2.25.0"
tokio = { version = "1.20.1", features=["full"] }
libcosmic.workspace = true
cosmic-time.workspace = true
log = "0.4.14"
tracing = "0.1.40"
pretty_env_logger = "0.4.0"
# Application i18n
i18n-embed = { version = "0.13", features = ["fluent-system", "desktop-requester"] }
i18n-embed-fl = "0.6"
rust-embed = "6.6"
rust-embed-utils = "7.5.0"
serde = "1.0.130"
mpris = "2.0.1"
url = "2"

View file

@ -0,0 +1,15 @@
use cosmic::cosmic_config::cosmic_config_derive::CosmicConfigEntry;
use cosmic::cosmic_config::{self, Config, ConfigGet, ConfigSet, CosmicConfigEntry};
use serde::{Deserialize, Serialize};
#[derive(Default, Debug, Clone, Serialize, Deserialize, CosmicConfigEntry, PartialEq, Eq)]
pub struct AudioAppletConfig {
pub show_media_controls_in_top_panel: bool,
}
impl AudioAppletConfig {
/// Returns the version of the config
pub fn version() -> u64 {
1
}
}

View file

@ -1,12 +1,21 @@
mod localize;
use config::AudioAppletConfig;
use cosmic::app::Command;
use cosmic::applet::cosmic_panel_config::PanelAnchor;
use cosmic::applet::menu_button;
use cosmic::applet::menu_control_padding;
use cosmic::applet::padded_control;
use cosmic::cosmic_config::CosmicConfigEntry;
use cosmic::iced::widget;
use cosmic::iced::Limits;
use cosmic::iced_futures::futures::channel::mpsc::Sender;
use cosmic::iced_futures::futures::SinkExt;
use cosmic::iced_runtime::core::alignment::Horizontal;
use cosmic::widget::button;
use cosmic::widget::Column;
use cosmic::widget::Row;
use cosmic::widget::{divider, icon};
use cosmic::Renderer;
@ -21,7 +30,12 @@ use cosmic_time::{anim, chain, id, once_cell::sync::Lazy, Instant, Timeline};
use iced::wayland::popup::{destroy_popup, get_popup};
use iced::widget::container;
use mpris::PlaybackStatus;
use mpris_subscription::MprisRequest;
use mpris_subscription::MprisUpdate;
mod config;
mod mpris_subscription;
mod pulse;
use crate::localize::localize;
use crate::pulse::DeviceInfo;
@ -33,7 +47,7 @@ pub fn main() -> cosmic::iced::Result {
// Prepare i18n
localize();
cosmic::applet::run::<Audio>(false, ())
cosmic::applet::run::<Audio>(true, ())
}
static SHOW_MEDIA_CONTROLS: Lazy<id::Toggler> = Lazy::new(id::Toggler::unique);
@ -50,9 +64,11 @@ struct Audio {
icon_name: String,
input_icon_name: String,
popup: Option<window::Id>,
show_media_controls_in_top_panel: bool,
id_ctr: u128,
timeline: Timeline,
config: AudioAppletConfig,
mpris_tx: Option<Sender<MprisRequest>>,
player_status: Option<mpris_subscription::PlayerStatus>,
}
impl Audio {
@ -127,6 +143,113 @@ enum Message {
CloseRequested(window::Id),
ToggleMediaControlsInTopPanel(chain::Toggler, bool),
Frame(Instant),
ConfigChanged(AudioAppletConfig),
Mpris(mpris_subscription::MprisUpdate),
MprisRequest(MprisRequest),
}
impl Audio {
fn playback_buttons(&self) -> Option<Element<Message>> {
if self.player_status.is_some() && self.config.show_media_controls_in_top_panel {
let mut elements = Vec::with_capacity(3);
if let Some(go_prev) = self.go_previous() {
elements.push(go_prev);
}
if let Some(play_pause) = self.play_pause() {
elements.push(play_pause);
}
if let Some(go_next) = self.go_next() {
elements.push(go_next);
}
Some(match self.core.applet.anchor {
PanelAnchor::Left | PanelAnchor::Right => Column::with_children(elements)
.align_items(Alignment::Center)
.into(),
PanelAnchor::Top | PanelAnchor::Bottom => Row::with_children(elements)
.align_items(Alignment::Center)
.into(),
})
} else {
None
}
}
fn go_previous(&self) -> Option<Element<Message>> {
self.player_status.as_ref().and_then(|s| {
if s.can_go_previous {
Some(
button::icon(
icon::from_name("media-skip-backward-symbolic")
.size(24)
.symbolic(true),
)
.extra_small()
.on_press(Message::MprisRequest(MprisRequest::Previous))
.into(),
)
} else {
None
}
})
}
fn go_next(&self) -> Option<Element<Message>> {
self.player_status.as_ref().and_then(|s| {
if s.can_go_next {
Some(
button::icon(
icon::from_name("media-skip-forward-symbolic")
.size(24)
.symbolic(true),
)
.extra_small()
.on_press(Message::MprisRequest(MprisRequest::Next))
.into(),
)
} else {
None
}
})
}
fn play_pause(&self) -> Option<Element<Message>> {
self.player_status.as_ref().and_then(|s| match s.status {
PlaybackStatus::Playing => {
if s.can_pause {
Some(
button::icon(
icon::from_name("media-playback-pause-symbolic")
.size(32)
.symbolic(true),
)
.on_press(Message::MprisRequest(MprisRequest::Pause))
.extra_small()
.into(),
)
} else {
None
}
}
PlaybackStatus::Paused | PlaybackStatus::Stopped => {
if s.can_play {
Some(
button::icon(
icon::from_name("media-playback-start-symbolic")
.size(32)
.symbolic(true),
)
.extra_small()
.on_press(Message::MprisRequest(MprisRequest::Play))
.into(),
)
} else {
None
}
}
})
}
}
impl cosmic::Application for Audio {
@ -227,7 +350,7 @@ impl cosmic::Application for Audio {
if let PulseState::Connected(connection) = &mut self.pulse_state {
if let Some(device) = &self.current_input {
if let Some(name) = &device.name {
log::info!("increasing volume of {}", name);
tracing::info!("increasing volume of {}", name);
connection.send(pulse::Message::SetSourceVolumeByName(
name.clone(),
device.volume,
@ -308,7 +431,7 @@ impl cosmic::Application for Audio {
panic!("Subscriton error handling is bad. This should never happen.")
}
_ => {
log::trace!("Received misc message")
tracing::trace!("Received misc message")
}
}
}
@ -316,13 +439,40 @@ impl cosmic::Application for Audio {
},
Message::ToggleMediaControlsInTopPanel(chain, enabled) => {
self.timeline.set_chain(chain).start();
self.show_media_controls_in_top_panel = enabled;
self.config.show_media_controls_in_top_panel = enabled;
if let Ok(helper) =
cosmic::cosmic_config::Config::new(Self::APP_ID, AudioAppletConfig::version())
{
if let Err(err) = self.config.write_entry(&helper) {
tracing::error!(?err, "Error writing config");
}
}
}
Message::CloseRequested(id) => {
if Some(id) == self.popup {
self.popup = None;
}
}
Message::ConfigChanged(c) => {
self.config = c;
}
Message::Mpris(mpris_subscription::MprisUpdate::Setup(tx)) => {
self.mpris_tx = Some(tx);
}
Message::Mpris(mpris_subscription::MprisUpdate::Player(p)) => {
self.player_status = Some(p);
}
Message::Mpris(MprisUpdate::Finished) => {
self.player_status = None;
self.mpris_tx = None;
}
Message::MprisRequest(r) => {
if let Some(mut tx) = self.mpris_tx.clone() {
_ = tokio::spawn(async move {
_ = tx.send(r).await;
});
}
}
};
Command::none()
@ -334,15 +484,46 @@ impl cosmic::Application for Audio {
self.timeline
.as_subscription()
.map(|(_, now)| Message::Frame(now)),
cosmic::cosmic_config::config_subscription(
0,
Self::APP_ID.into(),
AudioAppletConfig::version(),
)
.map(|(_, res)| match res {
Ok(c) => Message::ConfigChanged(c),
Err((errs, c)) => {
for err in errs {
tracing::error!("Error loading config: {}", err);
}
Message::ConfigChanged(c)
}
}),
mpris_subscription::mpris_subscription(0).map(Message::Mpris),
])
}
fn view(&self) -> Element<Message> {
self.core
let btn = self
.core
.applet
.icon_button(&self.icon_name)
.on_press(Message::TogglePopup)
.into()
.on_press(Message::TogglePopup);
if let Some(playback_buttons) = self.playback_buttons() {
match self.core.applet.anchor {
PanelAnchor::Left | PanelAnchor::Right => {
Column::with_children(vec![playback_buttons, btn.into()])
.align_items(Alignment::Center)
.into()
}
PanelAnchor::Top | PanelAnchor::Bottom => {
Row::with_children(vec![playback_buttons, btn.into()])
.align_items(Alignment::Center)
.into()
}
}
} else {
btn.into()
}
}
fn view_window(&self, _id: window::Id) -> Element<Message> {
@ -362,7 +543,7 @@ impl cosmic::Application for Audio {
)
.0 * 100.0;
let audio_content = if audio_disabled {
let mut audio_content = if audio_disabled {
column![padded_control(
text(fl!("disconnected"))
.width(Length::Fill)
@ -441,6 +622,40 @@ impl cosmic::Application for Audio {
]
.align_items(Alignment::Start)
};
if let Some(s) = self.player_status.as_ref() {
let mut elements = Vec::with_capacity(5);
if let Some(icon_path) = s.icon.clone() {
elements.push(icon(icon::from_path(icon_path)).size(24).into());
}
elements.push(
column![
text(s.title.clone().unwrap_or_default()).size(14),
text(s.artists.clone().unwrap_or_default().join(", ")).size(10),
]
.into(),
);
if let Some(go_prev) = self.go_previous() {
elements.push(go_prev);
}
if let Some(play_pause) = self.play_pause() {
elements.push(play_pause);
}
if let Some(go_next) = self.go_next() {
elements.push(go_next);
}
audio_content = audio_content.push(padded_control(divider::horizontal::default()));
audio_content = audio_content.push(
Row::with_children(elements)
.align_items(Alignment::Center)
.spacing(8)
.padding(menu_control_padding()),
);
}
let content = column![
audio_content,
padded_control(divider::horizontal::default()),
@ -450,7 +665,7 @@ impl cosmic::Application for Audio {
SHOW_MEDIA_CONTROLS,
&self.timeline,
Some(fl!("show-media-controls")),
self.show_media_controls_in_top_panel,
self.config.show_media_controls_in_top_panel,
Message::ToggleMediaControlsInTopPanel,
)
.text_size(14)

View file

@ -0,0 +1,205 @@
use std::{borrow::Cow, fmt::Debug, hash::Hash, path::PathBuf, time::Duration};
use cosmic::{
iced::{self, subscription},
iced_futures::futures::{
self,
channel::mpsc::{channel, Receiver, Sender},
SinkExt, StreamExt,
},
};
use mpris::{PlaybackStatus, PlayerFinder};
#[derive(Clone, Debug)]
pub struct PlayerStatus {
pub icon: Option<PathBuf>,
pub title: Option<Cow<'static, str>>,
pub artists: Option<Vec<Cow<'static, str>>>,
pub status: PlaybackStatus,
pub can_pause: bool,
pub can_play: bool,
pub can_go_previous: bool,
pub can_go_next: bool,
}
pub fn mpris_subscription<I: 'static + Hash + Copy + Send + Sync + Debug>(
id: I,
) -> iced::Subscription<MprisUpdate> {
subscription::channel(id, 50, move |mut output| async move {
let mut state = State::Setup;
loop {
state = update(state, &mut output).await;
}
})
}
#[derive(Debug)]
pub enum State {
Setup,
Wait(Receiver<MprisUpdate>),
Finished,
}
#[derive(Clone, Debug)]
pub enum MprisUpdate {
Setup(Sender<MprisRequest>),
Player(PlayerStatus),
Finished,
}
#[derive(Clone, Debug)]
pub enum MprisRequest {
Play,
Pause,
Next,
Previous,
}
async fn update(state: State, output: &mut futures::channel::mpsc::Sender<MprisUpdate>) -> State {
match state {
State::Setup => {
let (mut tx, rx) = channel(30);
let (thread_tx, mut thread_rx) = channel(30);
let _ = std::thread::spawn(move || {
let mut ctr = 0;
loop {
let player = match PlayerFinder::new().and_then(|f| {
f.find_active()
.map_err(|e| mpris::DBusError::Miscellaneous(e.to_string()))
}) {
Ok(p) => {
ctr = 0;
p
}
Err(e) => {
tracing::error!(?e, "Failed to find active media player.");
std::thread::sleep(Duration::from_millis(ctr.min(20) * 100));
continue;
}
};
let can_go_next = player.can_go_next().unwrap_or_default();
let can_go_previous = player.can_go_previous().unwrap_or_default();
let can_play = player.can_play().unwrap_or_default();
let can_pause = player.can_pause().unwrap_or_default();
let Ok(mut tracker) = player.track_progress(200) else {
tracing::error!("Failed to track progress.");
std::thread::sleep(Duration::from_secs(2));
continue;
};
let (title, artists, icon) = player
.get_metadata()
.map(|m| {
(
m.title().map(|c| Cow::Owned(String::from(c))),
m.artists().map(|a| {
a.into_iter()
.map(|a| Cow::from(String::from(a)))
.collect::<Vec<_>>()
}),
m.art_url()
.and_then(|u| url::Url::parse(u).ok())
.and_then(|u| {
if u.scheme() == "file" {
u.to_file_path().ok()
} else {
None
}
}),
)
})
.unwrap_or_default();
if let Err(err) = tx.try_send(MprisUpdate::Player(PlayerStatus {
icon,
title,
artists,
status: player
.get_playback_status()
.unwrap_or(PlaybackStatus::Stopped),
can_pause,
can_play,
can_go_previous,
can_go_next,
})) {
tracing::error!(?err, "Failed to send player update.");
}
loop {
if let Ok(req) = thread_rx.try_next() {
match req {
Some(MprisRequest::Play) => {
let _ = player.play();
}
Some(MprisRequest::Pause) => {
let _ = player.pause();
}
Some(MprisRequest::Next) => {
let _ = player.next();
}
Some(MprisRequest::Previous) => {
let _ = player.previous();
}
None => {
return;
}
}
}
let tick = tracker.tick();
if tick.player_quit {
tracing::info!("Player quit.");
break;
}
if tick.progress_changed {
let metadata = tick.progress.metadata();
if let Err(err) = tx.try_send(MprisUpdate::Player(PlayerStatus {
icon: metadata
.art_url()
.and_then(|u| url::Url::parse(u).ok())
.and_then(|u| {
if u.scheme() == "file" {
u.to_file_path().ok()
} else {
None
}
}),
title: metadata.title().map(|t| Cow::from(t.to_string())),
artists: metadata.artists().map(|a| {
a.into_iter().map(|a| Cow::from(a.to_string())).collect()
}),
status: tick.progress.playback_status(),
can_pause: player.can_pause().unwrap_or_default(),
can_play: player.can_play().unwrap_or_default(),
can_go_previous: player.can_go_previous().unwrap_or_default(),
can_go_next: player.can_go_next().unwrap_or_default(),
})) {
tracing::error!(?err, "Failed to send player update.");
break;
}
}
}
drop(tracker);
}
});
let _ = output.send(MprisUpdate::Setup(thread_tx)).await;
State::Wait(rx)
}
State::Wait(mut rx) => match rx.next().await {
Some(u) => {
match u {
MprisUpdate::Setup(_) => {}
u => {
let _ = output.send(u).await;
}
}
State::Wait(rx)
}
None => {
_ = output.send(MprisUpdate::Finished).await;
return State::Finished;
}
},
State::Finished => iced::futures::future::pending().await,
}
}

View file

@ -212,7 +212,7 @@ impl PulseHandle {
.await
.unwrap(),
Err(e) => {
log::error!("ERROR! {:?}", e);
tracing::error!("ERROR! {:?}", e);
PulseHandle::send_disconnected(&mut from_pulse_send).await;
}
}
@ -262,28 +262,31 @@ impl PulseHandle {
server.set_source_volume_by_name(&name, &channel_volumes)
}
Message::UpdateConnection => {
log::info!(
tracing::info!(
"Updating Connection, server exists: {:?}",
server.is_some()
);
if let Some(mut cur_server) = server.take() {
log::trace!("getting server info...");
tracing::trace!("getting server info...");
if let Err(_) = cur_server.get_server_info() {
log::warn!("got error, server must be disconnected...");
tracing::warn!("got error, server must be disconnected...");
PulseHandle::send_disconnected(&mut from_pulse_send).await;
} else {
log::trace!("got server info, still connected...");
tracing::trace!("got server info, still connected...");
server = Some(cur_server);
}
} else {
match PulseServer::connect().and_then(|server| server.init()) {
Ok(new_server) => {
log::info!("Connected to server");
tracing::info!("Connected to server");
PulseHandle::send_connected(&mut from_pulse_send).await;
server = Some(new_server);
}
Err(err) => {
log::error!("Failed to connect to server: {:?}", err);
tracing::error!(
"Failed to connect to server: {:?}",
err
);
}
}
}
@ -327,7 +330,7 @@ impl PulseHandle {
}
}
_ => {
log::warn!("message doesn't match")
tracing::warn!("message doesn't match")
}
}
}