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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,7 @@ position: Top # optional, default Top
# - Tray
# - Clock
# - Privacy
# - MediaPlayer
# - Settings
# optional, the following is the default configuration
modules:
Expand Down
8 changes: 5 additions & 3 deletions src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -227,7 +227,7 @@ impl App {
},
_ => Task::none(),
},
Message::MediaPlayer(msg) => self.media_player.update(msg, &self.config.media_player),
Message::MediaPlayer(msg) => self.media_player.update(msg),
}
}

Expand Down Expand Up @@ -272,8 +272,10 @@ impl App {
),
Some((MenuType::MediaPlayer, button_ui_ref)) => menu_wrapper(
id,
self.media_player.menu_view().map(Message::MediaPlayer),
MenuSize::Normal,
self.media_player
.menu_view(&self.config.media_player)
.map(Message::MediaPlayer),
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
275 changes: 108 additions & 167 deletions src/modules/media_player.rs
Original file line number Diff line number Diff line change
@@ -1,214 +1,155 @@
use std::{any::TypeId, ops::Not, process::Stdio, time::Duration};

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 {
song: Option<String>,
volume: Option<f64>,
service: Option<MprisPlayerService>,
}

#[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 {
pub fn update(
&mut self,
message: Message,
config: &MediaPlayerModuleConfig,
) -> Task<crate::app::Message> {
pub fn update(&mut self, message: Message) -> 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.service = Some(s);
Task::none()
}

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));
ServiceEvent::Update(d) => {
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),
pub fn menu_view(&self, config: &MediaPlayerModuleConfig) -> Element<Message> {
match &self.service {
None => text("Not connected to MPRIS service").into(),
Some(s) => column(
s.iter()
.flat_map(|d| {
let d = d.clone();
let title = text(Self::get_title(&d, config));
let buttons = row![
button(icon(Icons::SkipPrevious))
.on_press(Message::Prev(d.service.clone()))
.padding([5, 12])
.style(SettingsButtonStyle.into_style()),
button(icon(Icons::PlayPause))
.on_press(Message::PlayPause(d.service.clone()))
.style(SettingsButtonStyle.into_style()),
button(icon(Icons::SkipNext))
.on_press(Message::Next(d.service.clone()))
.padding([5, 12])
.style(SettingsButtonStyle.into_style())
]
.spacing(8);
let volume_slider = d.volume.map(|v| {
slider(0.0..=100.0, v, move |v| {
Message::SetVolume(d.service.clone(), v)
})
});

[
iced::widget::horizontal_rule(2).into(),
container(
column![title]
.push_maybe(volume_slider)
.push(buttons)
.width(iced::Length::Fill)
.spacing(12)
.align_x(Center),
)
.padding(16)
.into(),
]
})
.skip(1),
)
.spacing(8)
.align_x(Center)
.into()
.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 get_title(d: &MprisPlayerData, config: &MediaPlayerModuleConfig) -> String {
match &d.metadata {
Some(m) => truncate_text(&m.to_string(), config.max_title_length),
None => "No Title".to_string(),
}
}
}

impl Module for MediaPlayer {
type ViewData<'a> = ();
type ViewData<'a> = &'a MediaPlayerModuleConfig;
type SubscriptionData<'a> = ();

fn view(
&self,
(): Self::ViewData<'_>,
config: Self::ViewData<'_>,
) -> Option<(Element<app::Message>, Option<OnModulePress>)> {
self.song.clone().map(|s| {
(
text(s).size(12).into(),
self.service.as_ref().and_then(|s| match s.len() {
0 => None,
_ => Some((
row![icon(Icons::MusicNote), text(Self::get_title(&s[0], config))]
MalpenZibo marked this conversation as resolved.
Show resolved Hide resolved
.spacing(8)
.into(),
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))),
)
}
}
2 changes: 1 addition & 1 deletion src/modules/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -224,7 +224,7 @@ impl App {
ModuleName::Clock => self.clock.view(&self.config.clock.format),
ModuleName::Privacy => self.privacy.view(()),
ModuleName::Settings => self.settings.view(()),
ModuleName::MediaPlayer => self.media_player.view(()),
ModuleName::MediaPlayer => self.media_player.view(&self.config.media_player),
}
}

Expand Down
Loading
Loading