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',