diff --git a/Cargo.lock b/Cargo.lock index 6c11fe1..6c8a633 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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]] diff --git a/TESTING.md b/TESTING.md index cbac4ed..8efc53e 100644 --- a/TESTING.md +++ b/TESTING.md @@ -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 diff --git a/cosmic-settings/src/app.rs b/cosmic-settings/src/app.rs index 96c0ddb..1596305 100644 --- a/cosmic-settings/src/app.rs +++ b/cosmic-settings/src/app.rs @@ -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::() { + return page.update(message).map(Into::into); + } + } + #[cfg(feature = "page-sound")] crate::pages::Message::SoundDeviceProfiles(message) => { if let Some(page) = self.pages.page_mut::() { diff --git a/cosmic-settings/src/pages/applications/startup_apps.rs b/cosmic-settings/src/pages/applications/startup_apps.rs index 4735771..f7118a4 100644 --- a/cosmic-settings/src/pages/applications/startup_apps.rs +++ b/cosmic-settings/src/pages/applications/startup_apps.rs @@ -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}; diff --git a/cosmic-settings/src/pages/desktop/appearance/font_config.rs b/cosmic-settings/src/pages/desktop/appearance/font_config.rs index f367a0f..2f88b13 100644 --- a/cosmic-settings/src/pages/desktop/appearance/font_config.rs +++ b/cosmic-settings/src/pages/desktop/appearance/font_config.rs @@ -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; diff --git a/cosmic-settings/src/pages/mod.rs b/cosmic-settings/src/pages/mod.rs index 3a148af..84f82c4 100644 --- a/cosmic-settings/src/pages/mod.rs +++ b/cosmic-settings/src/pages/mod.rs @@ -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")] diff --git a/cosmic-settings/src/pages/sound/applications.rs b/cosmic-settings/src/pages/sound/applications.rs new file mode 100644 index 0000000..dbe69da --- /dev/null +++ b/cosmic-settings/src/pages/sound/applications.rs @@ -0,0 +1,465 @@ +// Copyright 2026 System76 +// 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 for crate::pages::Message { + fn from(message: Message) -> Self { + crate::pages::Message::SoundApplications(message) + } +} + +impl From 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>>, + desktop_entries: DesktopEntryCache, + playback_icons: IntMap, + amplification_sink: bool, + expanded: Option, +} + +impl page::AutoBind for Page {} + +impl page::Page for Page { + fn on_enter(&mut self) -> cosmic::Task { + self.amplification_sink = Config::new(AUDIO_CONFIG, 1) + .ok() + .and_then(|config| config.get::(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>, + ) -> Option { + Some(vec![sections.insert(view())]) + } + + fn set_id(&mut self, entity: page::Entity) { + self.entity = entity; + } + + fn on_leave(&mut self) -> cosmic::Task { + *self = Page { + entity: self.entity, + ..Page::default() + }; + cosmic::Task::none() + } + + fn subscription(&self, _core: &cosmic::Core) -> iced::Subscription { + iced::Subscription::run(|| { + iced::stream::channel( + 1, + move |emitter: futures::channel::mpsc::Sender| 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 { + 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 { + Section::default().view::(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() + }) +} diff --git a/cosmic-settings/src/pages/sound/mod.rs b/cosmic-settings/src/pages/sound/mod.rs index 4e1c9da..8537c93 100644 --- a/cosmic-settings/src/pages/sound/mod.rs +++ b/cosmic-settings/src/pages/sound/mod.rs @@ -1,6 +1,7 @@ // Copyright 2023 System76 // 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 for crate::Message { pub struct Page { entity: page::Entity, + applications: page::Entity, device_profiles: page::Entity, client: Option, 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 for Page { Some(vec![ sections.insert(output()), sections.insert(input()), + sections.insert(applications()), sections.insert(device_profiles()), ]) } @@ -150,6 +154,7 @@ impl page::Page for Page { fn on_leave(&mut self) -> Task { *self = Page { entity: self.entity, + applications: self.applications, device_profiles: self.device_profiles, ..Page::default() }; @@ -163,8 +168,10 @@ impl page::AutoBind for Page { mut page: page::Insert, ) -> page::Insert { let id = page.sub_page_with_id::(); + let applications_id = page.sub_page_with_id::(); let model = page.model.page_mut::().unwrap(); model.device_profiles = id; + model.applications = applications_id; page } } @@ -534,6 +541,24 @@ fn device_profiles() -> Section { }) } +/// A section for opening the application volume controls sub-page. +fn applications() -> Section { + crate::slab!(descriptions { + button_txt = fl!("sound-applications"); + }); + + Section::default() + .descriptions(descriptions) + .view::(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 { // let mut descriptions = Slab::new(); // let volume = descriptions.insert(fl!("sound-alerts", "volume")); @@ -549,21 +574,3 @@ fn device_profiles() -> Section { // .into() // }) // } - -// fn applications() -> Section { -// let mut descriptions = Slab::new(); - -// let applications = descriptions.insert(fl!("sound-applications", "desc")); - -// Section::default() -// .title(fl!("sound-applications")) -// .descriptions(descriptions) -// .view::(move |_binder, _page, section| { -// settings::section().title(§ion.title) -// .add(settings::item( -// &*section.descriptions[applications], -// text::body("TODO"), -// )) -// .into() -// }) -// } diff --git a/i18n/en/cosmic_settings.ftl b/i18n/en/cosmic_settings.ftl index b77f075..a721520 100644 --- a/i18n/en/cosmic_settings.ftl +++ b/i18n/en/cosmic_settings.ftl @@ -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 diff --git a/pages/sound/src/model.rs b/pages/sound/src/model.rs index 87c6509..c18ae6d 100644 --- a/pages/sound/src/model.rs +++ b/pages/sound/src/model.rs @@ -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, pub device_routes: IntMap>, pub node_devices: IntMap>, + pub playback: Vec, 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, + /// Explicitly selected output. `None` means the playback follows the default sink. + pub sink_id: Option, +} + +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) -> Option { + 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);