From b425b915875ce7b2d4e2337cf49609cea1e8b72e Mon Sep 17 00:00:00 2001 From: Leonhard <106322251+leolost2605@users.noreply.github.com> Date: Tue, 19 Mar 2024 17:54:35 +0100 Subject: [PATCH] Implement per application volume control (#253) --- data/meson.build | 5 ++ data/sound.gschema.xml | 10 +++ meson.build | 2 + po/POTFILES | 3 + src/App.vala | 59 ++++++++++++++++++ src/AppRow.vala | 124 +++++++++++++++++++++++++++++++++++++ src/ApplicationsPanel.vala | 55 ++++++++++++++++ src/Plug.vala | 13 ++-- src/PulseAudioManager.vala | 87 +++++++++++++++++++++++++- src/meson.build | 3 + 10 files changed, 352 insertions(+), 9 deletions(-) create mode 100644 data/sound.gschema.xml create mode 100644 src/App.vala create mode 100644 src/AppRow.vala create mode 100644 src/ApplicationsPanel.vala diff --git a/data/meson.build b/data/meson.build index 64c26070..437715be 100644 --- a/data/meson.build +++ b/data/meson.build @@ -6,3 +6,8 @@ i18n.merge_file( install: true, install_dir: datadir / 'metainfo', ) + +install_data( + 'sound.gschema.xml', + install_dir: datadir / 'glib-2.0' / 'schemas' +) diff --git a/data/sound.gschema.xml b/data/sound.gschema.xml new file mode 100644 index 00000000..37dee087 --- /dev/null +++ b/data/sound.gschema.xml @@ -0,0 +1,10 @@ + + + + + false + Show apps for which no info could be found + Show apps that are found by PulseAudio but misbehave in a way that no app info can be found. This setting may cause apps to appear that have wrong names or icons or none at all. + + + diff --git a/meson.build b/meson.build index 34e9c5de..070565dd 100644 --- a/meson.build +++ b/meson.build @@ -36,6 +36,8 @@ config_file = configure_file( configuration: config_data ) +gnome.post_install (glib_compile_schemas: true) + subdir('data') subdir('src') subdir('po') diff --git a/po/POTFILES b/po/POTFILES index 7c6089f9..22037789 100644 --- a/po/POTFILES +++ b/po/POTFILES @@ -1,3 +1,6 @@ +src/App.vala +src/ApplicationsPanel.vala +src/AppRow.vala src/TestPopover.vala src/PulseAudioManager.vala src/Plug.vala diff --git a/src/App.vala b/src/App.vala new file mode 100644 index 00000000..645917ef --- /dev/null +++ b/src/App.vala @@ -0,0 +1,59 @@ +/* +* SPDX-License-Identifier: LGPL-3.0-or-later +* SPDX-FileCopyrightText: 2023 elementary, Inc. (https://elementary.io) +* +* Authored by: Leonhard Kargl +*/ + +public class Sound.App : Object { + public signal void changed (); + + public uint32 index { get; private set; } + public string name { get; private set; } + public string display_name { get; private set; } + public Icon icon { get; private set; } + + public string media_name { get; set; } + public double volume { get; set; } + public bool muted { get; set; } + public PulseAudio.ChannelMap channel_map { get; set; } + + public bool hidden { get; set; default = false; } + + private static Settings settings; + + static construct { + settings = new Settings ("io.elementary.switchboard.sound"); + } + + public App.from_sink_input_info (PulseAudio.SinkInputInfo sink_input) { + index = sink_input.index; + name = sink_input.proplist.gets (PulseAudio.Proplist.PROP_APPLICATION_NAME); + + string app_id; + if (sink_input.proplist.contains (PulseAudio.Proplist.PROP_APPLICATION_ID) == 1) { + app_id = sink_input.proplist.gets (PulseAudio.Proplist.PROP_APPLICATION_ID); + } else { + app_id = name; + } + + var app_info = new DesktopAppInfo (app_id + ".desktop"); + + if (app_info == null) { + settings.bind ("show-unknown-apps", this, "hidden", GET | INVERT_BOOLEAN); + } + + if (app_info != null) { + display_name = app_info.get_name (); + icon = app_info.get_icon (); + } else { + display_name = name; + + if (sink_input.proplist.contains (PulseAudio.Proplist.PROP_APPLICATION_ICON_NAME) == 1) { + icon = new ThemedIcon (sink_input.proplist.gets (PulseAudio.Proplist.PROP_APPLICATION_ICON_NAME)); + } else { + icon = new ThemedIcon ("application-default-icon"); + } + } + } +} diff --git a/src/AppRow.vala b/src/AppRow.vala new file mode 100644 index 00000000..4f3cc4c9 --- /dev/null +++ b/src/AppRow.vala @@ -0,0 +1,124 @@ +/* +* SPDX-License-Identifier: LGPL-3.0-or-later +* SPDX-FileCopyrightText: 2023 elementary, Inc. (https://elementary.io) +* +* Authored by: Leonhard Kargl +*/ + +public class Sound.AppRow : Gtk.Grid { + private App? app; + private Gtk.Label app_name_label; + private Gtk.Label media_name_label; + private Gtk.Image image; + private Gtk.Button icon_button; + private Gtk.Scale volume_scale; + private Gtk.Switch mute_switch; + + construct { + image = new Gtk.Image () { + icon_size = LARGE + }; + + app_name_label = new Gtk.Label ("") { + ellipsize = END, + xalign = 0 + }; + app_name_label.add_css_class (Granite.STYLE_CLASS_H3_LABEL); + + media_name_label = new Gtk.Label ("") { + ellipsize = END, + xalign = 0 + }; + media_name_label.add_css_class (Granite.STYLE_CLASS_DIM_LABEL); + media_name_label.add_css_class (Granite.STYLE_CLASS_SMALL_LABEL); + + var label_box = new Gtk.Box (HORIZONTAL, 6); + label_box.append (app_name_label); + label_box.append (media_name_label); + + icon_button = new Gtk.Button.from_icon_name ("audio-volume-muted"); + + volume_scale = new Gtk.Scale.with_range (HORIZONTAL, 0, 1, 0.01) { + hexpand = true + }; + + mute_switch = new Gtk.Switch () { + valign = CENTER + }; + + hexpand = true; + column_spacing = 6; + + attach (image, 0, 0, 1, 2); + attach (label_box, 1, 0, 2); + attach (icon_button, 1, 1); + attach (volume_scale, 2, 1); + attach (mute_switch, 3, 0, 1, 2); + + volume_scale.change_value.connect ((type, new_value) => { + if (app != null) { + PulseAudioManager.get_default ().change_application_volume (app, new_value.clamp (0, 1)); + } + + return true; + }); + + icon_button.clicked.connect (() => toggle_mute_application ()); + + mute_switch.state_set.connect ((state) => { + toggle_mute_application (!state); + return true; + }); + } + + private void toggle_mute_application (bool? custom = null) { + if (app == null) { + return; + } + + PulseAudioManager.get_default ().mute_application (app, custom != null ? custom : !app.muted); + } + + private void update () { + media_name_label.label = media_name_label.tooltip_text = app.media_name; + volume_scale.set_value (app.volume); + mute_switch.state = !app.muted; + volume_scale.sensitive = !app.muted; + + if (app.muted) { + icon_button.icon_name = "audio-volume-muted"; + } else if (volume_scale.get_value () < 0.33) { + icon_button.icon_name = "audio-volume-low"; + } else if (volume_scale.get_value () > 0.66) { + icon_button.icon_name = "audio-volume-high"; + } else { + icon_button.icon_name = "audio-volume-medium"; + } + + if (app.muted) { + icon_button.tooltip_text = _("Unmute"); + } else { + icon_button.tooltip_text = _("Mute"); + } + } + + public void bind_app (App app) { + this.app = app; + + app_name_label.label = app.display_name; + image.set_from_gicon (app.icon); + + app.changed.connect (update); + app.notify["hidden"].connect (() => { + visible = app.hidden; + }); + + visible = app.hidden; + + volume_scale.set_value (app.volume); + } + + public void unbind_app () { + app.changed.disconnect (update); + } +} diff --git a/src/ApplicationsPanel.vala b/src/ApplicationsPanel.vala new file mode 100644 index 00000000..7c34d328 --- /dev/null +++ b/src/ApplicationsPanel.vala @@ -0,0 +1,55 @@ +/* +* SPDX-License-Identifier: LGPL-3.0-or-later +* SPDX-FileCopyrightText: 2023 elementary, Inc. (https://elementary.io) +* +* Authored by: Leonhard Kargl +*/ + +public class Sound.ApplicationsPanel : Gtk.Box { + construct { + var pulse_audio_manager = PulseAudioManager.get_default (); + + var placeholder = new Granite.Placeholder (_("No applications currently emittings sounds")) { + description = _("Applications emitting sounds will automatically appear here") + }; + + var list_box = new Gtk.ListBox () { + selection_mode = NONE, + }; + list_box.bind_model (pulse_audio_manager.apps, widget_create_func); + list_box.set_placeholder (placeholder); + list_box.add_css_class (Granite.STYLE_CLASS_RICH_LIST); + + var scrolled_window = new Gtk.ScrolledWindow () { + child = list_box, + vexpand = true + }; + + var frame = new Gtk.Frame (null) { + child = scrolled_window + }; + + var reset_button = new Gtk.Button.with_label (_("Reset all apps to default")) { + halign = END + }; + + orientation = VERTICAL; + spacing = 12; + append (frame); + append (reset_button); + + // TODO: Reset also non active applications + reset_button.clicked.connect (() => { + for (int i = 0; i < pulse_audio_manager.apps.get_n_items (); i++) { + pulse_audio_manager.change_application_volume ((App) pulse_audio_manager.apps.get_item (i), 1); + } + }); + } + + private Gtk.Widget widget_create_func (Object item) { + var app = (App) item; + var app_row = new AppRow (); + app_row.bind_app (app); + return app_row; + } +} diff --git a/src/Plug.vala b/src/Plug.vala index d0fc207a..b2bbcbe6 100644 --- a/src/Plug.vala +++ b/src/Plug.vala @@ -16,6 +16,7 @@ public class Sound.Plug : Switchboard.Plug { var settings = new Gee.TreeMap (null, null); settings.set ("sound", null); + settings.set ("sound/applications", "applications"); settings.set ("sound/input", "input"); settings.set ("sound/output", "output"); Object (category: Category.HARDWARE, @@ -30,6 +31,7 @@ public class Sound.Plug : Switchboard.Plug { if (box == null) { var output_panel = new OutputPanel (); input_panel = new InputPanel (); + var applications_panel = new ApplicationsPanel (); stack = new Gtk.Stack () { hexpand = true, @@ -37,6 +39,7 @@ public class Sound.Plug : Switchboard.Plug { }; stack.add_titled (output_panel, "output", _("Output")); stack.add_titled (input_panel, "input", _("Input")); + stack.add_titled (applications_panel, "applications", _("Applications")); var stack_switcher = new Gtk.StackSwitcher () { halign = Gtk.Align.CENTER, @@ -78,14 +81,7 @@ public class Sound.Plug : Switchboard.Plug { } public override void search_callback (string location) { - switch (location) { - case "input": - stack.set_visible_child_name ("input"); - break; - case "output": - stack.set_visible_child_name ("output"); - break; - } + stack.set_visible_child_name (location); } // 'search' returns results like ("Keyboard → Behavior → Duration", "keyboardbehavior") @@ -104,6 +100,7 @@ public class Sound.Plug : Switchboard.Plug { search_results.set ("%s → %s → %s".printf (display_name, _("Input"), _("Port")), "input"); search_results.set ("%s → %s → %s".printf (display_name, _("Input"), _("Volume")), "input"); search_results.set ("%s → %s → %s".printf (display_name, _("Input"), _("Enable")), "input"); + search_results.set ("%s → %s".printf (display_name, _("Applications")), "applications"); return search_results; } } diff --git a/src/PulseAudioManager.vala b/src/PulseAudioManager.vala index 1f213ec0..7d5d9733 100644 --- a/src/PulseAudioManager.vala +++ b/src/PulseAudioManager.vala @@ -41,6 +41,8 @@ public class Sound.PulseAudioManager : GLib.Object { public signal void new_device (Device dev); public PulseAudio.Context context { get; private set; } + public ListStore apps { get; construct; } + private PulseAudio.GLibMainLoop loop; private bool is_ready = false; private uint reconnect_timer_id = 0U; @@ -58,6 +60,7 @@ public class Sound.PulseAudioManager : GLib.Object { construct { loop = new PulseAudio.GLibMainLoop (); + apps = new ListStore (typeof (App)); input_devices = new Gee.HashMap (); output_devices = new Gee.HashMap (); volume_operations = new Gee.HashMap (); @@ -305,6 +308,8 @@ public class Sound.PulseAudioManager : GLib.Object { PulseAudio.Context.SubscriptionMask.SOURCE_OUTPUT | PulseAudio.Context.SubscriptionMask.CARD); context.get_server_info (server_info_callback); + context.get_sink_input_info_list (sink_input_info_callback); + is_ready = true; break; @@ -330,7 +335,6 @@ public class Sound.PulseAudioManager : GLib.Object { var source_type = t & PulseAudio.Context.SubscriptionEventType.FACILITY_MASK; switch (source_type) { case PulseAudio.Context.SubscriptionEventType.SINK: - case PulseAudio.Context.SubscriptionEventType.SINK_INPUT: var event_type = t & PulseAudio.Context.SubscriptionEventType.TYPE_MASK; switch (event_type) { case PulseAudio.Context.SubscriptionEventType.NEW: @@ -366,6 +370,27 @@ public class Sound.PulseAudioManager : GLib.Object { break; + case PulseAudio.Context.SubscriptionEventType.SINK_INPUT: + switch (t & PulseAudio.Context.SubscriptionEventType.TYPE_MASK) { + case NEW: + case CHANGE: + c.get_sink_input_info (index, sink_input_info_callback); + break; + + case REMOVE: + for (uint i = 0; i < apps.get_n_items (); i++) { + if (index == ((App) apps.get_item (i)).index) { + apps.remove (i); + } + } + break; + + default: + break; + } + + break; + case PulseAudio.Context.SubscriptionEventType.SERVER: context.get_server_info (server_info_callback); break; @@ -578,6 +603,42 @@ public class Sound.PulseAudioManager : GLib.Object { } } + + + private void sink_input_info_callback (PulseAudio.Context c, PulseAudio.SinkInputInfo? sink_input, int eol) { + if (sink_input == null) { + return; + } + + App? app = null; + for (uint i = 0; i < apps.get_n_items (); i++) { + var _app = (App) apps.get_item (i); + if (sink_input.index == _app.index) { + app = _app; + break; + } + } + + if (app == null) { + app = new App.from_sink_input_info (sink_input); + apps.append (app); + } + + app.media_name = sink_input.name; + app.channel_map = sink_input.channel_map; + + if ((sink_input.mute != 0) != app.muted) { + app.muted = sink_input.mute != 0; + } + + var volume = sink_input.volume.avg ().sw_to_linear (); + if (app.volume != volume) { + app.volume = volume; + } + + app.changed (); + } + private void card_info_callback (PulseAudio.Context c, PulseAudio.CardInfo? card, int eol) { if (card == null) { return; @@ -852,4 +913,28 @@ public class Sound.PulseAudioManager : GLib.Object { double tmp = (double)(PulseAudio.Volume.NORM - PulseAudio.Volume.MUTED) * vol / 100; return (PulseAudio.Volume)tmp + PulseAudio.Volume.MUTED; } + + /* + * Application Volume management + */ + + public void change_application_volume (App app, double volume) { + var cvol = PulseAudio.CVolume (); + + cvol.set (app.channel_map.channels, PulseAudio.Volume.sw_from_linear (volume)); + + context.set_sink_input_volume (app.index, cvol, (c, success) => { + if (success != 1) { + warning ("Failed to change volume of application '%s'.", app.name); + } + }); + } + + public void mute_application (App app, bool mute) { + context.set_sink_input_mute (app.index, mute, (c, success) => { + if (success != 1) { + warning ("Failed to mute application '%s'.", app.name); + } + }); + } } diff --git a/src/meson.build b/src/meson.build index 2d4165a3..c1fe53f9 100644 --- a/src/meson.build +++ b/src/meson.build @@ -1,4 +1,7 @@ plug_files = files( + 'App.vala', + 'ApplicationsPanel.vala', + 'AppRow.vala', 'CanberraGtk4.vala', 'TestPopover.vala', 'PulseAudioManager.vala',