[gnome-shell] status/volume: Add device submenus



commit 762b4c20664880af30149e7f6a097f68c27adf83
Author: Florian Müllner <fmuellner gnome org>
Date:   Fri Jul 15 14:35:35 2022 +0200

    status/volume: Add device submenus
    
    In case where there are multiple in- or output devices, pulseaudio
    or pipewire can pick the "wrong" one by default.
    
    Allow users to change devices without opening sound settings by
    adding a submenu to the sliders when there is more than one device.
    
    Part-of: <https://gitlab.gnome.org/GNOME/gnome-shell/-/merge_requests/2380>

 js/ui/status/volume.js | 137 ++++++++++++++++++++++++++++++++++++++++++++-----
 1 file changed, 123 insertions(+), 14 deletions(-)
---
diff --git a/js/ui/status/volume.js b/js/ui/status/volume.js
index 1babc440e7..9668e5bde5 100644
--- a/js/ui/status/volume.js
+++ b/js/ui/status/volume.js
@@ -38,8 +38,18 @@ var StreamSlider = class extends Signals.EventEmitter {
 
         this._control = control;
 
-        this.item = new PopupMenu.PopupBaseMenuItem({ activate: false });
-        this.item.hide();
+        this.item = new PopupMenu.PopupMenuSection();
+
+        const sliderItem = new PopupMenu.PopupBaseMenuItem({activate: false});
+        this.item.addMenuItem(sliderItem);
+
+        const submenuItem = new PopupMenu.PopupSubMenuMenuItem('');
+        this.item.addMenuItem(submenuItem);
+
+        // HACK: Hide the submenu item, its menu is controlled from sliderItem
+        submenuItem.hide();
+
+        this.menu = submenuItem.menu;
 
         this._inDrag = false;
         this._notifyVolumeChangeId = 0;
@@ -62,18 +72,47 @@ var StreamSlider = class extends Signals.EventEmitter {
         });
 
         this._icon = new St.Icon({ style_class: 'popup-menu-icon' });
-        this.item.add(this._icon);
-        this.item.add_child(this._slider);
-        this.item.connect('button-press-event',
+        sliderItem.add(this._icon);
+        sliderItem.add_child(this._slider);
+        sliderItem.connect('button-press-event',
             (actor, event) => this._slider.startDragging(event));
-        this.item.connect('key-press-event',
+        sliderItem.connect('key-press-event',
             (actor, event) => this._slider.emit('key-press-event', event));
-        this.item.connect('scroll-event',
+        sliderItem.connect('scroll-event',
             (actor, event) => this._slider.emit('scroll-event', event));
 
+        this._menuButton = new St.Button({
+            child: new St.Icon({
+                iconName: 'pan-end-symbolic',
+                style_class: 'popup-menu-arrow',
+            }),
+            y_expand: true,
+        });
+        sliderItem.add_child(this._menuButton);
+
+        this._menuButton.connect('clicked', () => this.menu.toggle());
+
+        // In order to keep sliders aligned, do not hide
+        // the menu button, but make it fully transparent
+        this._menuButton.bind_property_full('reactive',
+            this._menuButton, 'opacity',
+            GObject.BindingFlags.DEFAULT,
+            (bind, source) => [true, source ? 255 : 0],
+            null);
+
+        this._deviceItems = new Map();
+
+        this._deviceSection = new PopupMenu.PopupMenuSection();
+        this.menu.addMenuItem(this._deviceSection);
+
+        this.menu.addSettingsAction(_('Sound Settings'),
+            'gnome-sound-panel.desktop');
+
         this._stream = null;
         this._volumeCancellable = null;
         this._icons = [];
+
+        this._sync();
     }
 
     get stream() {
@@ -92,7 +131,7 @@ var StreamSlider = class extends Signals.EventEmitter {
             this.emit('stream-updated');
         }
 
-        this._updateVisibility();
+        this._sync();
     }
 
     _connectStream(stream) {
@@ -101,13 +140,55 @@ var StreamSlider = class extends Signals.EventEmitter {
             'notify::volume', this._updateVolume.bind(this), this);
     }
 
+    _lookupDevice(_id) {
+        throw new GObject.NotImplementedError(
+            `_lookupDevice in ${this.constructor.name}`);
+    }
+
+    _activateDevice(_device) {
+        throw new GObject.NotImplementedError(
+            `_activateDevice in ${this.constructor.name}`);
+    }
+
+    _addDevice(id) {
+        if (this._deviceItems.has(id))
+            return;
+
+        const device = this._lookupDevice(id);
+        if (!device)
+            return;
+
+        const item = new PopupMenu.PopupImageMenuItem(
+            device.get_description(), device.get_gicon());
+        item.connect('activate', () => this._activateDevice(device));
+
+        this._deviceSection.addMenuItem(item);
+        this._deviceItems.set(id, item);
+
+        this._sync();
+    }
+
+    _removeDevice(id) {
+        this._deviceItems.get(id)?.destroy();
+        if (this._deviceItems.delete(id))
+            this._sync();
+    }
+
+    _setActiveDevice(activeId) {
+        for (const [id, item] of this._deviceItems) {
+            item.setOrnament(id === activeId
+                ? PopupMenu.Ornament.CHECK
+                : PopupMenu.Ornament.NONE);
+        }
+    }
+
     _shouldBeVisible() {
         return this._stream != null;
     }
 
-    _updateVisibility() {
-        let visible = this._shouldBeVisible();
-        this.item.visible = visible;
+    _sync() {
+        this.item.actor.visible = this._shouldBeVisible();
+        this._menuButton.reactive = this._deviceItems.size > 1;
     }
 
     scroll(event) {
@@ -216,7 +297,15 @@ var StreamSlider = class extends Signals.EventEmitter {
 var OutputStreamSlider = class extends StreamSlider {
     constructor(control) {
         super(control);
-        this._slider.accessible_name = _("Volume");
+
+        this._slider.accessible_name = _('Volume');
+
+        this._control.connectObject(
+            'output-added', (c, id) => this._addDevice(id),
+            'output-removed', (c, id) => this._removeDevice(id),
+            'active-output-update', (c, id) => this._setActiveDevice(id),
+            this);
+
         this._icons = [
             'audio-volume-muted-symbolic',
             'audio-volume-low-symbolic',
@@ -233,6 +322,14 @@ var OutputStreamSlider = class extends StreamSlider {
         this._portChanged();
     }
 
+    _lookupDevice(id) {
+        return this._control.lookup_output_id(id);
+    }
+
+    _activateDevice(device) {
+        this._control.change_output(device);
+    }
+
     _findHeadphones(sink) {
         // This only works for external headphones (e.g. bluetooth)
         if (sink.get_form_factor() == 'headset' ||
@@ -263,9 +360,13 @@ var OutputStreamSlider = class extends StreamSlider {
 var InputStreamSlider = class extends StreamSlider {
     constructor(control) {
         super(control);
-        this._slider.accessible_name = _("Microphone");
+
+        this._slider.accessible_name = _('Microphone');
 
         this._control.connectObject(
+            'input-added', (c, id) => this._addDevice(id),
+            'input-removed', (c, id) => this._removeDevice(id),
+            'active-input-update', (c, id) => this._setActiveDevice(id),
             'stream-added', () => this._maybeShowInput(),
             'stream-removed', () => this._maybeShowInput(),
             this);
@@ -284,6 +385,14 @@ var InputStreamSlider = class extends StreamSlider {
         this._maybeShowInput();
     }
 
+    _lookupDevice(id) {
+        return this._control.lookup_input_id(id);
+    }
+
+    _activateDevice(device) {
+        this._control.change_input(device);
+    }
+
     _maybeShowInput() {
         // only show input widgets if any application is recording audio
         let showInput = false;
@@ -300,7 +409,7 @@ var InputStreamSlider = class extends StreamSlider {
         }
 
         this._showInput = showInput;
-        this._updateVisibility();
+        this._sync();
     }
 
     _shouldBeVisible() {


[Date Prev][Date Next]   [Thread Prev][Thread Next]   [Thread Index] [Date Index] [Author Index]