Per app volume control (#235)

This commit is contained in:
Jannis 2023-04-11 10:31:16 +02:00 committed by GitHub
parent a2397da06a
commit 91f19dfb4e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 456 additions and 14 deletions

View File

@ -314,13 +314,13 @@ config file to be able to detect config errors
default: "right" ++
description: Horizontal position of the button in the bar ++
enum: ["right", "left"] ++
animation_type: ++
animation-type: ++
type: string ++
optional: true ++
default: "slide_down" ++
description: Animation type for menu++
enum: ["slide_down", "slide_up", "none"] ++
animation_duration: ++
animation-duration: ++
type: integer ++
optional: true ++
default: 250 ++
@ -388,13 +388,51 @@ config file to be able to detect config errors
description: A grid of buttons that execute shell commands ++
*volume*++
type: object ++
css class: widget-volume ++
css class: ++
widget-volume ++
per-app-volume ++
properties: ++
label: ++
type: string ++
optional: true ++
default: "Volume" ++
description: Text displayed in front of the volume slider ++
show-per-app: ++
type: bool ++
optional: true ++
default: false ++
description: Show per app volume control ++
empty-list-label: ++
type: string ++
optional: true ++
default: "No active sink input" ++
description: Text displayed when there are not active sink inputs ++
expand-button-label: ++
type: string ++
optional: true ++
default: "⇧" ++
description: Label displayed on button to show per app volume control ++
collapse-button-label: ++
type: string ++
optional: true ++
default: "⇩" ++
description: Label displayed on button to hide per app volume control ++
icon-size: ++
type: integer ++
optional: true ++
default: 24 ++
description: Size of the application icon in per app volume control ++
animation-type: ++
type: string ++
optional: true ++
default: "slide_down" ++
description: Animation type for the per app volume control ++
enum: ["slide_down", "slide_up", "none"] ++
animation-duration: ++
type: integer ++
optional: true ++
default: 250 ++
description: Duration of animation in milliseconds ++
description: Slider to control pulse volume ++
*backlight*++
type: object ++

View File

@ -423,13 +423,13 @@
"default": "right",
"enum": ["right", "left"]
},
"animation_type": {
"animation-type": {
"type": "string",
"default": "slide_down",
"description": "Animation type for menu",
"enum": ["slide_down", "slide_up", "none"]
},
"animation_duration":{
"animation-duration":{
"type": "integer",
"default": 250,
"description": "Duration of animation in milliseconds"
@ -466,6 +466,42 @@
"type": "string",
"description": "Text displayed in front of the volume slider",
"default": "Volume"
},
"show-per-app": {
"type": "boolean",
"default": false,
"description": "Show per app volume control"
},
"empty-list-label": {
"type": "string",
"default": "No active sink input",
"description": "Text displayed when there are not active sink inputs"
},
"expand-button-label": {
"type": "string",
"default": "⇧",
"description": "Label displayed on button to show per app volume control"
},
"collapse-button-label": {
"type": "string",
"default": "⇩",
"description": "Label displayed on button to hide per app volume control"
},
"icon-size": {
"type": "integer",
"default": 24,
"description": "Size of the application icon in per app volume control"
},
"animation-type": {
"type": "string",
"default": "slide_down",
"description": "Animation type for menu",
"enum": ["slide_down", "slide_up", "none"]
},
"animation-duration":{
"type": "integer",
"default": 250,
"description": "Duration of animation in milliseconds"
}
}
},

View File

@ -167,10 +167,10 @@ namespace SwayNotificationCenter.Widgets {
info ("No label for menu-object given using default");
}
int duration = int.max (0, get_prop<int> (obj, "animation_duration"));
int duration = int.max (0, get_prop<int> (obj, "animation-duration"));
if (duration == 0) duration = 250;
string ? animation_type = get_prop<string> (obj, "animation_type");
string ? animation_type = get_prop<string> (obj, "animation-type");
if (animation_type == null) {
animation_type = "slide_down";
info ("No animation-type for menu-object given using default");
@ -204,5 +204,13 @@ namespace SwayNotificationCenter.Widgets {
});
}
}
public override void on_cc_visibility_change (bool val) {
if (!val) {
foreach (var obj in menu_objects) {
obj.revealer ?.set_reveal_child (false);
}
}
}
}
}

View File

@ -8,7 +8,7 @@ namespace SwayNotificationCenter.Widgets {
* https://github.com/elementary/switchboard-plug-sound
*/
public class PulseDaemon : Object {
private Context? context;
private Context ? context;
private GLibMainLoop mainloop;
private bool quitting = false;
@ -21,10 +21,14 @@ namespace SwayNotificationCenter.Widgets {
public HashMap<string, PulseDevice> sinks { get; private set; }
public HashMap<uint32, PulseSinkInput> active_sinks { get; private set; }
construct {
mainloop = new GLibMainLoop ();
sinks = new HashMap<string, PulseDevice> ();
active_sinks = new HashMap<uint32, PulseSinkInput> ();
}
public void start () {
@ -45,6 +49,10 @@ namespace SwayNotificationCenter.Widgets {
public signal void change_default_device (PulseDevice device);
public signal void new_active_sink (PulseSinkInput device);
public signal void change_active_sink (PulseSinkInput device);
public signal void remove_active_sink (PulseSinkInput device);
public signal void new_device (PulseDevice device);
public signal void change_device (PulseDevice device);
public signal void remove_device (PulseDevice device);
@ -100,6 +108,26 @@ namespace SwayNotificationCenter.Widgets {
var type = t & Context.SubscriptionEventType.FACILITY_MASK;
var event = t & Context.SubscriptionEventType.TYPE_MASK;
switch (type) {
case Context.SubscriptionEventType.SINK_INPUT:
switch (event) {
default: break;
case Context.SubscriptionEventType.NEW:
case Context.SubscriptionEventType.CHANGE:
ctx.get_sink_input_info_list (this.get_sink_input_info);
break;
case Context.SubscriptionEventType.REMOVE:
// A safe way of removing the sink_input
var iter = active_sinks.map_iterator ();
while (iter.next ()) {
var sink_input = iter.get_value ();
if (sink_input.index != index) continue;
this.remove_active_sink (sink_input);
iter.unset ();
break;
}
break;
}
break;
case Context.SubscriptionEventType.SINK:
switch (event) {
default: break;
@ -165,6 +193,68 @@ namespace SwayNotificationCenter.Widgets {
ctx.get_card_info_list (this.get_card_info);
ctx.get_sink_info_list (this.get_sink_info);
foreach (var sink_input in active_sinks) {
sink_input.value.active = false;
}
ctx.get_sink_input_info_list (this.get_sink_input_info);
var iter = active_sinks.map_iterator ();
while (iter.next ()) {
var sink_input = iter.get_value ();
if (!sink_input.active) {
this.remove_active_sink (sink_input);
iter.unset ();
}
}
}
private void get_sink_input_info (Context ctx, SinkInputInfo ? info, int eol) {
if (info == null || eol != 0) return;
uint32 id = PulseSinkInput.get_hash_map_key (info.index);
PulseSinkInput sink_input = null;
bool has_sink_input = active_sinks.has_key (id);
if (has_sink_input) {
sink_input = active_sinks.get (id);
} else {
sink_input = new PulseSinkInput ();
}
sink_input.index = info.index;
sink_input.sink_index = info.sink;
sink_input.client_index = info.client;
sink_input.name = info.proplist.gets ("application.name");
sink_input.application_binary = info.proplist
.gets ("application.process.binary");
sink_input.application_icon_name = info.proplist
.gets ("application.icon_name");
sink_input.media_name = info.proplist.gets ("media.name");
sink_input.is_muted = info.mute == 1;
sink_input.cvolume = info.volume;
sink_input.channel_map = info.channel_map;
sink_input.balance = sink_input.cvolume
.get_balance (sink_input.channel_map);
sink_input.volume_operations.foreach ((op) => {
if (op.get_state () != Operation.State.RUNNING) {
sink_input.volume_operations.remove (op);
}
return Source.CONTINUE;
});
if (sink_input.volume_operations.is_empty) {
sink_input.volume = volume_to_double (
sink_input.cvolume.max ());
}
sink_input.active = true;
if (!has_sink_input) {
active_sinks.set (id, sink_input);
this.new_active_sink (sink_input);
} else {
this.change_active_sink (sink_input);
}
}
private void get_card_info (Context ctx, CardInfo ? info, int eol) {
@ -368,6 +458,26 @@ namespace SwayNotificationCenter.Widgets {
/*
* Setters
*/
public void set_sink_input_volume (PulseSinkInput sink_input, double volume) {
sink_input.volume_operations.foreach ((operation) => {
if (operation.get_state () == Operation.State.RUNNING) {
operation.cancel ();
}
sink_input.volume_operations.remove (operation);
return GLib.Source.CONTINUE;
});
var cvol = sink_input.cvolume;
cvol.scale (double_to_volume (volume));
Operation ? operation = null;
operation = context.set_sink_input_volume (
sink_input.index, cvol);
if (operation != null) {
sink_input.volume_operations.add (operation);
}
}
public void set_device_volume (PulseDevice device, double volume) {
device.volume_operations.foreach ((operation) => {
if (operation.get_state () == Operation.State.RUNNING) {
@ -515,10 +625,10 @@ namespace SwayNotificationCenter.Widgets {
}
}
// public void set_sink_input_mute (bool state, PulseSinkInput sink_input) {
// if (sink_input.is_muted == state) return;
// context.set_sink_input_mute (sink_input.index, state);
// }
public void set_sink_input_mute (bool state, PulseSinkInput sink_input) {
if (sink_input.is_muted == state) return;
context.set_sink_input_mute (sink_input.index, state);
}
/*
* Volume utils

View File

@ -0,0 +1,62 @@
// From SwaySettings PulseAudio page: https://github.com/ErikReider/SwaySettings/blob/2b05776bce2fd55933a7fbdec995f54849e39e7d/src/Pages/Pulse/PulseSinkInput.vala
using PulseAudio;
using Gee;
namespace SwayNotificationCenter.Widgets {
public class PulseSinkInput : Object {
/** The card index: ex. `Sink Input #227` */
public uint32 index;
/** The sink index: ex. `55` */
public uint32 sink_index;
/** The client index: ex. `266` */
public uint32 client_index;
/** The name of the application: `application.name` */
public string name;
/** The name of the application binary: `application.process.binary` */
public string application_binary;
/** The application icon. Can be null: `application.icon_name` */
public string ? application_icon_name;
/** The name of the media: `media.name` */
public string media_name;
/** The mute state: `Mute` */
public bool is_muted;
public double volume;
public float balance { get; set; default = 0; }
public CVolume cvolume;
public ChannelMap channel_map;
public LinkedList<Operation> volume_operations;
public bool active;
/** Gets the name to be shown to the user:
* "application_name"
*/
public string ? get_display_name () {
return name;
}
public bool cmp (PulseSinkInput sink_input) {
return sink_input.index == index
&& sink_input.sink_index == sink_index
&& sink_input.client_index == client_index
&& sink_input.name == name
&& sink_input.application_binary == application_binary
&& sink_input.is_muted == is_muted
&& sink_input.volume == volume;
}
/** Gets the name to be shown to the user:
* "index:application_name"
*/
public static uint32 get_hash_map_key (uint32 i) {
return i;
}
construct {
volume_operations = new LinkedList<Operation> ();
}
}
}

View File

@ -0,0 +1,49 @@
namespace SwayNotificationCenter.Widgets {
public class SinkInputRow : Gtk.ListBoxRow {
Gtk.Box container;
Gtk.Image icon = new Gtk.Image ();
Gtk.Scale scale = new Gtk.Scale.with_range (Gtk.Orientation.HORIZONTAL, 0, 100, 1);
public unowned PulseSinkInput sink_input;
private unowned PulseDaemon client;
public SinkInputRow (PulseSinkInput sink_input, PulseDaemon client, int icon_size) {
this.client = client;
update (sink_input);
scale.draw_value = false;
icon.pixel_size = icon_size;
container = new Gtk.Box (Gtk.Orientation.HORIZONTAL, 0);
container.add (icon);
container.pack_start (scale);
add (container);
scale.value_changed.connect (() => {
client.set_sink_input_volume (sink_input, (float) scale.get_value ());
scale.tooltip_text = ((int) scale.get_value ()).to_string ();
});
}
public void update (PulseSinkInput sink_input) {
this.sink_input = sink_input;
icon.set_from_icon_name (
sink_input.application_icon_name ?? "application-x-executable",
Gtk.IconSize.DIALOG
);
scale.set_value (sink_input.volume);
scale.tooltip_text = ((int) scale.get_value ()).to_string ();
this.show_all ();
}
}
}

View File

@ -6,12 +6,29 @@ namespace SwayNotificationCenter.Widgets {
}
}
Gtk.Box main_volume_slider_container = new Gtk.Box (Gtk.Orientation.HORIZONTAL, 0);
Gtk.Label label_widget = new Gtk.Label (null);
Gtk.Scale slider = new Gtk.Scale.with_range (Gtk.Orientation.HORIZONTAL, 0, 100, 1);
// Per app volume controll
Gtk.ListBox levels_listbox;
Gtk.Button reveal_button;
Gtk.Revealer revealer;
Gtk.Label no_sink_inputs_label;
string empty_label = "No active sink input";
string expand_label = "";
string collapse_label = "";
int icon_size = 24;
Gtk.RevealerTransitionType revealer_type = Gtk.RevealerTransitionType.SLIDE_DOWN;
int revealer_duration = 250;
private PulseDevice ? default_sink = null;
private PulseDaemon client = new PulseDaemon ();
private bool show_per_app;
construct {
this.client.change_default_device.connect (default_device_changed);
@ -32,12 +49,83 @@ namespace SwayNotificationCenter.Widgets {
if (config != null) {
string ? label = get_prop<string> (config, "label");
label_widget.set_label (label ?? "Volume");
show_per_app = get_prop<bool> (config, "show-per-app") ? true : false;
string ? el = get_prop<string> (config, "empty-list-label");
if (el != null) empty_label = el;
string ? l1 = get_prop<string> (config, "expand-button-label");
if (l1 != null) expand_label = l1;
string ? l2 = get_prop<string> (config, "collapse-button-label");
if (l2 != null) collapse_label = l2;
int i = int.max (get_prop<int> (config, "icon-size"), 0);
if (i != 0) icon_size = i;
revealer_duration = int.max (0, get_prop<int> (config, "animation-duration"));
if (revealer_duration == 0) revealer_duration = 250;
string ? animation_type = get_prop<string> (config, "animation-type");
if (animation_type != null) {
switch (animation_type) {
default:
case "none":
revealer_type = Gtk.RevealerTransitionType.NONE;
break;
case "slide_up":
revealer_type = Gtk.RevealerTransitionType.SLIDE_UP;
break;
case "slide_down":
revealer_type = Gtk.RevealerTransitionType.SLIDE_DOWN;
break;
}
}
}
this.orientation = Gtk.Orientation.VERTICAL;
slider.draw_value = false;
add (label_widget);
pack_start (slider, true, true, 0);
main_volume_slider_container.add (label_widget);
main_volume_slider_container.pack_start (slider, true, true, 0);
add (main_volume_slider_container);
if (show_per_app) {
reveal_button = new Gtk.Button.with_label (expand_label);
revealer = new Gtk.Revealer ();
revealer.transition_type = revealer_type;
revealer.transition_duration = revealer_duration;
levels_listbox = new Gtk.ListBox ();
levels_listbox.get_style_context ().add_class ("per-app-volume");
revealer.add (levels_listbox);
if (this.client.active_sinks.size == 0) {
no_sink_inputs_label = new Gtk.Label (empty_label);
levels_listbox.add (no_sink_inputs_label);
}
foreach (var item in this.client.active_sinks.values) {
levels_listbox.add (new SinkInputRow (item, client, icon_size));
}
this.client.change_active_sink.connect (active_sink_change);
this.client.new_active_sink.connect (active_sink_added);
this.client.remove_active_sink.connect (active_sink_removed);
reveal_button.clicked.connect (() => {
bool show = revealer.reveal_child;
revealer.set_reveal_child (!show);
if (show) {
reveal_button.label = expand_label;
} else {
reveal_button.label = collapse_label;
}
});
main_volume_slider_container.pack_end (reveal_button, false, false, 0);
add (revealer);
}
show_all ();
}
@ -47,6 +135,7 @@ namespace SwayNotificationCenter.Widgets {
this.client.start ();
} else {
this.client.close ();
if (show_per_app) revealer.set_reveal_child (false);
}
}
@ -56,5 +145,41 @@ namespace SwayNotificationCenter.Widgets {
slider.set_value (device.volume);
}
}
private void active_sink_change (PulseSinkInput sink) {
foreach (var row in levels_listbox.get_children ()) {
if (row == null) continue;
var s = (SinkInputRow) row;
if (s.sink_input.cmp (sink)) {
s.update (sink);
break;
}
}
}
private void active_sink_added (PulseSinkInput sink) {
// one element added -> remove the empty label
if (this.client.active_sinks.size == 1) {
var label = levels_listbox.get_children ().first ().data;
levels_listbox.remove ((Gtk.Widget) label);
}
levels_listbox.add (new SinkInputRow (sink, client, icon_size));
show_all ();
}
private void active_sink_removed (PulseSinkInput sink) {
foreach (var row in levels_listbox.get_children ()) {
if (row == null) continue;
var s = (SinkInputRow) row;
if (s.sink_input.cmp (sink)) {
levels_listbox.remove (row);
break;
}
}
if (levels_listbox.get_children ().length () == 0) {
levels_listbox.add (no_sink_inputs_label);
show_all ();
}
}
}
}

View File

@ -44,6 +44,8 @@ widget_sources = [
'controlCenter/widgets/volume/volume.vala',
'controlCenter/widgets/volume/pulseDaemon.vala',
'controlCenter/widgets/volume/pulseDevice.vala',
'controlCenter/widgets/volume/pulseSinkInput.vala',
'controlCenter/widgets/volume/sinkInputRow.vala',
# Widget: Backlight Slider
'controlCenter/widgets/backlight/backlight.vala',
'controlCenter/widgets/backlight/backlightUtil.vala',

View File

@ -291,6 +291,18 @@
border-radius: 12px;
}
.widget-volume>box>button {
background: transparent;
border: none;
}
.per-app-volume {
background-color: @noti-bg-alt;
padding: 4px 8px 8px 8px;
margin: 0px 8px 8px 8px;
border-radius: 12px
}
/* Backlight widget */
.widget-backlight {
background-color: @noti-bg;