From 91f19dfb4ee589489dd6f039467dc6adac398dfb Mon Sep 17 00:00:00 2001 From: Jannis <78504175+JannisPetschenka@users.noreply.github.com> Date: Tue, 11 Apr 2023 10:31:16 +0200 Subject: [PATCH] Per app volume control (#235) --- man/swaync.5.scd | 44 +++++- src/configSchema.json | 40 +++++- .../widgets/menubar/menubar.vala | 12 +- .../widgets/volume/pulseDaemon.vala | 120 +++++++++++++++- .../widgets/volume/pulseSinkInput.vala | 62 +++++++++ .../widgets/volume/sinkInputRow.vala | 49 +++++++ src/controlCenter/widgets/volume/volume.vala | 129 +++++++++++++++++- src/meson.build | 2 + src/style.css | 12 ++ 9 files changed, 456 insertions(+), 14 deletions(-) create mode 100644 src/controlCenter/widgets/volume/pulseSinkInput.vala create mode 100644 src/controlCenter/widgets/volume/sinkInputRow.vala diff --git a/man/swaync.5.scd b/man/swaync.5.scd index 8cbebfd..99dc422 100644 --- a/man/swaync.5.scd +++ b/man/swaync.5.scd @@ -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 ++ diff --git a/src/configSchema.json b/src/configSchema.json index 6308a47..e3a8f02 100644 --- a/src/configSchema.json +++ b/src/configSchema.json @@ -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" } } }, diff --git a/src/controlCenter/widgets/menubar/menubar.vala b/src/controlCenter/widgets/menubar/menubar.vala index c9a5271..a5d55e2 100644 --- a/src/controlCenter/widgets/menubar/menubar.vala +++ b/src/controlCenter/widgets/menubar/menubar.vala @@ -167,10 +167,10 @@ namespace SwayNotificationCenter.Widgets { info ("No label for menu-object given using default"); } - int duration = int.max (0, get_prop (obj, "animation_duration")); + int duration = int.max (0, get_prop (obj, "animation-duration")); if (duration == 0) duration = 250; - string ? animation_type = get_prop (obj, "animation_type"); + string ? animation_type = get_prop (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); + } + } + } } } diff --git a/src/controlCenter/widgets/volume/pulseDaemon.vala b/src/controlCenter/widgets/volume/pulseDaemon.vala index 7671ac2..7742e25 100644 --- a/src/controlCenter/widgets/volume/pulseDaemon.vala +++ b/src/controlCenter/widgets/volume/pulseDaemon.vala @@ -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 sinks { get; private set; } + public HashMap active_sinks { get; private set; } + construct { mainloop = new GLibMainLoop (); sinks = new HashMap (); + + active_sinks = new HashMap (); } 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 diff --git a/src/controlCenter/widgets/volume/pulseSinkInput.vala b/src/controlCenter/widgets/volume/pulseSinkInput.vala new file mode 100644 index 0000000..2836ef6 --- /dev/null +++ b/src/controlCenter/widgets/volume/pulseSinkInput.vala @@ -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 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 (); + } + } +} diff --git a/src/controlCenter/widgets/volume/sinkInputRow.vala b/src/controlCenter/widgets/volume/sinkInputRow.vala new file mode 100644 index 0000000..71593be --- /dev/null +++ b/src/controlCenter/widgets/volume/sinkInputRow.vala @@ -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 (); + } + } +} diff --git a/src/controlCenter/widgets/volume/volume.vala b/src/controlCenter/widgets/volume/volume.vala index 14e8903..5830602 100644 --- a/src/controlCenter/widgets/volume/volume.vala +++ b/src/controlCenter/widgets/volume/volume.vala @@ -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 (config, "label"); label_widget.set_label (label ?? "Volume"); + + show_per_app = get_prop (config, "show-per-app") ? true : false; + + string ? el = get_prop (config, "empty-list-label"); + if (el != null) empty_label = el; + + string ? l1 = get_prop (config, "expand-button-label"); + if (l1 != null) expand_label = l1; + string ? l2 = get_prop (config, "collapse-button-label"); + if (l2 != null) collapse_label = l2; + + int i = int.max (get_prop (config, "icon-size"), 0); + if (i != 0) icon_size = i; + + revealer_duration = int.max (0, get_prop (config, "animation-duration")); + if (revealer_duration == 0) revealer_duration = 250; + + string ? animation_type = get_prop (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 (); + } + } } } diff --git a/src/meson.build b/src/meson.build index 94a8e2f..32afbe5 100644 --- a/src/meson.build +++ b/src/meson.build @@ -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', diff --git a/src/style.css b/src/style.css index 96b84a4..5592591 100644 --- a/src/style.css +++ b/src/style.css @@ -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;