Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

MPRIS DBus integration & multiple media players #98

Merged
merged 14 commits into from
Feb 11, 2025
Merged
2 changes: 1 addition & 1 deletion src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -273,7 +273,7 @@ impl App {
Some((MenuType::MediaPlayer, button_ui_ref)) => menu_wrapper(
id,
self.media_player.menu_view().map(Message::MediaPlayer),
MenuSize::Normal,
MenuSize::Large,
*button_ui_ref,
self.config.position,
),
Expand Down
2 changes: 2 additions & 0 deletions src/components/icons.rs
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ pub enum Icons {
SkipPrevious,
PlayPause,
SkipNext,
MusicNote,
}

impl From<Icons> for &'static str {
Expand Down Expand Up @@ -135,6 +136,7 @@ impl From<Icons> for &'static str {
Icons::SkipPrevious => "󰒮",
Icons::PlayPause => "󰐎",
Icons::SkipNext => "󰒭",
Icons::MusicNote => "󰎇",
}
}
}
Expand Down
280 changes: 123 additions & 157 deletions src/modules/media_player.rs
Original file line number Diff line number Diff line change
@@ -1,94 +1,43 @@
use std::{any::TypeId, ops::Not, process::Stdio, time::Duration};
use std::ops::Deref;

use super::{Module, OnModulePress};
use crate::{
app,
components::icons::{icon, Icons},
config::MediaPlayerModuleConfig,
menu::MenuType,
services::{
mpris::{MprisPlayerCommand, MprisPlayerData, MprisPlayerService, PlayerCommand},
ReadOnlyService, Service, ServiceEvent,
},
style::SettingsButtonStyle,
utils::launcher::execute_command,
utils::truncate_text,
};
use iced::{
stream::channel,
widget::{button, column, row, slider, text},
widget::{button, column, container, row, slider, text},
Alignment::Center,
Element, Subscription, Task,
};
use log::error;
use tokio::{process, time::sleep};

async fn get_current_song() -> Option<String> {
let get_current_song_cmd = process::Command::new("bash")
.arg("-c")
.arg("playerctl metadata --format \"{{ artist }} - {{ title }}\"")
.stdout(Stdio::piped())
.output()
.await;

match get_current_song_cmd {
Ok(get_current_song_cmd) => {
if !get_current_song_cmd.status.success() {
return None;
}
let s = String::from_utf8_lossy(&get_current_song_cmd.stdout);
let trimmed = s.trim();
trimmed.is_empty().not().then(|| trimmed.into())
}
Err(e) => {
error!("Error: {:?}", e);
None
}
}
}

async fn get_volume() -> Option<f64> {
let get_volume_cmd = process::Command::new("bash")
.arg("-c")
.arg("playerctl volume")
.stdout(Stdio::piped())
.output()
.await;

match get_volume_cmd {
Ok(get_volume_cmd) => {
if !get_volume_cmd.status.success() {
return None;
}
let v = String::from_utf8_lossy(&get_volume_cmd.stdout);
let trimmed = v.trim();
if trimmed.is_empty() {
return None;
}
match trimmed.parse::<f64>() {
Ok(v) => Some(v * 100.0),
Err(e) => {
error!("Error: {:?}", e);
None
}
}
}
Err(e) => {
error!("Error: {:?}", e);
None
}
}
}

#[derive(Default)]
pub struct MediaPlayer {
data: Vec<PlayerData>,
service: Option<MprisPlayerService>,
MalpenZibo marked this conversation as resolved.
Show resolved Hide resolved
}

struct PlayerData {
name: String,
song: Option<String>,
volume: Option<f64>,
}

#[derive(Debug, Clone)]
pub enum Message {
SetSong(Option<String>),
Prev,
Play,
Next,
SetVolume(Option<f64>),
SyncVolume(Option<f64>),
Prev(String),
PlayPause(String),
Next(String),
SetVolume(String, f64),
Event(ServiceEvent<MprisPlayerService>),
}

impl MediaPlayer {
Expand All @@ -98,81 +47,104 @@ impl MediaPlayer {
config: &MediaPlayerModuleConfig,
) -> Task<crate::app::Message> {
match message {
Message::SetSong(song) => {
if let Some(song) = song {
let length = song.len();

self.song = Some(if length > config.max_title_length as usize {
let split = config.max_title_length as usize / 2;
let first_part = song.chars().take(split).collect::<String>();
let last_part = song.chars().skip(length - split).collect::<String>();
format!("{}...{}", first_part, last_part)
} else {
song
});
} else {
self.song = None;
Message::Prev(s) => self.handle_command(s, PlayerCommand::Prev),
Message::PlayPause(s) => self.handle_command(s, PlayerCommand::PlayPause),
Message::Next(s) => self.handle_command(s, PlayerCommand::Next),
Message::SetVolume(s, v) => self.handle_command(s, PlayerCommand::Volume(v)),
Message::Event(event) => match event {
ServiceEvent::Init(s) => {
self.data = Self::map_service_to_module_data(s.deref(), config);
self.service = Some(s);
Task::none()
}
ServiceEvent::Update(d) => {
self.data = Self::map_service_to_module_data(&d, config);

Task::none()
}
Message::Prev => {
execute_command("playerctl previous".to_string());
Task::perform(async move { get_current_song().await }, move |song| {
app::Message::MediaPlayer(Message::SetSong(song))
})
}
Message::Play => {
execute_command("playerctl play-pause".to_string());
Task::perform(async move { get_current_song().await }, move |song| {
app::Message::MediaPlayer(Message::SetSong(song))
})
}
Message::Next => {
execute_command("playerctl next".to_string());
Task::perform(async move { get_current_song().await }, move |song| {
app::Message::MediaPlayer(Message::SetSong(song))
})
}
Message::SetVolume(v) => {
if let Some(v) = v {
execute_command(format!("playerctl volume {}", v / 100.0));
if let Some(service) = self.service.as_mut() {
service.update(d);
}
Task::none()
}
self.volume = v;
Task::none()
}
Message::SyncVolume(v) => {
self.volume = v;
Task::none()
}
ServiceEvent::Error(_) => Task::none(),
},
}
}

pub fn menu_view(&self) -> Element<Message> {
column![]
.push_maybe(
self.volume
.map(|v| slider(0.0..=100.0, v, |new_v| Message::SetVolume(Some(new_v)))),
)
.push(
row![
button(icon(Icons::SkipPrevious))
.on_press(Message::Prev)
.padding([5, 12])
.style(SettingsButtonStyle.into_style()),
button(icon(Icons::PlayPause))
.on_press(Message::Play)
.style(SettingsButtonStyle.into_style()),
button(icon(Icons::SkipNext))
.on_press(Message::Next)
.padding([5, 12])
.style(SettingsButtonStyle.into_style())
]
.spacing(8),
)
.spacing(8)
.align_x(Center)
.into()
column(
self.data
.iter()
.flat_map(|d| {
let buttons = row![
button(icon(Icons::SkipPrevious))
.on_press(Message::Prev(d.name.clone()))
.padding([5, 12])
.style(SettingsButtonStyle.into_style()),
button(icon(Icons::PlayPause))
.on_press(Message::PlayPause(d.name.clone()))
.style(SettingsButtonStyle.into_style()),
button(icon(Icons::SkipNext))
.on_press(Message::Next(d.name.clone()))
.padding([5, 12])
.style(SettingsButtonStyle.into_style())
]
.spacing(8);

[
iced::widget::horizontal_rule(2).into(),
container(
column![]
.push_maybe(d.song.clone().map(text))
.push_maybe(d.volume.map(|v| {
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would be nice to have a fallback value where we cannot find the song title. Something like NoTitle or boh I don't know some common default value in this case 😅

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Went with "No Title" for now.

I think in the future it can rely on Track_Id as a fallback, though if it has custom formatting support its back to square one. I have not looked at other status bars/shells to see what they do, but would think that's probably the easiest way to figure out what's sensible.

slider(0.0..=100.0, v, |v| {
Message::SetVolume(d.name.clone(), v)
})
}))
.push(buttons)
.width(iced::Length::Fill)
.spacing(12)
.align_x(Center),
)
.padding(16)
.into(),
]
})
.skip(1),
)
.spacing(16)
.into()
}

fn handle_command(
&mut self,
service_name: String,
command: PlayerCommand,
) -> Task<crate::app::Message> {
if let Some(s) = self.service.as_mut() {
s.command(MprisPlayerCommand {
service_name,
command,
})
.map(|event| crate::app::Message::MediaPlayer(Message::Event(event)))
} else {
Task::none()
}
}

fn map_service_to_module_data(
data: &[MprisPlayerData],
config: &MediaPlayerModuleConfig,
) -> Vec<PlayerData> {
data.iter()
.map(|d| PlayerData {
name: d.service.clone(),
song: d
.metadata
.clone()
.map(|d| truncate_text(&d.to_string(), config.max_title_length)),
volume: d.volume,
})
.collect()
}
}

Expand All @@ -184,31 +156,25 @@ impl Module for MediaPlayer {
&self,
(): Self::ViewData<'_>,
) -> Option<(Element<app::Message>, Option<OnModulePress>)> {
self.song.clone().map(|s| {
(
text(s).size(12).into(),
match self.data.len() {
0 => None,
1 => self.data[0].song.clone().map(|s| {
(
MalpenZibo marked this conversation as resolved.
Show resolved Hide resolved
text(s).into(),
Some(OnModulePress::ToggleMenu(MenuType::MediaPlayer)),
)
}),
_ => Some((
icon(Icons::MusicNote).into(),
MalpenZibo marked this conversation as resolved.
Show resolved Hide resolved
Some(OnModulePress::ToggleMenu(MenuType::MediaPlayer)),
)
})
)),
}
}

fn subscription(&self, (): Self::SubscriptionData<'_>) -> Option<Subscription<app::Message>> {
let id = TypeId::of::<Self>();

Some(
Subscription::run_with_id(
id,
channel(10, |mut output| async move {
loop {
let song = get_current_song().await;
let _ = output.try_send(Message::SetSong(song));
let volume = get_volume().await;
let _ = output.try_send(Message::SyncVolume(volume));
sleep(Duration::from_secs(1)).await;
}
}),
)
.map(app::Message::MediaPlayer),
MprisPlayerService::subscribe()
.map(|event| app::Message::MediaPlayer(Message::Event(event))),
)
}
}
13 changes: 2 additions & 11 deletions src/modules/window_title.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
use crate::app;
use crate::{app, utils::truncate_text};
use hyprland::{data::Client, event_listener::AsyncEventListener, shared::HyprDataActiveOptional};
use iced::{stream::channel, widget::text, Element, Subscription};
use log::{debug, error};
Expand Down Expand Up @@ -31,16 +31,7 @@ impl WindowTitle {
match message {
Message::TitleChanged(value) => {
if let Some(value) = value {
let length = value.len();

self.value = Some(if length > truncate_title_after_length as usize {
let split = truncate_title_after_length as usize / 2;
let first_part = value.chars().take(split).collect::<String>();
let last_part = value.chars().skip(length - split).collect::<String>();
format!("{}...{}", first_part, last_part)
} else {
value
});
self.value = Some(truncate_text(&value, truncate_title_after_length));
} else {
self.value = None;
}
Expand Down
1 change: 1 addition & 0 deletions src/services/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ pub mod audio;
pub mod bluetooth;
pub mod brightness;
pub mod idle_inhibitor;
pub mod mpris;
pub mod network;
pub mod privacy;
pub mod tray;
Expand Down
Loading
Loading