feat(sound): per-application volume control (#2138)
This commit is contained in:
parent
209ea08f03
commit
6b0164ddaa
10 changed files with 600 additions and 38 deletions
30
Cargo.lock
generated
30
Cargo.lock
generated
|
|
@ -899,7 +899,7 @@ dependencies = [
|
|||
[[package]]
|
||||
name = "build_helpers"
|
||||
version = "0.14.0"
|
||||
source = "git+https://github.com/pop-os/libcosmic#1f6dc991eaa5115a09be2505cc8a323e5b2b0bff"
|
||||
source = "git+https://github.com/pop-os/libcosmic#1f8d8786940cc6db59d412153c5a03f69a20375c"
|
||||
dependencies = [
|
||||
"cfg_aliases 0.2.1",
|
||||
]
|
||||
|
|
@ -1393,7 +1393,7 @@ dependencies = [
|
|||
[[package]]
|
||||
name = "cosmic-config"
|
||||
version = "1.0.0"
|
||||
source = "git+https://github.com/pop-os/libcosmic#1f6dc991eaa5115a09be2505cc8a323e5b2b0bff"
|
||||
source = "git+https://github.com/pop-os/libcosmic#1f8d8786940cc6db59d412153c5a03f69a20375c"
|
||||
dependencies = [
|
||||
"atomicwrites",
|
||||
"cosmic-config-derive",
|
||||
|
|
@ -1414,7 +1414,7 @@ dependencies = [
|
|||
[[package]]
|
||||
name = "cosmic-config-derive"
|
||||
version = "1.0.0"
|
||||
source = "git+https://github.com/pop-os/libcosmic#1f6dc991eaa5115a09be2505cc8a323e5b2b0bff"
|
||||
source = "git+https://github.com/pop-os/libcosmic#1f8d8786940cc6db59d412153c5a03f69a20375c"
|
||||
dependencies = [
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
|
|
@ -1642,7 +1642,7 @@ dependencies = [
|
|||
[[package]]
|
||||
name = "cosmic-settings-audio-client"
|
||||
version = "1.0.0"
|
||||
source = "git+https://github.com/pop-os/cosmic-settings-daemon#21a9692b53fcbffa0f18f7d0a12bf0f9d5bd0590"
|
||||
source = "git+https://github.com/pop-os/cosmic-settings-daemon#e4d2a74f5115091940f6ea09f730863f74f34758"
|
||||
dependencies = [
|
||||
"cosmic-settings-audio-core",
|
||||
"dirs",
|
||||
|
|
@ -1658,7 +1658,7 @@ dependencies = [
|
|||
[[package]]
|
||||
name = "cosmic-settings-audio-core"
|
||||
version = "1.0.0"
|
||||
source = "git+https://github.com/pop-os/cosmic-settings-daemon#21a9692b53fcbffa0f18f7d0a12bf0f9d5bd0590"
|
||||
source = "git+https://github.com/pop-os/cosmic-settings-daemon#e4d2a74f5115091940f6ea09f730863f74f34758"
|
||||
dependencies = [
|
||||
"dirs",
|
||||
"ron 0.12.2",
|
||||
|
|
@ -1681,7 +1681,7 @@ dependencies = [
|
|||
[[package]]
|
||||
name = "cosmic-settings-config"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/pop-os/cosmic-settings-daemon#21a9692b53fcbffa0f18f7d0a12bf0f9d5bd0590"
|
||||
source = "git+https://github.com/pop-os/cosmic-settings-daemon#e4d2a74f5115091940f6ea09f730863f74f34758"
|
||||
dependencies = [
|
||||
"cosmic-config",
|
||||
"ron 0.11.0",
|
||||
|
|
@ -1702,7 +1702,7 @@ dependencies = [
|
|||
[[package]]
|
||||
name = "cosmic-settings-daemon-config"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/pop-os/cosmic-settings-daemon#21a9692b53fcbffa0f18f7d0a12bf0f9d5bd0590"
|
||||
source = "git+https://github.com/pop-os/cosmic-settings-daemon#e4d2a74f5115091940f6ea09f730863f74f34758"
|
||||
dependencies = [
|
||||
"cosmic-config",
|
||||
"serde",
|
||||
|
|
@ -2349,7 +2349,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -3266,7 +3266,7 @@ dependencies = [
|
|||
[[package]]
|
||||
name = "iced_accessibility"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/pop-os/libcosmic#1f6dc991eaa5115a09be2505cc8a323e5b2b0bff"
|
||||
source = "git+https://github.com/pop-os/libcosmic#1f8d8786940cc6db59d412153c5a03f69a20375c"
|
||||
dependencies = [
|
||||
"accesskit",
|
||||
"accesskit_winit",
|
||||
|
|
@ -3275,7 +3275,7 @@ dependencies = [
|
|||
[[package]]
|
||||
name = "iced_core"
|
||||
version = "0.14.0"
|
||||
source = "git+https://github.com/pop-os/libcosmic#1f6dc991eaa5115a09be2505cc8a323e5b2b0bff"
|
||||
source = "git+https://github.com/pop-os/libcosmic#1f8d8786940cc6db59d412153c5a03f69a20375c"
|
||||
dependencies = [
|
||||
"bitflags 2.13.1",
|
||||
"build_helpers",
|
||||
|
|
@ -3312,7 +3312,7 @@ dependencies = [
|
|||
[[package]]
|
||||
name = "iced_futures"
|
||||
version = "0.14.0"
|
||||
source = "git+https://github.com/pop-os/libcosmic#1f6dc991eaa5115a09be2505cc8a323e5b2b0bff"
|
||||
source = "git+https://github.com/pop-os/libcosmic#1f8d8786940cc6db59d412153c5a03f69a20375c"
|
||||
dependencies = [
|
||||
"futures",
|
||||
"iced_core",
|
||||
|
|
@ -6473,7 +6473,7 @@ dependencies = [
|
|||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys 0.4.15",
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -6486,7 +6486,7 @@ dependencies = [
|
|||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys 0.12.1",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -7209,7 +7209,7 @@ dependencies = [
|
|||
"getrandom 0.4.3",
|
||||
"once_cell",
|
||||
"rustix 1.1.4",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -8272,7 +8272,7 @@ version = "0.1.11"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
||||
dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
|
|||
|
|
@ -52,6 +52,9 @@ This document provides a regression testing checklist for COSMIC Settings and th
|
|||
- [ ] Output balance slider works & doesn't get reset by volume slider
|
||||
- [ ] Amplification toggles work as expected
|
||||
- [ ] Device profiles can be changed & reflect as expected on the main Sound page
|
||||
- [ ] Applications using sound appear on the Applications sub-page
|
||||
- [ ] Application volume and mute controls only affect the selected playback stream
|
||||
- [ ] Closing an playback stream removes it from the Applications sub-page
|
||||
|
||||
### Power & Battery
|
||||
|
||||
|
|
|
|||
|
|
@ -578,6 +578,13 @@ impl cosmic::Application for SettingsApp {
|
|||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "page-sound")]
|
||||
crate::pages::Message::SoundApplications(message) => {
|
||||
if let Some(page) = self.pages.page_mut::<sound::applications::Page>() {
|
||||
return page.update(message).map(Into::into);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "page-sound")]
|
||||
crate::pages::Message::SoundDeviceProfiles(message) => {
|
||||
if let Some(page) = self.pages.page_mut::<sound::device_profiles::Page>() {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
use cosmic::app::ContextDrawer;
|
||||
use cosmic::iced::{Alignment, Length};
|
||||
use cosmic::widget::text_input::focus;
|
||||
use cosmic::widget::{Id, button, icon, settings, text};
|
||||
use cosmic::widget::{button, icon, settings, text};
|
||||
use cosmic::{Apply, Element, Task, task, widget};
|
||||
use cosmic_settings_page::section::Entity;
|
||||
use cosmic_settings_page::{self as page, Content, Info, Section};
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ use std::sync::{Arc, LazyLock};
|
|||
use cosmic::config::{CosmicTk, FontConfig};
|
||||
use cosmic::{Apply, Element, Task, widget};
|
||||
use cosmic_config::ConfigSet;
|
||||
use mime::FONT;
|
||||
|
||||
use crate::app;
|
||||
use crate::widget::selection_context_item;
|
||||
|
|
|
|||
|
|
@ -87,6 +87,8 @@ pub enum Message {
|
|||
#[cfg(feature = "page-sound")]
|
||||
Sound(sound::Message),
|
||||
#[cfg(feature = "page-sound")]
|
||||
SoundApplications(sound::applications::Message),
|
||||
#[cfg(feature = "page-sound")]
|
||||
SoundDeviceProfiles(sound::device_profiles::Message),
|
||||
StartupApps(applications::startup_apps::Message),
|
||||
#[cfg(feature = "page-users")]
|
||||
|
|
|
|||
465
cosmic-settings/src/pages/sound/applications.rs
Normal file
465
cosmic-settings/src/pages/sound/applications.rs
Normal file
|
|
@ -0,0 +1,465 @@
|
|||
// Copyright 2026 System76 <info@system76.com>
|
||||
// SPDX-License-Identifier: GPL-3.0-only
|
||||
|
||||
use super::model;
|
||||
use cosmic::desktop::{
|
||||
DesktopEntryCache, DesktopLookupContext, DesktopResolveOptions, resolve_desktop_entry,
|
||||
};
|
||||
use cosmic::iced::futures;
|
||||
use cosmic::iced::{self, Alignment, Length, window};
|
||||
use cosmic::widget::space::horizontal as horizontal_space;
|
||||
use cosmic::{Apply, Element, surface, widget};
|
||||
use cosmic_config::{Config, ConfigGet};
|
||||
use cosmic_settings_audio_client::{self as audio_client, CosmicAudioProxy};
|
||||
use cosmic_settings_page::{self as page, Section, section};
|
||||
use futures::executor::block_on;
|
||||
use intmap::IntMap;
|
||||
use slotmap::SlotMap;
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
use std::sync::Arc;
|
||||
|
||||
const AUDIO_CONFIG: &str = "com.system76.CosmicAudio";
|
||||
const AMPLIFICATION_SINK: &str = "amplification_sink";
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum Message {
|
||||
Model(cosmic_settings_sound::Message),
|
||||
SetBalance(u32, f32),
|
||||
SetMute(u32, bool),
|
||||
SetSink(u32, usize),
|
||||
SetVolume(u32, u32),
|
||||
Surface(surface::Action),
|
||||
ToggleExpanded(u32),
|
||||
}
|
||||
|
||||
impl From<Message> for crate::pages::Message {
|
||||
fn from(message: Message) -> Self {
|
||||
crate::pages::Message::SoundApplications(message)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Message> for crate::Message {
|
||||
fn from(message: Message) -> Self {
|
||||
crate::Message::PageMessage(message.into())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct Page {
|
||||
entity: page::Entity,
|
||||
model: model::Model,
|
||||
client: Option<Rc<RefCell<audio_client::Client>>>,
|
||||
desktop_entries: DesktopEntryCache,
|
||||
playback_icons: IntMap<u32, String>,
|
||||
amplification_sink: bool,
|
||||
expanded: Option<u32>,
|
||||
}
|
||||
|
||||
impl page::AutoBind<crate::pages::Message> for Page {}
|
||||
|
||||
impl page::Page<crate::pages::Message> for Page {
|
||||
fn on_enter(&mut self) -> cosmic::Task<crate::pages::Message> {
|
||||
self.amplification_sink = Config::new(AUDIO_CONFIG, 1)
|
||||
.ok()
|
||||
.and_then(|config| config.get::<bool>(AMPLIFICATION_SINK).ok())
|
||||
.unwrap_or(true);
|
||||
cosmic::Task::none()
|
||||
}
|
||||
|
||||
fn info(&self) -> page::Info {
|
||||
page::Info::new("sound-applications", "preferences-sound-symbolic")
|
||||
.title(fl!("sound-applications"))
|
||||
}
|
||||
|
||||
fn content(
|
||||
&self,
|
||||
sections: &mut SlotMap<section::Entity, Section<crate::pages::Message>>,
|
||||
) -> Option<page::Content> {
|
||||
Some(vec![sections.insert(view())])
|
||||
}
|
||||
|
||||
fn set_id(&mut self, entity: page::Entity) {
|
||||
self.entity = entity;
|
||||
}
|
||||
|
||||
fn on_leave(&mut self) -> cosmic::Task<crate::pages::Message> {
|
||||
*self = Page {
|
||||
entity: self.entity,
|
||||
..Page::default()
|
||||
};
|
||||
cosmic::Task::none()
|
||||
}
|
||||
|
||||
fn subscription(&self, _core: &cosmic::Core) -> iced::Subscription<crate::pages::Message> {
|
||||
iced::Subscription::run(|| {
|
||||
iced::stream::channel(
|
||||
1,
|
||||
move |emitter: futures::channel::mpsc::Sender<crate::pages::Message>| async move {
|
||||
cosmic_settings_sound::subscribe(emitter, |m| Message::Model(m).into()).await
|
||||
},
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Page {
|
||||
fn resolve_playback_icon(&mut self, node_id: u32, info: &audio_client::PlaybackInfo) {
|
||||
let Some(candidate) = [
|
||||
info.application_id.as_deref(),
|
||||
info.icon_name.as_deref(),
|
||||
info.application_name.as_deref(),
|
||||
]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.find(|value| !value.is_empty()) else {
|
||||
return;
|
||||
};
|
||||
|
||||
let mut context = DesktopLookupContext::new(candidate);
|
||||
if let Some(icon_name) = info.icon_name.as_deref().filter(|value| !value.is_empty()) {
|
||||
context = context.with_identifier(icon_name);
|
||||
}
|
||||
if let Some(application_name) = info
|
||||
.application_name
|
||||
.as_deref()
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
context = context.with_title(application_name);
|
||||
}
|
||||
|
||||
let entry = resolve_desktop_entry(
|
||||
&mut self.desktop_entries,
|
||||
&context,
|
||||
&DesktopResolveOptions::default(),
|
||||
);
|
||||
let entry_icon = entry.icon();
|
||||
let icon_name = entry_icon
|
||||
.filter(|value| !value.is_empty())
|
||||
.or(info.icon_name.as_deref())
|
||||
.or(info.application_id.as_deref())
|
||||
.unwrap_or("application-default");
|
||||
|
||||
self.playback_icons.insert(node_id, icon_name.to_owned());
|
||||
}
|
||||
|
||||
pub fn update(&mut self, message: Message) -> cosmic::Task<crate::app::Message> {
|
||||
match message {
|
||||
Message::Model(cosmic_settings_sound::Message::Subscription(message)) => {
|
||||
match &message {
|
||||
audio_client::Event::Playback(node_id, info) => {
|
||||
self.resolve_playback_icon(*node_id, info);
|
||||
}
|
||||
audio_client::Event::RemoveNode(node_id) => {
|
||||
self.playback_icons.remove(*node_id);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
self.model.update(message);
|
||||
}
|
||||
|
||||
Message::Model(cosmic_settings_sound::Message::Client(client)) => {
|
||||
if let Some(client) = Arc::into_inner(client) {
|
||||
self.client = Some(Rc::new(RefCell::new(client)));
|
||||
self.model = model::Model::default();
|
||||
self.playback_icons = IntMap::new();
|
||||
}
|
||||
}
|
||||
|
||||
Message::SetMute(node_id, mute) => {
|
||||
self.model
|
||||
.update(audio_client::Event::NodeMute(node_id, mute));
|
||||
if let Some(client) = &self.client {
|
||||
match block_on(client.borrow_mut().conn.set_node_mute(node_id, mute)) {
|
||||
Ok(Ok(_)) => {}
|
||||
Ok(Err(why)) => {
|
||||
tracing::error!(?why, node_id, mute, "daemon rejected playback mute");
|
||||
}
|
||||
Err(why) => {
|
||||
tracing::error!(?why, node_id, mute, "failed to set playback mute");
|
||||
}
|
||||
}
|
||||
} else {
|
||||
tracing::warn!(node_id, mute, "audio client is not connected");
|
||||
}
|
||||
}
|
||||
|
||||
Message::SetBalance(playback_id, balance) => {
|
||||
if let Some(playback) = self
|
||||
.model
|
||||
.playback
|
||||
.iter_mut()
|
||||
.find(|playback| playback.id == playback_id)
|
||||
{
|
||||
playback.balance = Some(balance);
|
||||
}
|
||||
|
||||
if let Some(client) = &self.client {
|
||||
match block_on(
|
||||
client
|
||||
.borrow_mut()
|
||||
.conn
|
||||
.set_node_volume_balance(playback_id, Some(balance)),
|
||||
) {
|
||||
Ok(Ok(volume)) => {
|
||||
self.model.update(audio_client::Event::NodeVolume(
|
||||
playback_id,
|
||||
volume.volume,
|
||||
volume.balance,
|
||||
));
|
||||
}
|
||||
Ok(Err(why)) => {
|
||||
tracing::error!(
|
||||
?why,
|
||||
playback_id,
|
||||
balance,
|
||||
"daemon rejected playback balance"
|
||||
);
|
||||
}
|
||||
Err(why) => {
|
||||
tracing::error!(
|
||||
?why,
|
||||
playback_id,
|
||||
balance,
|
||||
"failed to set playback balance"
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
tracing::warn!(playback_id, balance, "audio client is not connected");
|
||||
}
|
||||
}
|
||||
|
||||
Message::SetSink(playback_id, sorted_pos) => {
|
||||
let Some(&sink_pos) = self.model.sinks.sorted_index.get(sorted_pos) else {
|
||||
return cosmic::Task::none();
|
||||
};
|
||||
let Some(&sink_id) = self.model.sinks.id.get(sink_pos as usize) else {
|
||||
return cosmic::Task::none();
|
||||
};
|
||||
|
||||
if let Some(client) = &self.client {
|
||||
match block_on(
|
||||
client
|
||||
.borrow_mut()
|
||||
.conn
|
||||
.set_playback_sink(playback_id, sink_id),
|
||||
) {
|
||||
Ok(Ok(())) => {
|
||||
if let Some(playback) = self
|
||||
.model
|
||||
.playback
|
||||
.iter_mut()
|
||||
.find(|playback| playback.id == playback_id)
|
||||
{
|
||||
playback.sink_id = Some(sink_id);
|
||||
}
|
||||
}
|
||||
Ok(Err(why)) => {
|
||||
tracing::error!(
|
||||
?why,
|
||||
playback_id,
|
||||
sink_id,
|
||||
"daemon rejected playback output"
|
||||
);
|
||||
}
|
||||
Err(why) => {
|
||||
tracing::error!(
|
||||
?why,
|
||||
playback_id,
|
||||
sink_id,
|
||||
"failed to set playback output"
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
tracing::warn!(playback_id, sink_id, "audio client is not connected");
|
||||
}
|
||||
}
|
||||
|
||||
Message::SetVolume(node_id, volume) => {
|
||||
if let Some(playback) = self
|
||||
.model
|
||||
.playback
|
||||
.iter_mut()
|
||||
.find(|playback| playback.id == node_id)
|
||||
{
|
||||
playback.volume = volume;
|
||||
}
|
||||
if let Some(client) = &self.client {
|
||||
match block_on(client.borrow_mut().conn.set_node_volume(node_id, volume)) {
|
||||
Ok(Ok(_)) => {}
|
||||
Ok(Err(why)) => {
|
||||
tracing::error!(
|
||||
?why,
|
||||
node_id,
|
||||
volume,
|
||||
"daemon rejected playback volume"
|
||||
);
|
||||
}
|
||||
Err(why) => {
|
||||
tracing::error!(?why, node_id, volume, "failed to set playback volume");
|
||||
}
|
||||
}
|
||||
} else {
|
||||
tracing::warn!(node_id, volume, "audio client is not connected");
|
||||
}
|
||||
}
|
||||
|
||||
Message::Surface(action) => {
|
||||
return cosmic::task::message(crate::app::Message::Surface(action));
|
||||
}
|
||||
|
||||
Message::ToggleExpanded(node_id) => {
|
||||
self.expanded = if self.expanded == Some(node_id) {
|
||||
None
|
||||
} else {
|
||||
Some(node_id)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
cosmic::Task::none()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn view() -> Section<crate::pages::Message> {
|
||||
Section::default().view::<Page>(move |_, page, _section| {
|
||||
if page.model.playback.is_empty() {
|
||||
return widget::settings::section()
|
||||
.add(
|
||||
widget::text::body(fl!("sound-applications", "none"))
|
||||
.width(Length::Fill)
|
||||
.align_x(Alignment::Center),
|
||||
)
|
||||
.into();
|
||||
}
|
||||
|
||||
let mut applications = widget::settings::section();
|
||||
|
||||
for playback in page.model.playback.iter().cloned() {
|
||||
let node_id = playback.id;
|
||||
let mute = playback.mute;
|
||||
let volume = playback.volume;
|
||||
let expanded = page.expanded == Some(node_id);
|
||||
let icon_name = page
|
||||
.playback_icons
|
||||
.get(node_id)
|
||||
.map(String::as_str)
|
||||
.unwrap_or("application-default");
|
||||
|
||||
let slider = if page.amplification_sink {
|
||||
widget::slider(0..=150, volume, move |volume| {
|
||||
Message::SetVolume(node_id, volume)
|
||||
})
|
||||
.breakpoints(&[100])
|
||||
} else {
|
||||
widget::slider(0..=100, volume, move |volume| {
|
||||
Message::SetVolume(node_id, volume)
|
||||
})
|
||||
}
|
||||
.width(Length::Fill)
|
||||
.apply(widget::container)
|
||||
.max_width(250.);
|
||||
|
||||
let volume_controls = widget::row::with_capacity(5)
|
||||
.align_y(Alignment::Center)
|
||||
.push(
|
||||
widget::button::icon(widget::icon::from_name(if mute {
|
||||
"audio-volume-muted-symbolic"
|
||||
} else {
|
||||
"audio-volume-high-symbolic"
|
||||
}))
|
||||
.on_press(Message::SetMute(node_id, !mute)),
|
||||
)
|
||||
.push(
|
||||
widget::text::body(volume.to_string())
|
||||
.width(Length::Fixed(22.))
|
||||
.align_x(Alignment::Center),
|
||||
)
|
||||
.push(horizontal_space().width(8.))
|
||||
.push(slider)
|
||||
.push(
|
||||
widget::button::icon(widget::icon::from_name(if expanded {
|
||||
"go-up-symbolic"
|
||||
} else {
|
||||
"go-down-symbolic"
|
||||
}))
|
||||
.on_press(Message::ToggleExpanded(node_id)),
|
||||
)
|
||||
.apply(Element::from)
|
||||
.map(crate::pages::Message::from);
|
||||
|
||||
applications = applications.add(
|
||||
widget::settings::item::builder(playback.name().to_owned())
|
||||
.icon(widget::icon::from_name(icon_name).size(16))
|
||||
.flex_control(volume_controls)
|
||||
.align_items(Alignment::Center),
|
||||
);
|
||||
|
||||
if !expanded {
|
||||
continue;
|
||||
}
|
||||
|
||||
let selected_sink =
|
||||
playback
|
||||
.selected_sink(page.model.default_sink)
|
||||
.and_then(|sink_id| {
|
||||
let sink_pos = page.model.sinks.id.iter().position(|id| *id == sink_id)?;
|
||||
page.model
|
||||
.sinks
|
||||
.sorted_index
|
||||
.iter()
|
||||
.position(|pos| usize::from(*pos) == sink_pos)
|
||||
});
|
||||
let output = widget::dropdown::popup_dropdown(
|
||||
&page.model.sinks.sorted_display,
|
||||
selected_sink,
|
||||
move |pos| Message::SetSink(node_id, pos),
|
||||
window::Id::RESERVED,
|
||||
Message::Surface,
|
||||
crate::Message::from,
|
||||
)
|
||||
.apply(Element::from)
|
||||
.map(crate::pages::Message::from);
|
||||
|
||||
let balance = widget::row::with_capacity(5)
|
||||
.align_y(Alignment::Center)
|
||||
.push(
|
||||
widget::column::with_capacity(2)
|
||||
.align_x(Alignment::Center)
|
||||
.push(widget::text::body(fl!("sound-output", "left")))
|
||||
.push(horizontal_space().width(22.)),
|
||||
)
|
||||
.push(horizontal_space().width(8.))
|
||||
.push(
|
||||
widget::slider(0.0..=2.0, playback.balance.unwrap_or(1.0), move |balance| {
|
||||
Message::SetBalance(node_id, balance)
|
||||
})
|
||||
.step(0.01)
|
||||
.breakpoints(&[1.0]),
|
||||
)
|
||||
.push(horizontal_space().width(8.))
|
||||
.push(
|
||||
widget::column::with_capacity(2)
|
||||
.align_x(Alignment::Center)
|
||||
.push(widget::text::body(fl!("sound-output", "right")))
|
||||
.push(horizontal_space().width(22.)),
|
||||
)
|
||||
.apply(Element::from)
|
||||
.map(crate::pages::Message::from);
|
||||
|
||||
applications = applications
|
||||
.add(widget::settings::item(
|
||||
fl!("sound-output", "device"),
|
||||
output,
|
||||
))
|
||||
.add(widget::settings::item(
|
||||
fl!("sound-output", "balance"),
|
||||
balance,
|
||||
));
|
||||
}
|
||||
|
||||
applications.into()
|
||||
})
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
// Copyright 2023 System76 <info@system76.com>
|
||||
// SPDX-License-Identifier: GPL-3.0-only
|
||||
|
||||
pub mod applications;
|
||||
pub mod device_profiles;
|
||||
|
||||
use cosmic::iced::{self, Alignment, Length, window};
|
||||
|
|
@ -63,6 +64,7 @@ impl From<Message> for crate::Message {
|
|||
|
||||
pub struct Page {
|
||||
entity: page::Entity,
|
||||
applications: page::Entity,
|
||||
device_profiles: page::Entity,
|
||||
client: Option<audio_client::Client>,
|
||||
model: model::Model,
|
||||
|
|
@ -78,6 +80,7 @@ impl Default for Page {
|
|||
fn default() -> Self {
|
||||
Self {
|
||||
entity: page::Entity::default(),
|
||||
applications: page::Entity::default(),
|
||||
device_profiles: page::Entity::default(),
|
||||
client: None,
|
||||
model: model::Model {
|
||||
|
|
@ -122,6 +125,7 @@ impl page::Page<crate::pages::Message> for Page {
|
|||
Some(vec![
|
||||
sections.insert(output()),
|
||||
sections.insert(input()),
|
||||
sections.insert(applications()),
|
||||
sections.insert(device_profiles()),
|
||||
])
|
||||
}
|
||||
|
|
@ -150,6 +154,7 @@ impl page::Page<crate::pages::Message> for Page {
|
|||
fn on_leave(&mut self) -> Task<crate::pages::Message> {
|
||||
*self = Page {
|
||||
entity: self.entity,
|
||||
applications: self.applications,
|
||||
device_profiles: self.device_profiles,
|
||||
..Page::default()
|
||||
};
|
||||
|
|
@ -163,8 +168,10 @@ impl page::AutoBind<crate::pages::Message> for Page {
|
|||
mut page: page::Insert<crate::pages::Message>,
|
||||
) -> page::Insert<crate::pages::Message> {
|
||||
let id = page.sub_page_with_id::<device_profiles::Page>();
|
||||
let applications_id = page.sub_page_with_id::<applications::Page>();
|
||||
let model = page.model.page_mut::<Page>().unwrap();
|
||||
model.device_profiles = id;
|
||||
model.applications = applications_id;
|
||||
page
|
||||
}
|
||||
}
|
||||
|
|
@ -534,6 +541,24 @@ fn device_profiles() -> Section<crate::pages::Message> {
|
|||
})
|
||||
}
|
||||
|
||||
/// A section for opening the application volume controls sub-page.
|
||||
fn applications() -> Section<crate::pages::Message> {
|
||||
crate::slab!(descriptions {
|
||||
button_txt = fl!("sound-applications");
|
||||
});
|
||||
|
||||
Section::default()
|
||||
.descriptions(descriptions)
|
||||
.view::<Page>(move |_binder, page, section| {
|
||||
settings::section()
|
||||
.add(crate::widget::go_next_item(
|
||||
§ion.descriptions[button_txt],
|
||||
crate::pages::Message::Page(page.applications),
|
||||
))
|
||||
.into()
|
||||
})
|
||||
}
|
||||
|
||||
// fn alerts() -> Section<crate::pages::Message> {
|
||||
// let mut descriptions = Slab::new();
|
||||
// let volume = descriptions.insert(fl!("sound-alerts", "volume"));
|
||||
|
|
@ -549,21 +574,3 @@ fn device_profiles() -> Section<crate::pages::Message> {
|
|||
// .into()
|
||||
// })
|
||||
// }
|
||||
|
||||
// fn applications() -> Section<crate::pages::Message> {
|
||||
// let mut descriptions = Slab::new();
|
||||
|
||||
// let applications = descriptions.insert(fl!("sound-applications", "desc"));
|
||||
|
||||
// Section::default()
|
||||
// .title(fl!("sound-applications"))
|
||||
// .descriptions(descriptions)
|
||||
// .view::<Page>(move |_binder, _page, section| {
|
||||
// settings::section().title(§ion.title)
|
||||
// .add(settings::item(
|
||||
// &*section.descriptions[applications],
|
||||
// text::body("TODO"),
|
||||
// ))
|
||||
// .into()
|
||||
// })
|
||||
// }
|
||||
|
|
|
|||
1
i18n/en/cosmic_settings.ftl
vendored
1
i18n/en/cosmic_settings.ftl
vendored
|
|
@ -632,6 +632,7 @@ sound-alerts = Alerts
|
|||
|
||||
sound-applications = Applications
|
||||
.desc = Application volumes and settings
|
||||
.none = No applications are using sound
|
||||
|
||||
# No speaker, headphones, or microphone plugged into sound card port
|
||||
sound-device-port-unplugged = Unplugged
|
||||
|
|
|
|||
|
|
@ -3,7 +3,9 @@
|
|||
|
||||
use std::sync::Arc;
|
||||
|
||||
use cosmic_settings_audio_client::{self as audio_client, Availability, ProfileInfo, RouteInfo};
|
||||
use cosmic_settings_audio_client::{
|
||||
self as audio_client, Availability, PlaybackInfo, ProfileInfo, RouteInfo,
|
||||
};
|
||||
use intmap::IntMap;
|
||||
|
||||
pub type DeviceId = u32;
|
||||
|
|
@ -18,6 +20,7 @@ pub struct Model {
|
|||
pub device_profiles_active: IntMap<DeviceId, ProfileInfo>,
|
||||
pub device_routes: IntMap<DeviceId, Vec<RouteInfo>>,
|
||||
pub node_devices: IntMap<NodeId, Option<u32>>,
|
||||
pub playback: Vec<Playback>,
|
||||
pub sinks: Nodes,
|
||||
pub sources: Nodes,
|
||||
pub active_sink: ActiveNode,
|
||||
|
|
@ -27,6 +30,43 @@ pub struct Model {
|
|||
pub text: Text,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Playback {
|
||||
pub id: NodeId,
|
||||
pub info: PlaybackInfo,
|
||||
pub mute: bool,
|
||||
pub volume: u32,
|
||||
pub balance: Option<f32>,
|
||||
/// Explicitly selected output. `None` means the playback follows the default sink.
|
||||
pub sink_id: Option<NodeId>,
|
||||
}
|
||||
|
||||
impl Playback {
|
||||
pub fn name(&self) -> &str {
|
||||
[
|
||||
self.info.application_name.as_deref(),
|
||||
self.info.application_id.as_deref(),
|
||||
self.info.media_name.as_deref(),
|
||||
Some(self.info.node_name.as_str()),
|
||||
]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.find(|value| !value.is_empty())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
pub fn description(&self) -> Option<&str> {
|
||||
self.info
|
||||
.media_name
|
||||
.as_deref()
|
||||
.filter(|description| !description.is_empty() && *description != self.name())
|
||||
}
|
||||
|
||||
pub fn selected_sink(&self, default_sink: Option<NodeId>) -> Option<NodeId> {
|
||||
self.sink_id.or(default_sink)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct Text {
|
||||
pub hd_audio: String,
|
||||
|
|
@ -106,7 +146,9 @@ impl Model {
|
|||
tracing::info!(target: "sound", ?event, "update");
|
||||
match event {
|
||||
audio_client::Event::NodeMute(node_id, mute) => {
|
||||
if let Some(pos) = self.sinks.id.iter().position(|id| node_id == *id) {
|
||||
if let Some(playback) = self.playback.iter_mut().find(|item| item.id == node_id) {
|
||||
playback.mute = mute;
|
||||
} else if let Some(pos) = self.sinks.id.iter().position(|id| node_id == *id) {
|
||||
self.sinks.mute[pos] = mute;
|
||||
if self.sinks.active == Some(pos) {
|
||||
self.active_sink.mute = mute;
|
||||
|
|
@ -120,7 +162,10 @@ impl Model {
|
|||
}
|
||||
|
||||
audio_client::Event::NodeVolume(node_id, volume, balance) => {
|
||||
if let Some(pos) = self.sinks.id.iter().position(|id| node_id == *id) {
|
||||
if let Some(playback) = self.playback.iter_mut().find(|item| item.id == node_id) {
|
||||
playback.volume = volume;
|
||||
playback.balance = balance;
|
||||
} else if let Some(pos) = self.sinks.id.iter().position(|id| node_id == *id) {
|
||||
self.sinks.volume[pos] = volume;
|
||||
self.sinks.balance[pos] = balance;
|
||||
if self.default_sink.as_ref().is_some_and(|&id| id == node_id)
|
||||
|
|
@ -304,6 +349,33 @@ impl Model {
|
|||
}
|
||||
}
|
||||
|
||||
audio_client::Event::Playback(node_id, info) => {
|
||||
if let Some(playback) = self.playback.iter_mut().find(|item| item.id == node_id) {
|
||||
playback.info = info;
|
||||
} else {
|
||||
self.playback.push(Playback {
|
||||
id: node_id,
|
||||
info,
|
||||
mute: false,
|
||||
volume: 0,
|
||||
balance: None,
|
||||
sink_id: None,
|
||||
});
|
||||
}
|
||||
self.playback
|
||||
.sort_by(|a, b| a.name().cmp(b.name()).then(a.id.cmp(&b.id)));
|
||||
}
|
||||
|
||||
audio_client::Event::PlaybackTarget(playback_id, sink_id) => {
|
||||
if let Some(playback) = self
|
||||
.playback
|
||||
.iter_mut()
|
||||
.find(|playback| playback.id == playback_id)
|
||||
{
|
||||
playback.sink_id = sink_id;
|
||||
}
|
||||
}
|
||||
|
||||
audio_client::Event::ActiveRoute(device_id, _index, route) => {
|
||||
self.update_device_names(device_id, &route);
|
||||
}
|
||||
|
|
@ -338,6 +410,12 @@ impl Model {
|
|||
|
||||
audio_client::Event::RemoveNode(node_id) => {
|
||||
self.node_devices.remove(node_id);
|
||||
self.playback.retain(|item| item.id != node_id);
|
||||
for playback in &mut self.playback {
|
||||
if playback.sink_id == Some(node_id) {
|
||||
playback.sink_id = None;
|
||||
}
|
||||
}
|
||||
|
||||
if !self.sinks.remove(node_id) {
|
||||
self.sources.remove(node_id);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue