Merge branch 'audio-applet_jammy' into update-relm4_jammy
This commit is contained in:
commit
fded103467
11 changed files with 372 additions and 303 deletions
139
Cargo.lock
generated
139
Cargo.lock
generated
|
|
@ -340,25 +340,6 @@ dependencies = [
|
|||
"xdg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cosmic-applet-audio"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"async-io",
|
||||
"freedesktop-desktop-entry",
|
||||
"futures-util",
|
||||
"gtk4",
|
||||
"libcosmic-widgets",
|
||||
"libpulse-binding",
|
||||
"mpris2-zbus",
|
||||
"once_cell",
|
||||
"pulsectl-rs",
|
||||
"relm4-macros 0.4.4",
|
||||
"tokio",
|
||||
"tracker",
|
||||
"zbus",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cosmic-applet-graphics"
|
||||
version = "0.1.0"
|
||||
|
|
@ -620,15 +601,6 @@ dependencies = [
|
|||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dirs"
|
||||
version = "3.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "30baa043103c9d0c2a57cf537cc2f35623889dc0d405e6c3cccfadbc81c71309"
|
||||
dependencies = [
|
||||
"dirs-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dirs"
|
||||
version = "4.0.0"
|
||||
|
|
@ -850,19 +822,6 @@ version = "1.2.0"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e9d758e60b45e8d749c89c1b389ad8aee550f86aa12e2b9298b546dda7a82ab1"
|
||||
|
||||
[[package]]
|
||||
name = "freedesktop-desktop-entry"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "45157175a725e81f3f594382430b6b78af5f8f72db9bd51b94f0785f80fc6d29"
|
||||
dependencies = [
|
||||
"dirs 3.0.2",
|
||||
"gettext-rs",
|
||||
"memchr",
|
||||
"thiserror",
|
||||
"xdg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures"
|
||||
version = "0.3.21"
|
||||
|
|
@ -1084,26 +1043,6 @@ dependencies = [
|
|||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gettext-rs"
|
||||
version = "0.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e49ea8a8fad198aaa1f9655a2524b64b70eb06b2f3ff37da407566c93054f364"
|
||||
dependencies = [
|
||||
"gettext-sys",
|
||||
"locale_config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gettext-sys"
|
||||
version = "0.21.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c63ce2e00f56a206778276704bbe38564c8695249fdc8f354b4ef71c57c3839d"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"temp-dir",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gio"
|
||||
version = "0.15.11"
|
||||
|
|
@ -1541,33 +1480,6 @@ dependencies = [
|
|||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libpulse-binding"
|
||||
version = "2.26.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "17be42160017e0ae993c03bfdab4ecb6f82ce3f8d515bd8da8fdf18d10703663"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"libc",
|
||||
"libpulse-sys",
|
||||
"num-derive",
|
||||
"num-traits",
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libpulse-sys"
|
||||
version = "1.19.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "991e6bd0efe2a36e6534e136e7996925e4c1a8e35b7807fe533f2beffff27c30"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"num-derive",
|
||||
"num-traits",
|
||||
"pkg-config",
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "locale_config"
|
||||
version = "0.3.0"
|
||||
|
|
@ -1662,18 +1574,6 @@ dependencies = [
|
|||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mpris2-zbus"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/pop-os/mpris2-zbus#bcc8481ea7ccfc08aa870f28272d9093db3b1ba9"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"thiserror",
|
||||
"time 0.3.9",
|
||||
"zbus",
|
||||
"zvariant",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nanorand"
|
||||
version = "0.7.0"
|
||||
|
|
@ -1731,17 +1631,6 @@ dependencies = [
|
|||
"minimal-lexical",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-derive"
|
||||
version = "0.3.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "876a53fff98e03a936a674b29568b0e605f06b29372c2489ff4de23f1949743d"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-integer"
|
||||
version = "0.1.45"
|
||||
|
|
@ -2042,15 +1931,6 @@ dependencies = [
|
|||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pulsectl-rs"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "06a988bceed1981b2c5fc4a3da0e4e073fdaff8e6bd022b089f54bc573dc3cfc"
|
||||
dependencies = [
|
||||
"libpulse-binding",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quick-error"
|
||||
version = "1.2.3"
|
||||
|
|
@ -2168,17 +2048,6 @@ dependencies = [
|
|||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "relm4-macros"
|
||||
version = "0.4.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7136d9b9b97dc87198c619587de7bd61aca5ec4bec58a7167404c1edf750a490"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "relm4-macros"
|
||||
version = "0.5.0-beta.1"
|
||||
|
|
@ -2555,12 +2424,6 @@ dependencies = [
|
|||
"version-compare",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "temp-dir"
|
||||
version = "0.1.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "af547b166dd1ea4b472165569fc456cfb6818116f854690b0ff205e636523dab"
|
||||
|
||||
[[package]]
|
||||
name = "tempfile"
|
||||
version = "3.3.0"
|
||||
|
|
@ -3058,7 +2921,7 @@ version = "2.4.1"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0c4583db5cbd4c4c0303df2d15af80f0539db703fa1c68802d4cbbd2dd0f88f6"
|
||||
dependencies = [
|
||||
"dirs 4.0.0",
|
||||
"dirs",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
[workspace]
|
||||
members = [
|
||||
"applets/cosmic-applet-audio",
|
||||
"applets/cosmic-applet-graphics",
|
||||
"applets/cosmic-applet-network",
|
||||
"applets/cosmic-applet-notifications",
|
||||
|
|
@ -13,5 +12,6 @@ members = [
|
|||
]
|
||||
|
||||
exclude = [
|
||||
"applets/cosmic-applet-audio",
|
||||
"applets/cosmic-applet-battery",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -5,18 +5,20 @@ edition = "2021"
|
|||
license = "GPL-3.0-or-later"
|
||||
|
||||
[dependencies]
|
||||
futures = "0.3.21"
|
||||
futures-util = "0.3.21"
|
||||
libcosmic-widgets = { git = "https://github.com/pop-os/libcosmic" }
|
||||
libcosmic-widgets = { git = "https://github.com/pop-os/libcosmic", branch = "relm4-next" }
|
||||
libpulse-binding = "2.26.0"
|
||||
pulsectl-rs = "0.3.2"
|
||||
libpulse-glib-binding = "2.25.0"
|
||||
tracker = "0.1.1"
|
||||
freedesktop-desktop-entry = "0.5.0"
|
||||
mpris2-zbus = { git = "https://github.com/pop-os/mpris2-zbus" }
|
||||
zbus = "2.1.1"
|
||||
tokio = { version = "1.17.0", features = ["full"] }
|
||||
relm4-macros = "0.4.4"
|
||||
relm4 = { git = "https://github.com/relm4/relm4", branch = "next", features = ["macros"] }
|
||||
relm4-macros = { git = "https://github.com/relm4/relm4", branch = "next" }
|
||||
once_cell = "1.10.0"
|
||||
gtk4 = { version = "0.4.7", features = ["v4_2"] }
|
||||
gtk4 = { git = "https://github.com/gtk-rs/gtk4-rs", features = ["v4_2"] }
|
||||
async-io = "1.6.0"
|
||||
|
||||
[features]
|
||||
|
|
|
|||
|
|
@ -2,17 +2,10 @@ use crate::icons::{parse_desktop_icons, DesktopApplication};
|
|||
use futures_util::StreamExt;
|
||||
use libcosmic_widgets::LabeledItem;
|
||||
use libpulse_binding::{
|
||||
context::subscribe::{Facility, InterestMaskSet, Operation},
|
||||
context::{subscribe::{Facility, InterestMaskSet, Operation}, State},
|
||||
volume::Volume,
|
||||
};
|
||||
use mpris2_zbus::media_player::MediaPlayer;
|
||||
use pulsectl::{
|
||||
controllers::{
|
||||
types::{ApplicationInfo, DeviceInfo},
|
||||
AppControl, DeviceControl, SinkController, SourceController,
|
||||
},
|
||||
Handler,
|
||||
};
|
||||
use relm4::{
|
||||
component,
|
||||
gtk::{
|
||||
|
|
@ -22,12 +15,14 @@ use relm4::{
|
|||
Align, Box as GtkBox, Button, Image, Label, ListBox, Orientation, PositionType, Revealer,
|
||||
RevealerTransitionType, Scale, Separator, Window,
|
||||
},
|
||||
view, ComponentParts, RelmContainerExt, Sender, SimpleComponent,
|
||||
view, ComponentParts, ComponentSender, RelmContainerExt, Sender, SimpleComponent, send,
|
||||
};
|
||||
use std::{collections::HashMap, rc::Rc};
|
||||
use tracker::track;
|
||||
use zbus::Connection;
|
||||
|
||||
use crate::pa::{DeviceInfo, PA};
|
||||
|
||||
pub enum AppInput {
|
||||
Inputs,
|
||||
Outputs,
|
||||
|
|
@ -59,7 +54,7 @@ pub struct App {
|
|||
#[do_not_track]
|
||||
desktop_icons: HashMap<DesktopApplication, String>,
|
||||
#[do_not_track]
|
||||
handler: Handler,
|
||||
pa: PA,
|
||||
}
|
||||
|
||||
impl Default for App {
|
||||
|
|
@ -74,15 +69,8 @@ impl Default for App {
|
|||
let outputs = output_controller.list_devices().unwrap_or_default();
|
||||
let now_playing = Vec::new();
|
||||
let desktop_icons = parse_desktop_icons();
|
||||
let handler = Handler::connect("com.system76.cosmic.applets.audio")
|
||||
.expect("failed to connect to pulse");
|
||||
relm4::spawn_local(clone!(@weak handler.mainloop as main_loop => async move {
|
||||
let mut timer = async_io::Timer::interval(std::time::Duration::from_millis(100));
|
||||
loop {
|
||||
main_loop.borrow_mut().iterate(false);
|
||||
timer.next().await;
|
||||
}
|
||||
}));
|
||||
// XXX handle no pulseaudio daemon?
|
||||
let pa = PA::new().unwrap();
|
||||
Self {
|
||||
default_input,
|
||||
inputs,
|
||||
|
|
@ -90,7 +78,7 @@ impl Default for App {
|
|||
outputs,
|
||||
now_playing,
|
||||
desktop_icons,
|
||||
handler,
|
||||
pa,
|
||||
tracker: 0,
|
||||
}
|
||||
}
|
||||
|
|
@ -142,23 +130,27 @@ impl App {
|
|||
}
|
||||
|
||||
fn subscribe_for_updates(&self, input: &Sender<AppInput>) {
|
||||
let mut context = self.handler.context.borrow_mut();
|
||||
let input_clone = input.clone();
|
||||
context.set_subscribe_callback(Some(Box::new(move |facility, operation, _idx| {
|
||||
self.pa.set_subscribe_callback(move |facility, operation, _idx| {
|
||||
if !matches!(operation, Some(Operation::Changed)) {
|
||||
return;
|
||||
}
|
||||
match facility {
|
||||
Some(Facility::Sink) => {
|
||||
send!(input_clone, AppInput::OutputVolume);
|
||||
input_clone.send(AppInput::OutputVolume);
|
||||
}
|
||||
Some(Facility::Source) => {
|
||||
send!(input_clone, AppInput::InputVolume);
|
||||
input_clone.send(AppInput::InputVolume);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
})));
|
||||
context.subscribe(InterestMaskSet::SINK | InterestMaskSet::SOURCE, |_| {});
|
||||
});
|
||||
self.pa.set_state_callback(move |pa, state| {
|
||||
if state == State::Ready {
|
||||
pa.subscribe(InterestMaskSet::SINK | InterestMaskSet::SOURCE);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn refresh_input_list(&mut self) {
|
||||
let mut input_controller =
|
||||
|
|
@ -277,7 +269,8 @@ impl App {
|
|||
append: media_buttons = &GtkBox {
|
||||
set_halign: Align::End,
|
||||
append: pause_button = &Button {
|
||||
set_child: pause_button_img = Some(&Image) {
|
||||
#[wrap(Some)]
|
||||
set_child: pause_button_img = &Image {
|
||||
set_icon_name: Some("media-playback-pause-symbolic"),
|
||||
set_pixel_size: 24,
|
||||
},
|
||||
|
|
@ -309,89 +302,95 @@ impl SimpleComponent for App {
|
|||
set_default_width: 400,
|
||||
set_default_height: 300,
|
||||
|
||||
&GtkBox {
|
||||
GtkBox {
|
||||
set_orientation: Orientation::Vertical,
|
||||
set_spacing: 24,
|
||||
&GtkBox {
|
||||
GtkBox {
|
||||
set_orientation: Orientation::Horizontal,
|
||||
set_spacing: 16,
|
||||
&Image {
|
||||
Image {
|
||||
set_icon_name: Some("audio-speakers-symbolic"),
|
||||
},
|
||||
append: output_volume = &Scale::with_range(Orientation::Horizontal, 0., 100., 1.) {
|
||||
set_format_value_func: |_, value| {
|
||||
format!("{:.0}%", value)
|
||||
},
|
||||
set_value: watch! { model.default_output.as_ref().map(|info| (info.volume.avg().0 as f64 / Volume::NORMAL.0 as f64) * 100.).unwrap_or(0.) },
|
||||
#[watch]
|
||||
set_value: model.default_output.as_ref().map(|info| (info.volume.avg().0 as f64 / Volume::NORMAL.0 as f64) * 100.).unwrap_or(0.),
|
||||
set_value_pos: PositionType::Right,
|
||||
set_hexpand: true
|
||||
}
|
||||
},
|
||||
&GtkBox {
|
||||
GtkBox {
|
||||
set_orientation: Orientation::Horizontal,
|
||||
set_spacing: 16,
|
||||
&Image {
|
||||
Image {
|
||||
set_icon_name: Some("audio-input-microphone-symbolic"),
|
||||
},
|
||||
append: input_volume = &Scale::with_range(Orientation::Horizontal, 0., 100., 1.) {
|
||||
set_format_value_func: |_, value| {
|
||||
format!("{:.0}%", value)
|
||||
},
|
||||
set_value: watch! {
|
||||
model.default_input
|
||||
.as_ref()
|
||||
.map(|info| (info.volume.avg().0 as f64 / Volume::NORMAL.0 as f64) * 100.)
|
||||
.unwrap_or(0.)
|
||||
},
|
||||
#[watch]
|
||||
set_value: model.default_input
|
||||
.as_ref()
|
||||
.map(|info| (info.volume.avg().0 as f64 / Volume::NORMAL.0 as f64) * 100.)
|
||||
.unwrap_or(0.),
|
||||
set_value_pos: PositionType::Right,
|
||||
set_hexpand: true
|
||||
}
|
||||
},
|
||||
&Separator {
|
||||
Separator {
|
||||
set_orientation: Orientation::Horizontal,
|
||||
},
|
||||
&GtkBox {
|
||||
GtkBox {
|
||||
set_orientation: Orientation::Vertical,
|
||||
&Button {
|
||||
set_child: current_output = Some(&Label) {
|
||||
set_text: watch! { model.get_default_output_name() }
|
||||
Button {
|
||||
#[wrap(Some)]
|
||||
set_child: current_output = &Label {
|
||||
#[watch]
|
||||
set_text: model.get_default_output_name()
|
||||
},
|
||||
connect_clicked(input, outputs_revealer) => move |_| {
|
||||
send!(input, AppInput::Outputs);
|
||||
connect_clicked[sender, outputs_revealer] => move |_| {
|
||||
sender.input(AppInput::Outputs);
|
||||
outputs_revealer.set_reveal_child(!outputs_revealer.reveals_child());
|
||||
}
|
||||
},
|
||||
append: outputs_revealer = &Revealer {
|
||||
set_transition_type: RevealerTransitionType::SlideDown,
|
||||
set_child: outputs = Some(&ListBox) {
|
||||
#[wrap(Some)]
|
||||
set_child: outputs = &ListBox {
|
||||
set_selection_mode: gtk::SelectionMode::None,
|
||||
set_activate_on_single_click: true
|
||||
}
|
||||
}
|
||||
},
|
||||
&Separator {
|
||||
Separator {
|
||||
set_orientation: Orientation::Horizontal,
|
||||
},
|
||||
&GtkBox {
|
||||
GtkBox {
|
||||
set_orientation: Orientation::Vertical,
|
||||
&Button {
|
||||
set_child: current_input = Some(&Label) {
|
||||
set_text: watch! { model.get_default_input_name() }
|
||||
Button {
|
||||
#[wrap(Some)]
|
||||
set_child: current_input = &Label {
|
||||
#[watch]
|
||||
set_text: model.get_default_input_name()
|
||||
},
|
||||
connect_clicked(input, inputs_revealer) => move |_| {
|
||||
send!(input, AppInput::Inputs);
|
||||
connect_clicked[sender, inputs_revealer] => move |_| {
|
||||
sender.input(AppInput::Inputs);
|
||||
inputs_revealer.set_reveal_child(!inputs_revealer.reveals_child());
|
||||
}
|
||||
},
|
||||
append: inputs_revealer = &Revealer {
|
||||
set_transition_type: RevealerTransitionType::SlideDown,
|
||||
set_child: inputs = Some(&ListBox) {
|
||||
#[wrap(Some)]
|
||||
set_child: inputs = &ListBox {
|
||||
set_selection_mode: gtk::SelectionMode::None,
|
||||
set_activate_on_single_click: true
|
||||
}
|
||||
}
|
||||
},
|
||||
&Separator {
|
||||
Separator {
|
||||
set_orientation: Orientation::Horizontal,
|
||||
},
|
||||
append: playing_apps = &ListBox {
|
||||
|
|
@ -401,15 +400,14 @@ impl SimpleComponent for App {
|
|||
}
|
||||
}
|
||||
|
||||
fn init_parts(
|
||||
fn init(
|
||||
_init_params: Self::InitParams,
|
||||
root: &Self::Root,
|
||||
input: &Sender<Self::Input>,
|
||||
_output: &Sender<Self::Output>,
|
||||
sender: &ComponentSender<Self>,
|
||||
) -> ComponentParts<Self> {
|
||||
let model = App::default();
|
||||
let widgets = view_output!();
|
||||
model.subscribe_for_updates(input);
|
||||
model.subscribe_for_updates(&sender.input);
|
||||
|
||||
ComponentParts { model, widgets }
|
||||
}
|
||||
|
|
@ -417,8 +415,7 @@ impl SimpleComponent for App {
|
|||
fn update(
|
||||
&mut self,
|
||||
msg: Self::Input,
|
||||
_input: &Sender<Self::Input>,
|
||||
_output: &Sender<Self::Output>,
|
||||
_sender: &ComponentSender<Self>,
|
||||
) {
|
||||
self.reset();
|
||||
match msg {
|
||||
|
|
|
|||
|
|
@ -1,19 +1,21 @@
|
|||
use gtk4::{prelude::*, Button, Label, ListBox};
|
||||
use gtk4::{glib::clone, prelude::*, Button, Label, ListBox};
|
||||
use libcosmic_widgets::{relm4::RelmContainerExt, LabeledItem};
|
||||
use pulsectl::controllers::{types::DeviceInfo, DeviceControl, SourceController};
|
||||
use std::rc::Rc;
|
||||
|
||||
fn get_inputs() -> Vec<DeviceInfo> {
|
||||
SourceController::create()
|
||||
.expect("failed to create input controller")
|
||||
.list_devices()
|
||||
use crate::pa::{DeviceInfo, PA};
|
||||
|
||||
pub async fn get_inputs(pa: &PA) -> Vec<DeviceInfo> {
|
||||
// XXX handle error
|
||||
pa.get_source_info_list()
|
||||
.await
|
||||
.expect("failed to list input devices")
|
||||
}
|
||||
|
||||
pub fn refresh_default_input(label: &Label) -> DeviceInfo {
|
||||
let default_input = SourceController::create()
|
||||
.expect("failed to create input controller")
|
||||
.get_default_device()
|
||||
pub async fn refresh_default_input(pa: &PA, label: &Label) -> DeviceInfo {
|
||||
// XXX handle error
|
||||
let default_input = pa
|
||||
.get_default_source()
|
||||
.await
|
||||
.expect("failed to get default input");
|
||||
label.set_text(match &default_input.description {
|
||||
Some(name) => name.as_str(),
|
||||
|
|
@ -22,12 +24,11 @@ pub fn refresh_default_input(label: &Label) -> DeviceInfo {
|
|||
default_input
|
||||
}
|
||||
|
||||
pub fn refresh_input_widgets(inputs: &ListBox) {
|
||||
pub async fn refresh_input_widgets(pa: &PA, inputs: &ListBox) {
|
||||
while let Some(row) = inputs.row_at_index(0) {
|
||||
inputs.remove(&row);
|
||||
}
|
||||
for input in get_inputs() {
|
||||
let input = Rc::new(input.clone());
|
||||
for input in get_inputs(pa).await {
|
||||
let name = match &input.name {
|
||||
Some(name) => name.to_owned(),
|
||||
None => continue, // Why doesn't this have a name? Whatever, it's invalid.
|
||||
|
|
@ -39,12 +40,9 @@ pub fn refresh_input_widgets(inputs: &ListBox) {
|
|||
.unwrap_or(&name),
|
||||
set_child: set_current_input_device = &Button {
|
||||
set_label: "Switch",
|
||||
connect_clicked: move |_| {
|
||||
SourceController::create()
|
||||
.expect("failed to create input controller")
|
||||
.set_default_device(&name)
|
||||
.expect("failed to set default device");
|
||||
}
|
||||
connect_clicked: clone!(@strong pa => move |_| {
|
||||
pa.set_default_source(&name);
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,9 +8,11 @@ mod input;
|
|||
mod now_playing;
|
||||
mod output;
|
||||
mod pa;
|
||||
use pa::PA;
|
||||
mod task;
|
||||
mod volume;
|
||||
|
||||
use futures::{channel::mpsc, stream::StreamExt};
|
||||
use gtk4::{
|
||||
gio::ApplicationFlags,
|
||||
glib::{self, clone, MainContext, PRIORITY_DEFAULT},
|
||||
|
|
@ -19,12 +21,14 @@ use gtk4::{
|
|||
Orientation, PositionType, Revealer, RevealerTransitionType, Scale, SelectionMode, Separator,
|
||||
};
|
||||
use libpulse_binding::{
|
||||
context::subscribe::{Facility, InterestMaskSet, Operation},
|
||||
context::{
|
||||
subscribe::{Facility, InterestMaskSet, Operation},
|
||||
FlagSet, State,
|
||||
},
|
||||
volume::Volume,
|
||||
};
|
||||
use mpris2_zbus::metadata::Metadata;
|
||||
use once_cell::sync::Lazy;
|
||||
use pulsectl::Handler;
|
||||
use tokio::runtime::Runtime;
|
||||
|
||||
static RT: Lazy<Runtime> = Lazy::new(|| Runtime::new().expect("failed to build tokio runtime"));
|
||||
|
|
@ -39,35 +43,38 @@ fn main() {
|
|||
}
|
||||
|
||||
fn app(application: &Application) {
|
||||
let handler =
|
||||
Handler::connect("com.system76.cosmic.applets.audio").expect("failed to connect to pulse");
|
||||
task::spawn_local(clone!(@strong handler.mainloop as main_loop => async move {
|
||||
pa::drive_main_loop(main_loop).await
|
||||
}));
|
||||
let (refresh_output_tx, refresh_output_rx) = MainContext::channel::<()>(PRIORITY_DEFAULT);
|
||||
let (refresh_input_tx, refresh_input_rx) = MainContext::channel::<()>(PRIORITY_DEFAULT);
|
||||
let (now_playing_tx, now_playing_rx) = MainContext::channel::<Vec<Metadata>>(PRIORITY_DEFAULT);
|
||||
handler
|
||||
.context
|
||||
.borrow_mut()
|
||||
.set_subscribe_callback(Some(Box::new(clone!(@strong refresh_output_tx, @strong refresh_input_tx => move |facility, operation, _idx| {
|
||||
// XXX handle no pulseaudio daemon?
|
||||
let pa = PA::new().unwrap();
|
||||
let (refresh_output_tx, mut refresh_output_rx) = mpsc::unbounded();
|
||||
let (refresh_input_tx, mut refresh_input_rx) = mpsc::unbounded();
|
||||
let (now_playing_tx, mut now_playing_rx) = mpsc::unbounded::<Vec<Metadata>>();
|
||||
pa
|
||||
.set_subscribe_callback(clone!(@strong refresh_output_tx, @strong refresh_input_tx => move |facility, operation, _idx| {
|
||||
if !matches!(operation, Some(Operation::Changed)) {
|
||||
return;
|
||||
}
|
||||
match facility {
|
||||
Some(Facility::Sink) => {
|
||||
refresh_output_tx.send(()).expect("failed to send output refresh message");
|
||||
refresh_output_tx.unbounded_send(()).expect("failed to send output refresh message");
|
||||
}
|
||||
Some(Facility::Source) => {
|
||||
refresh_input_tx.send(()).expect("failed to send output refresh message");
|
||||
refresh_input_tx.unbounded_send(()).expect("failed to send output refresh message");
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}))));
|
||||
handler
|
||||
.context
|
||||
.borrow_mut()
|
||||
.subscribe(InterestMaskSet::SINK | InterestMaskSet::SOURCE, |_| {});
|
||||
}));
|
||||
pa.set_state_callback(move |pa, state| {
|
||||
if state == State::Ready {
|
||||
pa.subscribe(InterestMaskSet::SINK | InterestMaskSet::SOURCE);
|
||||
refresh_output_tx
|
||||
.unbounded_send(())
|
||||
.expect("failed to send output refresh message");
|
||||
refresh_input_tx
|
||||
.unbounded_send(())
|
||||
.expect("failed to send output refresh message");
|
||||
}
|
||||
});
|
||||
pa.connect().unwrap(); // XXX unwrap
|
||||
view! {
|
||||
window = ApplicationWindow {
|
||||
set_application: Some(application),
|
||||
|
|
@ -75,7 +82,8 @@ fn app(application: &Application) {
|
|||
set_default_width: 400,
|
||||
set_default_height: 300,
|
||||
|
||||
set_child: window_box = Some(&GtkBox) {
|
||||
#[wrap(Some)]
|
||||
set_child: window_box = &GtkBox {
|
||||
set_orientation: Orientation::Vertical,
|
||||
set_spacing: 24,
|
||||
append: output_box = &GtkBox {
|
||||
|
|
@ -112,14 +120,16 @@ fn app(application: &Application) {
|
|||
append: output_list_box = &GtkBox {
|
||||
set_orientation: Orientation::Vertical,
|
||||
append: current_output_button = &Button {
|
||||
set_child: current_output = Some(&Label) {},
|
||||
connect_clicked(outputs_revealer) => move |_| {
|
||||
#[wrap(Some)]
|
||||
set_child: current_output = &Label {},
|
||||
connect_clicked[outputs_revealer] => move |_| {
|
||||
outputs_revealer.set_reveal_child(!outputs_revealer.reveals_child());
|
||||
}
|
||||
},
|
||||
append: outputs_revealer = &Revealer {
|
||||
set_transition_type: RevealerTransitionType::SlideDown,
|
||||
set_child: outputs = Some(&ListBox) {
|
||||
#[wrap(Some)]
|
||||
set_child: outputs = &ListBox {
|
||||
set_selection_mode: SelectionMode::None,
|
||||
set_activate_on_single_click: true
|
||||
}
|
||||
|
|
@ -131,14 +141,16 @@ fn app(application: &Application) {
|
|||
append: input_list_box = &GtkBox {
|
||||
set_orientation: Orientation::Vertical,
|
||||
append: current_input_button = &Button {
|
||||
set_child: current_input = Some(&Label) {},
|
||||
connect_clicked(inputs_revealer) => move |_| {
|
||||
#[wrap(Some)]
|
||||
set_child: current_input = &Label {},
|
||||
connect_clicked[inputs_revealer] => move |_| {
|
||||
inputs_revealer.set_reveal_child(!inputs_revealer.reveals_child());
|
||||
}
|
||||
},
|
||||
append: inputs_revealer = &Revealer {
|
||||
set_transition_type: RevealerTransitionType::SlideDown,
|
||||
set_child: inputs = Some(&ListBox) {
|
||||
#[wrap(Some)]
|
||||
set_child: inputs = &ListBox {
|
||||
set_selection_mode: SelectionMode::None,
|
||||
set_activate_on_single_click: true
|
||||
}
|
||||
|
|
@ -153,29 +165,27 @@ fn app(application: &Application) {
|
|||
}
|
||||
}
|
||||
}
|
||||
refresh_input_rx.attach(
|
||||
None,
|
||||
clone!(@weak inputs, @weak current_input, @weak input_volume => @default-return Continue(true), move |_| {
|
||||
input::refresh_input_widgets(&inputs);
|
||||
let default_input = input::refresh_default_input(¤t_input);
|
||||
volume::update_volume(&default_input, &input_volume);
|
||||
Continue(true)
|
||||
glib::MainContext::default().spawn_local(
|
||||
clone!(@weak inputs, @weak current_input, @weak input_volume, @strong pa => async move {
|
||||
while let Some(()) = refresh_input_rx.next().await {
|
||||
input::refresh_input_widgets(&pa, &inputs).await;
|
||||
let default_input = input::refresh_default_input(&pa, ¤t_input).await;
|
||||
volume::update_volume(&default_input, &input_volume);
|
||||
}
|
||||
}),
|
||||
);
|
||||
refresh_output_rx.attach(
|
||||
None,
|
||||
clone!(@weak outputs, @weak current_output, @weak output_volume => @default-return Continue(true), move |_| {
|
||||
output::refresh_output_widgets(&outputs);
|
||||
let default_output = output::refresh_default_output(¤t_output);
|
||||
volume::update_volume(&default_output, &output_volume);
|
||||
Continue(true)
|
||||
}),
|
||||
);
|
||||
now_playing_rx.attach(
|
||||
None,
|
||||
clone!(@weak playing_apps => @default-return Continue(true), move |all_metadata| {
|
||||
Continue(true)
|
||||
glib::MainContext::default().spawn_local(
|
||||
clone!(@weak outputs, @weak current_output, @weak output_volume, @strong pa => async move {
|
||||
while let Some(()) = refresh_output_rx.next().await {
|
||||
output::refresh_output_widgets(&pa, &outputs);
|
||||
let default_output = output::refresh_default_output(&pa, ¤t_output).await;
|
||||
volume::update_volume(&default_output, &output_volume);
|
||||
}
|
||||
}),
|
||||
);
|
||||
glib::MainContext::default().spawn_local(clone!(@weak playing_apps => async move {
|
||||
while let Some(all_metadata) = now_playing_rx.next().await {
|
||||
}
|
||||
}));
|
||||
window.show();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,19 +1,21 @@
|
|||
use gtk4::{prelude::*, Button, Label, ListBox};
|
||||
use gtk4::{glib::clone, prelude::*, Button, Label, ListBox};
|
||||
use libcosmic_widgets::{relm4::RelmContainerExt, LabeledItem};
|
||||
use pulsectl::controllers::{types::DeviceInfo, DeviceControl, SinkController};
|
||||
use std::rc::Rc;
|
||||
|
||||
fn get_outputs() -> Vec<DeviceInfo> {
|
||||
SinkController::create()
|
||||
.expect("failed to create output controller")
|
||||
.list_devices()
|
||||
use crate::pa::{DeviceInfo, PA};
|
||||
|
||||
pub async fn get_outputs(pa: &PA) -> Vec<DeviceInfo> {
|
||||
// XXX handle error
|
||||
pa.get_sink_info_list()
|
||||
.await
|
||||
.expect("failed to list output devices")
|
||||
}
|
||||
|
||||
pub fn refresh_default_output(label: &Label) -> DeviceInfo {
|
||||
let default_output = SinkController::create()
|
||||
.expect("failed to create output controller")
|
||||
.get_default_device()
|
||||
pub async fn refresh_default_output(pa: &PA, label: &Label) -> DeviceInfo {
|
||||
// XXX handle error
|
||||
let default_output = pa
|
||||
.get_default_sink()
|
||||
.await
|
||||
.expect("failed to get default output");
|
||||
label.set_text(match &default_output.description {
|
||||
Some(name) => name.as_str(),
|
||||
|
|
@ -22,12 +24,11 @@ pub fn refresh_default_output(label: &Label) -> DeviceInfo {
|
|||
default_output
|
||||
}
|
||||
|
||||
pub fn refresh_output_widgets(outputs: &ListBox) {
|
||||
pub async fn refresh_output_widgets(pa: &PA, outputs: &ListBox) {
|
||||
while let Some(row) = outputs.row_at_index(0) {
|
||||
outputs.remove(&row);
|
||||
}
|
||||
for output in get_outputs() {
|
||||
let output = Rc::new(output.clone());
|
||||
for output in get_outputs(pa).await {
|
||||
let name = match &output.name {
|
||||
Some(name) => name.to_owned(),
|
||||
None => continue, // Why doesn't this have a name? Whatever, it's invalid.
|
||||
|
|
@ -39,12 +40,9 @@ pub fn refresh_output_widgets(outputs: &ListBox) {
|
|||
.unwrap_or(&name),
|
||||
set_child: set_current_input_device = &Button {
|
||||
set_label: "Switch",
|
||||
connect_clicked: move |_| {
|
||||
SinkController::create()
|
||||
.expect("failed to create output controller")
|
||||
.set_default_device(&name)
|
||||
.expect("failed to set default device");
|
||||
}
|
||||
connect_clicked: clone!(@strong pa => move |_| {
|
||||
pa.set_default_sink(&name);
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,12 +1,210 @@
|
|||
use async_io::Timer;
|
||||
use futures_util::StreamExt;
|
||||
use libpulse_binding::mainloop::standard::Mainloop;
|
||||
use std::{cell::RefCell, rc::Rc, time::Duration};
|
||||
use futures::{channel::oneshot, future::poll_fn, task::Poll};
|
||||
use libpulse_binding::{
|
||||
callbacks::ListResult,
|
||||
context::{
|
||||
introspect::{Introspector, SinkInfo},
|
||||
subscribe::{Facility, InterestMaskSet, Operation},
|
||||
Context, FlagSet, State,
|
||||
},
|
||||
error::PAErr,
|
||||
volume::ChannelVolumes,
|
||||
};
|
||||
use libpulse_glib_binding::Mainloop;
|
||||
use std::{
|
||||
cell::{Ref, RefCell},
|
||||
rc::Rc,
|
||||
};
|
||||
|
||||
pub async fn drive_main_loop(main_loop: Rc<RefCell<Mainloop>>) {
|
||||
let mut timer = Timer::interval(Duration::from_millis(100));
|
||||
loop {
|
||||
main_loop.borrow_mut().iterate(false);
|
||||
timer.next().await;
|
||||
pub struct DeviceInfo {
|
||||
pub name: Option<String>,
|
||||
pub description: Option<String>,
|
||||
pub volume: ChannelVolumes,
|
||||
pub index: u32,
|
||||
}
|
||||
|
||||
pub struct ServerInfo {
|
||||
pub default_sink_name: Option<String>,
|
||||
pub default_source_name: Option<String>,
|
||||
}
|
||||
|
||||
struct PAInner {
|
||||
main_loop: Mainloop,
|
||||
pub context: RefCell<Context>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct PA(Rc<PAInner>);
|
||||
|
||||
impl PA {
|
||||
pub fn new() -> Option<Self> {
|
||||
let main_loop = Mainloop::new(None)?;
|
||||
let context = Context::new(&main_loop, "com.system76.cosmic.applets.audio")?;
|
||||
Some(Self(Rc::new(PAInner {
|
||||
main_loop,
|
||||
context: RefCell::new(context),
|
||||
})))
|
||||
}
|
||||
|
||||
pub fn set_state_callback<F: Fn(&Self, State) + 'static>(&self, cb: F) {
|
||||
let pa = self.clone(); // TODO: weak ref?
|
||||
self.0
|
||||
.context
|
||||
.borrow_mut()
|
||||
.set_state_callback(Some(Box::new(move || {
|
||||
let state = pa.0.context.borrow().get_state();
|
||||
cb(&pa, state);
|
||||
})));
|
||||
}
|
||||
|
||||
// TODO: builder pattern?
|
||||
pub fn set_subscribe_callback<F: FnMut(Option<Facility>, Option<Operation>, u32) + 'static>(
|
||||
&self,
|
||||
cb: F,
|
||||
) {
|
||||
self.0
|
||||
.context
|
||||
.borrow_mut()
|
||||
.set_subscribe_callback(Some(Box::new(cb)));
|
||||
}
|
||||
|
||||
pub fn subscribe(&self, mask: InterestMaskSet) {
|
||||
// XXX cb; operation; async
|
||||
self.0.context.borrow_mut().subscribe(mask, |_| {});
|
||||
}
|
||||
|
||||
pub fn connect(&self) -> Result<(), PAErr> {
|
||||
self.0
|
||||
.context
|
||||
.borrow_mut()
|
||||
.connect(None, FlagSet::empty(), None)
|
||||
}
|
||||
|
||||
fn introspect(&self) -> Introspector {
|
||||
self.0.context.borrow().introspect()
|
||||
}
|
||||
|
||||
pub async fn get_server_info(&self) -> ServerInfo {
|
||||
let (sender, receiver) = oneshot::channel();
|
||||
let mut sender = Some(sender);
|
||||
self.introspect().get_server_info(move |info| {
|
||||
sender.take().unwrap().send(ServerInfo {
|
||||
default_sink_name: info.default_sink_name.clone().map(|x| x.into_owned()),
|
||||
default_source_name: info.default_source_name.clone().map(|x| x.into_owned()),
|
||||
});
|
||||
});
|
||||
receiver.await.unwrap()
|
||||
}
|
||||
|
||||
pub async fn get_sink_info_list(&self) -> Result<Vec<DeviceInfo>, ()> {
|
||||
let (sender, receiver) = oneshot::channel();
|
||||
let mut sender = Some(sender);
|
||||
let mut items = Some(Vec::new());
|
||||
self.introspect()
|
||||
.get_sink_info_list(move |result| match result {
|
||||
ListResult::Item(item) => items.as_mut().unwrap().push(DeviceInfo {
|
||||
name: item.name.clone().map(|x| x.into_owned()),
|
||||
description: item.description.clone().map(|x| x.into_owned()),
|
||||
volume: item.volume,
|
||||
index: item.index,
|
||||
}),
|
||||
ListResult::End => {
|
||||
sender.take().unwrap().send(Ok(items.take().unwrap()));
|
||||
}
|
||||
ListResult::Error => {
|
||||
sender.take().unwrap().send(Err(()));
|
||||
}
|
||||
});
|
||||
receiver.await.unwrap()
|
||||
}
|
||||
|
||||
pub async fn get_default_sink(&self) -> Result<DeviceInfo, ()> {
|
||||
let name = match self.get_server_info().await.default_sink_name {
|
||||
Some(name) => name,
|
||||
None => {
|
||||
return Err(());
|
||||
}
|
||||
};
|
||||
let (sender, receiver) = oneshot::channel();
|
||||
let mut sender = Some(sender);
|
||||
let mut sink = None;
|
||||
self.introspect()
|
||||
.get_sink_info_by_name(&name, move |result| match result {
|
||||
ListResult::Item(item) => {
|
||||
sink = Some(DeviceInfo {
|
||||
name: item.name.clone().map(|x| x.into_owned()),
|
||||
description: item.description.clone().map(|x| x.into_owned()),
|
||||
volume: item.volume,
|
||||
index: item.index,
|
||||
});
|
||||
}
|
||||
ListResult::End => {
|
||||
sender.take().unwrap().send(sink.take().ok_or(()));
|
||||
}
|
||||
ListResult::Error => {
|
||||
sender.take().unwrap().send(Err(()));
|
||||
}
|
||||
});
|
||||
receiver.await.unwrap()
|
||||
}
|
||||
|
||||
// XXX async wait and handle error
|
||||
pub fn set_default_sink(&self, name: &str) {
|
||||
self.0.context.borrow_mut().set_default_sink(name, |_| {});
|
||||
}
|
||||
|
||||
pub fn set_default_source(&self, name: &str) {
|
||||
self.0.context.borrow_mut().set_default_source(name, |_| {});
|
||||
}
|
||||
|
||||
pub async fn get_source_info_list(&self) -> Result<Vec<DeviceInfo>, ()> {
|
||||
let (sender, receiver) = oneshot::channel();
|
||||
let mut sender = Some(sender);
|
||||
let mut items = Some(Vec::new());
|
||||
self.introspect()
|
||||
.get_source_info_list(move |result| match result {
|
||||
ListResult::Item(item) => items.as_mut().unwrap().push(DeviceInfo {
|
||||
name: item.name.clone().map(|x| x.into_owned()),
|
||||
description: item.description.clone().map(|x| x.into_owned()),
|
||||
volume: item.volume,
|
||||
index: item.index,
|
||||
}),
|
||||
ListResult::End => {
|
||||
sender.take().unwrap().send(Ok(items.take().unwrap()));
|
||||
}
|
||||
ListResult::Error => {
|
||||
sender.take().unwrap().send(Err(()));
|
||||
}
|
||||
});
|
||||
receiver.await.unwrap()
|
||||
}
|
||||
|
||||
pub async fn get_default_source(&self) -> Result<DeviceInfo, ()> {
|
||||
let name = match self.get_server_info().await.default_source_name {
|
||||
Some(name) => name,
|
||||
None => {
|
||||
return Err(());
|
||||
}
|
||||
};
|
||||
let (sender, receiver) = oneshot::channel();
|
||||
let mut sender = Some(sender);
|
||||
let mut source = None;
|
||||
self.introspect()
|
||||
.get_source_info_by_name(&name, move |result| match result {
|
||||
ListResult::Item(item) => {
|
||||
source = Some(DeviceInfo {
|
||||
name: item.name.clone().map(|x| x.into_owned()),
|
||||
description: item.description.clone().map(|x| x.into_owned()),
|
||||
volume: item.volume,
|
||||
index: item.index,
|
||||
});
|
||||
}
|
||||
ListResult::End => {
|
||||
sender.take().unwrap().send(source.take().ok_or(()));
|
||||
}
|
||||
ListResult::Error => {
|
||||
sender.take().unwrap().send(Err(()));
|
||||
}
|
||||
});
|
||||
receiver.await.unwrap()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
use gtk4::{prelude::*, Scale};
|
||||
use libpulse_binding::volume::Volume;
|
||||
use pulsectl::controllers::types::DeviceInfo;
|
||||
|
||||
use crate::pa::DeviceInfo;
|
||||
|
||||
pub fn update_volume(device: &DeviceInfo, scale: &Scale) {
|
||||
scale.set_value((device.volume.avg().0 as f64 / Volume::NORMAL.0 as f64) * 100.);
|
||||
|
|
|
|||
3
debian/rules
vendored
3
debian/rules
vendored
|
|
@ -13,12 +13,13 @@ override_dh_shlibdeps:
|
|||
override_dh_auto_clean:
|
||||
if test "${CLEAN}" = "1"; then \
|
||||
cargo clean; \
|
||||
cargo clean --manifest-path applets/cosmic-applet-audio/Cargo.toml; \
|
||||
cargo clean --manifest-path applets/cosmic-applet-battery/Cargo.toml; \
|
||||
fi
|
||||
|
||||
if ! ischroot && test "${VENDOR}" = "1"; then \
|
||||
mkdir -p .cargo; \
|
||||
cargo vendor --sync Cargo.toml --sync applets/cosmic-applet-battery/Cargo.toml | head -n -1 > .cargo/config; \
|
||||
cargo vendor --sync Cargo.toml --sync applets/cosmic-applet-audio/Cargo.toml applets/cosmic-applet-battery/Cargo.toml | head -n -1 > .cargo/config; \
|
||||
echo 'directory = "vendor"' >> .cargo/config; \
|
||||
tar pcf vendor.tar vendor; \
|
||||
rm -rf vendor; \
|
||||
|
|
|
|||
3
justfile
3
justfile
|
|
@ -28,6 +28,7 @@ workspaces_button_id := 'com.system76.CosmicPanelWorkspacesButton'
|
|||
|
||||
all: _extract_vendor
|
||||
cargo build {{cargo_args}}
|
||||
cargo build --manifest-path applets/cosmic-applet-audio/Cargo.toml {{cargo_args}}
|
||||
cargo build --manifest-path applets/cosmic-applet-battery/Cargo.toml {{cargo_args}}
|
||||
|
||||
# Installs files into the system
|
||||
|
|
@ -42,7 +43,7 @@ install:
|
|||
# audio
|
||||
install -Dm0644 applets/cosmic-applet-audio/data/icons/{{audio_id}}.svg {{iconsdir}}/{{audio_id}}.svg
|
||||
install -Dm0644 applets/cosmic-applet-audio/data/{{audio_id}}.desktop {{sharedir}}/applications/{{audio_id}}.desktop
|
||||
install -Dm0755 target/release/cosmic-applet-audio {{bindir}}/cosmic-applet-audio
|
||||
install -Dm0755 applets/cosmic-applet-audio/target/release/cosmic-applet-audio {{bindir}}/cosmic-applet-audio
|
||||
|
||||
# battery
|
||||
install -Dm0644 applets/cosmic-applet-battery/data/icons/{{battery_id}}.svg {{iconsdir}}/{{battery_id}}.svg
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue