Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement per application volume control #253

Merged
merged 26 commits into from
Mar 19, 2024
Merged
Show file tree
Hide file tree
Changes from 13 commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
aa60c35
Prototype volume control per application
leolost2605 Aug 26, 2023
f09731f
Improvements
leolost2605 Aug 26, 2023
ef2b2ba
Add scrolled window, a placeholder and reset button
leolost2605 Aug 26, 2023
2d01585
Allow muting applications
leolost2605 Aug 26, 2023
cc16fdd
Add mute switch
leolost2605 Aug 26, 2023
c51c0ce
Add halign and TODO
leolost2605 Aug 26, 2023
c84e426
Cleanup + fixes
leolost2605 Aug 26, 2023
abe4123
Add signal disconnect
leolost2605 Aug 26, 2023
91cf98f
Add sound namespace to App
leolost2605 Aug 26, 2023
1a317de
Cleanup
leolost2605 Aug 26, 2023
acee840
Move name and icon detection to app class
leolost2605 Aug 26, 2023
cd25225
Update POTFILES
leolost2605 Aug 26, 2023
44b0402
Add search result
leolost2605 Aug 26, 2023
441d346
Construct app from SinkInputInfo
leolost2605 Aug 26, 2023
58c57de
Add media label
leolost2605 Aug 26, 2023
42e849e
Use DesktopAppInfo.search
leolost2605 Aug 26, 2023
f07be2f
Pixel size 48
leolost2605 Aug 26, 2023
bdd9d73
Fix grid
leolost2605 Aug 26, 2023
f185805
Add tooltip text
leolost2605 Aug 26, 2023
99cac1d
Use changed signal for app updates
leolost2605 Aug 26, 2023
099fa37
Merge branch 'master' into application-volume-control
leolost2605 Aug 28, 2023
7fb5259
Only show apps for which a DesktopAppInfo can be found + add a hidden…
leolost2605 Sep 7, 2023
34b51cb
Wording
leolost2605 Sep 7, 2023
66dd588
Merge branch 'main' into application-volume-control
lenemter Jan 8, 2024
4afb5f4
Merge branch 'main' into application-volume-control
danirabbit Mar 19, 2024
9539215
Update for GTK 4
danirabbit Mar 19, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions po/POTFILES
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
src/App.vala
src/ApplicationsPanel.vala
src/AppRow.vala
src/TestPopover.vala
src/PulseAudioManager.vala
src/Plug.vala
Expand Down
36 changes: 36 additions & 0 deletions src/App.vala
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
/*
* SPDX-License-Identifier: LGPL-3.0-or-later
* SPDX-FileCopyrightText: 2023 elementary, Inc. (https://elementary.io)
*
* Authored by: Leonhard Kargl <[email protected]>
*/

public class Sound.App : Object {
public uint32 index { get; construct; }
public string name { get; construct; }
public string display_name { get; construct; }
public Icon icon { get; construct; }

public double volume { get; set; }
public bool muted { get; set; }
public PulseAudio.ChannelMap channel_map { get; set; }

public App (uint32 index, string name) {
Object (
index: index,
name: name
);
}

construct {
var app_info = new GLib.DesktopAppInfo (name + ".desktop");

if (app_info != null) {
display_name = app_info.get_name ();
icon = app_info.get_icon ();
} else {
display_name = name;
icon = new ThemedIcon ("application-default-icon");
}
}
}
112 changes: 112 additions & 0 deletions src/AppRow.vala
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
/*
* SPDX-License-Identifier: LGPL-3.0-or-later
* SPDX-FileCopyrightText: 2023 elementary, Inc. (https://elementary.io)
*
* Authored by: Leonhard Kargl <[email protected]>
*/

public class Sound.AppRow : Gtk.Grid {
private App? app;
private Gtk.Label title_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 () {
pixel_size = 32
};

title_label = new Gtk.Label ("") {
ellipsize = Pango.EllipsizeMode.END,
valign = Gtk.Align.END,
xalign = 0
};
title_label.get_style_context ().add_class (Granite.STYLE_CLASS_H3_LABEL);

icon_button = new Gtk.Button.from_icon_name ("audio-volume-muted") {
can_focus = false
};
icon_button.get_style_context ().add_class (Gtk.STYLE_CLASS_FLAT);

volume_scale = new Gtk.Scale.with_range (HORIZONTAL, 0, 1, 0.01) {
hexpand = true,
draw_value = false
};

mute_switch = new Gtk.Switch () {
halign = CENTER,
valign = CENTER
};

hexpand = true;
column_spacing = 6;
margin_top = 6;
margin_end = 6;
margin_bottom = 6;
margin_start = 6;

attach (image, 0, 0, 1, 2);
attach (title_label, 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 () {
volume_scale.set_value (app.volume);
mute_switch.state = !app.muted;
volume_scale.sensitive = !app.muted;

if (app.muted) {
((Gtk.Image) icon_button.image).icon_name = "audio-volume-muted";
} else if (volume_scale.get_value () < 0.33) {
((Gtk.Image) icon_button.image).icon_name = "audio-volume-low";
} else if (volume_scale.get_value () > 0.66) {
((Gtk.Image) icon_button.image).icon_name = "audio-volume-high";
} else {
((Gtk.Image) icon_button.image).icon_name = "audio-volume-medium";
}
}

public void bind_app (App app) {
this.app = app;

title_label.label = app.display_name;
image.set_from_gicon (app.icon, Gtk.IconSize.DND);

app.notify["volume"].connect (update);
app.notify["muted"].connect (update);

volume_scale.set_value (app.volume);
}

public void unbind_app () {
app.notify["volume"].disconnect (update);
app.notify["muted"].disconnect (update);
}
}
58 changes: 58 additions & 0 deletions src/ApplicationsPanel.vala
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
/*
* SPDX-License-Identifier: LGPL-3.0-or-later
* SPDX-FileCopyrightText: 2023 elementary, Inc. (https://elementary.io)
*
* Authored by: Leonhard Kargl <[email protected]>
*/

public class Sound.ApplicationsPanel : Gtk.Box {
construct {
var pulse_audio_manager = PulseAudioManager.get_default ();

var placeholder = new Granite.Widgets.AlertView (
_("No applications currently emittings sounds"),
_("Applications emitting sounds will automatically appear here"),
"dialog-information"
);
placeholder.show_all ();

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);

var scrolled_window = new Gtk.ScrolledWindow (null, null) {
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;
add (frame);
add (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);
app_row.show_all ();
return app_row;
}
}
13 changes: 5 additions & 8 deletions src/Plug.vala
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ public class Sound.Plug : Switchboard.Plug {

var settings = new Gee.TreeMap<string, string?> (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,
Expand All @@ -30,13 +31,15 @@ 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,
vexpand = true
};
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,
Expand Down Expand Up @@ -77,14 +80,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", "keyboard<sep>behavior")
Expand All @@ -101,6 +97,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;
}
}
Expand Down
89 changes: 88 additions & 1 deletion src/PulseAudioManager.vala
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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<string, Device> ();
output_devices = new Gee.HashMap<string, Device> ();
volume_operations = new Gee.HashMap<uint32, PulseAudio.Operation> ();
Expand Down Expand Up @@ -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;

Expand All @@ -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:
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -571,6 +596,44 @@ 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 (
sink_input.index,
sink_input.proplist.gets ("application.name")
);
apps.append (app);
}

if (!app.channel_map.equal (sink_input.channel_map)) {
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;
}
}

private void card_info_callback (PulseAudio.Context c, PulseAudio.CardInfo? card, int eol) {
if (card == null) {
return;
Expand Down Expand Up @@ -845,4 +908,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);
}
});
}
}
Loading
Loading