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>
This commit is contained in:
Florian Müllner 2022-07-15 14:35:35 +02:00
parent cf404f76c0
commit 762b4c2066

View File

@ -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() {