c4c5c4fd5c
Remove the `this.actor = ...` and `this.actor._delegate = this` patterns in most of classes, by inheriting all the actor container classes. Uses interfaces when needed for making sure that multiple classes will implement some required methods or to avoid redefining the same code multiple times. https://gitlab.gnome.org/GNOME/gnome-shell/merge_requests/559
394 lines
12 KiB
JavaScript
394 lines
12 KiB
JavaScript
// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*-
|
|
/* exported Indicator */
|
|
|
|
const { Clutter, Gio, GObject, Gvc, St } = imports.gi;
|
|
const Signals = imports.signals;
|
|
|
|
const Main = imports.ui.main;
|
|
const PanelMenu = imports.ui.panelMenu;
|
|
const PopupMenu = imports.ui.popupMenu;
|
|
const Slider = imports.ui.slider;
|
|
|
|
const ALLOW_AMPLIFIED_VOLUME_KEY = 'allow-volume-above-100-percent';
|
|
|
|
// Each Gvc.MixerControl is a connection to PulseAudio,
|
|
// so it's better to make it a singleton
|
|
let _mixerControl;
|
|
function getMixerControl() {
|
|
if (_mixerControl)
|
|
return _mixerControl;
|
|
|
|
_mixerControl = new Gvc.MixerControl({ name: 'GNOME Shell Volume Control' });
|
|
_mixerControl.open();
|
|
|
|
return _mixerControl;
|
|
}
|
|
|
|
var StreamSlider = class {
|
|
constructor(control) {
|
|
this._control = control;
|
|
|
|
this.item = new PopupMenu.PopupBaseMenuItem({ activate: false });
|
|
|
|
this._slider = new Slider.Slider(0);
|
|
|
|
this._soundSettings = new Gio.Settings({ schema_id: 'org.gnome.desktop.sound' });
|
|
this._soundSettings.connect(`changed::${ALLOW_AMPLIFIED_VOLUME_KEY}`, this._amplifySettingsChanged.bind(this));
|
|
this._amplifySettingsChanged();
|
|
|
|
this._sliderChangedId = this._slider.connect('notify::value',
|
|
this._sliderChanged.bind(this));
|
|
this._slider.connect('drag-end', this._notifyVolumeChange.bind(this));
|
|
|
|
this._icon = new St.Icon({ style_class: 'popup-menu-icon' });
|
|
this.item.add(this._icon);
|
|
this.item.add(this._slider, { expand: true });
|
|
this.item.connect('button-press-event', (actor, event) => {
|
|
return this._slider.startDragging(event);
|
|
});
|
|
this.item.connect('key-press-event', (actor, event) => {
|
|
return this._slider.onKeyPressEvent(actor, event);
|
|
});
|
|
|
|
this._stream = null;
|
|
this._volumeCancellable = null;
|
|
}
|
|
|
|
get stream() {
|
|
return this._stream;
|
|
}
|
|
|
|
set stream(stream) {
|
|
if (this._stream) {
|
|
this._disconnectStream(this._stream);
|
|
}
|
|
|
|
this._stream = stream;
|
|
|
|
if (this._stream) {
|
|
this._connectStream(this._stream);
|
|
this._updateVolume();
|
|
} else {
|
|
this.emit('stream-updated');
|
|
}
|
|
|
|
this._updateVisibility();
|
|
}
|
|
|
|
_disconnectStream(stream) {
|
|
stream.disconnect(this._mutedChangedId);
|
|
this._mutedChangedId = 0;
|
|
stream.disconnect(this._volumeChangedId);
|
|
this._volumeChangedId = 0;
|
|
}
|
|
|
|
_connectStream(stream) {
|
|
this._mutedChangedId = stream.connect('notify::is-muted', this._updateVolume.bind(this));
|
|
this._volumeChangedId = stream.connect('notify::volume', this._updateVolume.bind(this));
|
|
}
|
|
|
|
_shouldBeVisible() {
|
|
return this._stream != null;
|
|
}
|
|
|
|
_updateVisibility() {
|
|
let visible = this._shouldBeVisible();
|
|
this.item.visible = visible;
|
|
}
|
|
|
|
scroll(event) {
|
|
return this._slider.scroll(event);
|
|
}
|
|
|
|
_sliderChanged() {
|
|
if (!this._stream)
|
|
return;
|
|
|
|
let value = this._slider.value;
|
|
let volume = value * this._control.get_vol_max_norm();
|
|
let prevMuted = this._stream.is_muted;
|
|
if (volume < 1) {
|
|
this._stream.volume = 0;
|
|
if (!prevMuted)
|
|
this._stream.change_is_muted(true);
|
|
} else {
|
|
this._stream.volume = volume;
|
|
if (prevMuted)
|
|
this._stream.change_is_muted(false);
|
|
}
|
|
this._stream.push_volume();
|
|
}
|
|
|
|
_notifyVolumeChange() {
|
|
if (this._volumeCancellable)
|
|
this._volumeCancellable.cancel();
|
|
|
|
this._volumeCancellable = new Gio.Cancellable();
|
|
let player = global.display.get_sound_player();
|
|
player.play_from_theme('audio-volume-change',
|
|
_("Volume changed"),
|
|
this._volumeCancellable);
|
|
}
|
|
|
|
_changeSlider(value) {
|
|
GObject.signal_handler_block(this._slider, this._sliderChangedId);
|
|
this._slider.value = value;
|
|
GObject.signal_handler_unblock(this._slider, this._sliderChangedId);
|
|
}
|
|
|
|
_updateVolume() {
|
|
let muted = this._stream.is_muted;
|
|
this._changeSlider(muted
|
|
? 0 : (this._stream.volume / this._control.get_vol_max_norm()));
|
|
this.emit('stream-updated');
|
|
}
|
|
|
|
_amplifySettingsChanged() {
|
|
this._allowAmplified = this._soundSettings.get_boolean(ALLOW_AMPLIFIED_VOLUME_KEY);
|
|
|
|
this._slider.maximum_value = this._allowAmplified
|
|
? this.getMaxLevel() : 1;
|
|
|
|
if (this._stream)
|
|
this._updateVolume();
|
|
}
|
|
|
|
getIcon() {
|
|
if (!this._stream)
|
|
return null;
|
|
|
|
let icons = ["audio-volume-muted-symbolic",
|
|
"audio-volume-low-symbolic",
|
|
"audio-volume-medium-symbolic",
|
|
"audio-volume-high-symbolic",
|
|
"audio-volume-overamplified-symbolic"];
|
|
|
|
let volume = this._stream.volume;
|
|
let n;
|
|
if (this._stream.is_muted || volume <= 0) {
|
|
n = 0;
|
|
} else {
|
|
n = Math.ceil(3 * volume / this._control.get_vol_max_norm());
|
|
if (n < 1)
|
|
n = 1;
|
|
else if (n > 3)
|
|
n = 4;
|
|
}
|
|
return icons[n];
|
|
}
|
|
|
|
getLevel() {
|
|
if (!this._stream)
|
|
return null;
|
|
|
|
return this._stream.volume / this._control.get_vol_max_norm();
|
|
}
|
|
|
|
getMaxLevel() {
|
|
let maxVolume = this._control.get_vol_max_norm();
|
|
if (this._allowAmplified)
|
|
maxVolume = this._control.get_vol_max_amplified();
|
|
|
|
return maxVolume / this._control.get_vol_max_norm();
|
|
}
|
|
};
|
|
Signals.addSignalMethods(StreamSlider.prototype);
|
|
|
|
var OutputStreamSlider = class extends StreamSlider {
|
|
constructor(control) {
|
|
super(control);
|
|
this._slider.accessible_name = _("Volume");
|
|
}
|
|
|
|
_connectStream(stream) {
|
|
super._connectStream(stream);
|
|
this._portChangedId = stream.connect('notify::port', this._portChanged.bind(this));
|
|
this._portChanged();
|
|
}
|
|
|
|
_findHeadphones(sink) {
|
|
// This only works for external headphones (e.g. bluetooth)
|
|
if (sink.get_form_factor() == 'headset' ||
|
|
sink.get_form_factor() == 'headphone')
|
|
return true;
|
|
|
|
// a bit hackish, but ALSA/PulseAudio have a number
|
|
// of different identifiers for headphones, and I could
|
|
// not find the complete list
|
|
if (sink.get_ports().length > 0)
|
|
return sink.get_port().port.includes('headphone');
|
|
|
|
return false;
|
|
}
|
|
|
|
_disconnectStream(stream) {
|
|
super._disconnectStream(stream);
|
|
stream.disconnect(this._portChangedId);
|
|
this._portChangedId = 0;
|
|
}
|
|
|
|
_updateSliderIcon() {
|
|
this._icon.icon_name = (this._hasHeadphones
|
|
? 'audio-headphones-symbolic'
|
|
: 'audio-speakers-symbolic');
|
|
}
|
|
|
|
_portChanged() {
|
|
let hasHeadphones = this._findHeadphones(this._stream);
|
|
if (hasHeadphones != this._hasHeadphones) {
|
|
this._hasHeadphones = hasHeadphones;
|
|
this._updateSliderIcon();
|
|
}
|
|
}
|
|
};
|
|
|
|
var InputStreamSlider = class extends StreamSlider {
|
|
constructor(control) {
|
|
super(control);
|
|
this._slider.accessible_name = _("Microphone");
|
|
this._control.connect('stream-added', this._maybeShowInput.bind(this));
|
|
this._control.connect('stream-removed', this._maybeShowInput.bind(this));
|
|
this._icon.icon_name = 'audio-input-microphone-symbolic';
|
|
}
|
|
|
|
_connectStream(stream) {
|
|
super._connectStream(stream);
|
|
this._maybeShowInput();
|
|
}
|
|
|
|
_maybeShowInput() {
|
|
// only show input widgets if any application is recording audio
|
|
let showInput = false;
|
|
if (this._stream) {
|
|
// skip gnome-volume-control and pavucontrol which appear
|
|
// as recording because they show the input level
|
|
let skippedApps = [
|
|
'org.gnome.VolumeControl',
|
|
'org.PulseAudio.pavucontrol'
|
|
];
|
|
|
|
showInput = this._control.get_source_outputs().some(output => {
|
|
return !skippedApps.includes(output.get_application_id());
|
|
});
|
|
}
|
|
|
|
this._showInput = showInput;
|
|
this._updateVisibility();
|
|
}
|
|
|
|
_shouldBeVisible() {
|
|
return super._shouldBeVisible() && this._showInput;
|
|
}
|
|
};
|
|
|
|
var VolumeMenu = class extends PopupMenu.PopupMenuSection {
|
|
constructor(control) {
|
|
super();
|
|
|
|
this.hasHeadphones = false;
|
|
|
|
this._control = control;
|
|
this._control.connect('state-changed', this._onControlStateChanged.bind(this));
|
|
this._control.connect('default-sink-changed', this._readOutput.bind(this));
|
|
this._control.connect('default-source-changed', this._readInput.bind(this));
|
|
|
|
this._output = new OutputStreamSlider(this._control);
|
|
this._output.connect('stream-updated', () => {
|
|
this.emit('icon-changed');
|
|
});
|
|
this.addMenuItem(this._output.item);
|
|
|
|
this._input = new InputStreamSlider(this._control);
|
|
this._input.item.connect('notify::visible', () => {
|
|
this.emit('input-visible-changed');
|
|
});
|
|
this.addMenuItem(this._input.item);
|
|
|
|
this.addMenuItem(new PopupMenu.PopupSeparatorMenuItem());
|
|
|
|
this._onControlStateChanged();
|
|
}
|
|
|
|
scroll(event) {
|
|
return this._output.scroll(event);
|
|
}
|
|
|
|
_onControlStateChanged() {
|
|
if (this._control.get_state() == Gvc.MixerControlState.READY) {
|
|
this._readInput();
|
|
this._readOutput();
|
|
} else {
|
|
this.emit('icon-changed');
|
|
}
|
|
}
|
|
|
|
_readOutput() {
|
|
this._output.stream = this._control.get_default_sink();
|
|
}
|
|
|
|
_readInput() {
|
|
this._input.stream = this._control.get_default_source();
|
|
}
|
|
|
|
getIcon() {
|
|
return this._output.getIcon();
|
|
}
|
|
|
|
getLevel() {
|
|
return this._output.getLevel();
|
|
}
|
|
|
|
getMaxLevel() {
|
|
return this._output.getMaxLevel();
|
|
}
|
|
|
|
getInputVisible() {
|
|
return this._input.item.visible;
|
|
}
|
|
};
|
|
|
|
var Indicator = GObject.registerClass({
|
|
GTypeName: 'Volume_Indicator'
|
|
}, class Indicator extends PanelMenu.SystemIndicator {
|
|
_init() {
|
|
super._init();
|
|
|
|
this._primaryIndicator = this._addIndicator();
|
|
this._inputIndicator = this._addIndicator();
|
|
|
|
this._control = getMixerControl();
|
|
this._volumeMenu = new VolumeMenu(this._control);
|
|
this._volumeMenu.connect('icon-changed', () => {
|
|
let icon = this._volumeMenu.getIcon();
|
|
|
|
if (icon != null)
|
|
this._primaryIndicator.icon_name = icon;
|
|
this._primaryIndicator.visible = icon !== null;
|
|
});
|
|
|
|
this._inputIndicator.set({
|
|
icon_name: 'audio-input-microphone-symbolic',
|
|
visible: this._volumeMenu.getInputVisible(),
|
|
});
|
|
this._volumeMenu.connect('input-visible-changed', () => {
|
|
this._inputIndicator.visible = this._volumeMenu.getInputVisible();
|
|
});
|
|
|
|
this.menu.addMenuItem(this._volumeMenu);
|
|
|
|
this.connect('scroll-event', this._onScrollEvent.bind(this));
|
|
}
|
|
|
|
_onScrollEvent(actor, event) {
|
|
let result = this._volumeMenu.scroll(event);
|
|
if (result == Clutter.EVENT_PROPAGATE || this.menu.actor.mapped)
|
|
return result;
|
|
|
|
let gicon = new Gio.ThemedIcon({ name: this._volumeMenu.getIcon() });
|
|
let level = this._volumeMenu.getLevel();
|
|
let maxLevel = this._volumeMenu.getMaxLevel();
|
|
Main.osdWindowManager.show(-1, gicon, null, level, maxLevel);
|
|
return result;
|
|
}
|
|
});
|