02f1952851
Use the new privacy indicator class for the input one and move it next to the other privacy indicators. While on it move all privacy indicators to the front, following the system-status-indicators mockup. Part-of: <https://gitlab.gnome.org/GNOME/gnome-shell/-/merge_requests/2840>
514 lines
15 KiB
JavaScript
514 lines
15 KiB
JavaScript
// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*-
|
||
/* exported OutputIndicator InputIndicator */
|
||
|
||
const Clutter = imports.gi.Clutter;
|
||
const Gio = imports.gi.Gio;
|
||
const GLib = imports.gi.GLib;
|
||
const GObject = imports.gi.GObject;
|
||
const Gvc = imports.gi.Gvc;
|
||
|
||
const Main = imports.ui.main;
|
||
const PopupMenu = imports.ui.popupMenu;
|
||
|
||
const {QuickSlider, SystemIndicator} = imports.ui.quickSettings;
|
||
|
||
const ALLOW_AMPLIFIED_VOLUME_KEY = 'allow-volume-above-100-percent';
|
||
const UNMUTE_DEFAULT_VOLUME = 0.25;
|
||
|
||
// Each Gvc.MixerControl is a connection to PulseAudio,
|
||
// so it's better to make it a singleton
|
||
let _mixerControl;
|
||
/**
|
||
* @returns {Gvc.MixerControl} - the mixer control singleton
|
||
*/
|
||
function getMixerControl() {
|
||
if (_mixerControl)
|
||
return _mixerControl;
|
||
|
||
_mixerControl = new Gvc.MixerControl({ name: 'GNOME Shell Volume Control' });
|
||
_mixerControl.open();
|
||
|
||
return _mixerControl;
|
||
}
|
||
|
||
const StreamSlider = GObject.registerClass({
|
||
Signals: {
|
||
'stream-updated': {},
|
||
},
|
||
}, class StreamSlider extends QuickSlider {
|
||
_init(control) {
|
||
super._init({
|
||
icon_reactive: true,
|
||
});
|
||
|
||
this._control = control;
|
||
|
||
this._inDrag = false;
|
||
this._notifyVolumeChangeId = 0;
|
||
|
||
this._soundSettings = new Gio.Settings({
|
||
schema_id: 'org.gnome.desktop.sound',
|
||
});
|
||
this._soundSettings.connect(`changed::${ALLOW_AMPLIFIED_VOLUME_KEY}`,
|
||
() => this._amplifySettingsChanged());
|
||
this._amplifySettingsChanged();
|
||
|
||
this._sliderChangedId = this.slider.connect('notify::value',
|
||
() => this._sliderChanged());
|
||
this.slider.connect('drag-begin', () => (this._inDrag = true));
|
||
this.slider.connect('drag-end', () => {
|
||
this._inDrag = false;
|
||
this._notifyVolumeChange();
|
||
});
|
||
|
||
this.connect('icon-clicked', () => {
|
||
if (!this._stream)
|
||
return;
|
||
|
||
const {isMuted} = this._stream;
|
||
if (isMuted && this._stream.volume === 0) {
|
||
this._stream.volume =
|
||
UNMUTE_DEFAULT_VOLUME * this._control.get_vol_max_norm();
|
||
this._stream.push_volume();
|
||
}
|
||
this._stream.change_is_muted(!isMuted);
|
||
});
|
||
|
||
this._deviceItems = new Map();
|
||
|
||
this._deviceSection = new PopupMenu.PopupMenuSection();
|
||
this.menu.addMenuItem(this._deviceSection);
|
||
|
||
this.menu.addMenuItem(new PopupMenu.PopupSeparatorMenuItem());
|
||
this.menu.addSettingsAction(_('Sound Settings'),
|
||
'gnome-sound-panel.desktop');
|
||
|
||
this._stream = null;
|
||
this._volumeCancellable = null;
|
||
this._icons = [];
|
||
|
||
this._sync();
|
||
}
|
||
|
||
get stream() {
|
||
return this._stream;
|
||
}
|
||
|
||
set stream(stream) {
|
||
this._stream?.disconnectObject(this);
|
||
|
||
this._stream = stream;
|
||
|
||
if (this._stream) {
|
||
this._connectStream(this._stream);
|
||
this._updateVolume();
|
||
} else {
|
||
this.emit('stream-updated');
|
||
}
|
||
|
||
this._sync();
|
||
}
|
||
|
||
_connectStream(stream) {
|
||
stream.connectObject(
|
||
'notify::is-muted', this._updateVolume.bind(this),
|
||
'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 {description, origin} = device;
|
||
const name = origin
|
||
? `${description} – ${origin}`
|
||
: description;
|
||
const item = new PopupMenu.PopupImageMenuItem(name, 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;
|
||
}
|
||
|
||
_sync() {
|
||
this.visible = this._shouldBeVisible();
|
||
this.menuEnabled = this._deviceItems.size > 1;
|
||
}
|
||
|
||
_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;
|
||
let prevVolume = this._stream.volume;
|
||
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();
|
||
|
||
let volumeChanged = this._stream.volume !== prevVolume;
|
||
if (volumeChanged && !this._notifyVolumeChangeId && !this._inDrag) {
|
||
this._notifyVolumeChangeId = GLib.timeout_add(GLib.PRIORITY_DEFAULT, 30, () => {
|
||
this._notifyVolumeChange();
|
||
this._notifyVolumeChangeId = 0;
|
||
return GLib.SOURCE_REMOVE;
|
||
});
|
||
GLib.Source.set_name_by_id(this._notifyVolumeChangeId,
|
||
'[gnome-shell] this._notifyVolumeChangeId');
|
||
}
|
||
}
|
||
|
||
_notifyVolumeChange() {
|
||
if (this._volumeCancellable)
|
||
this._volumeCancellable.cancel();
|
||
this._volumeCancellable = null;
|
||
|
||
if (this._stream.state === Gvc.MixerStreamState.RUNNING)
|
||
return; // feedback not necessary while playing
|
||
|
||
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) {
|
||
this.slider.block_signal_handler(this._sliderChangedId);
|
||
this.slider.value = value;
|
||
this.slider.unblock_signal_handler(this._sliderChangedId);
|
||
}
|
||
|
||
_updateVolume() {
|
||
let muted = this._stream.is_muted;
|
||
this._changeSlider(muted
|
||
? 0 : this._stream.volume / this._control.get_vol_max_norm());
|
||
this.iconLabel = muted ? _('Unmute') : _('Mute');
|
||
this._updateIcon();
|
||
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();
|
||
}
|
||
|
||
_updateIcon() {
|
||
this.iconName = this.getIcon();
|
||
}
|
||
|
||
getIcon() {
|
||
if (!this._stream)
|
||
return null;
|
||
|
||
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());
|
||
n = Math.clamp(n, 1, this._icons.length - 1);
|
||
}
|
||
return this._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();
|
||
}
|
||
});
|
||
|
||
const OutputStreamSlider = GObject.registerClass(
|
||
class OutputStreamSlider extends StreamSlider {
|
||
_init(control) {
|
||
super._init(control);
|
||
|
||
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',
|
||
'audio-volume-medium-symbolic',
|
||
'audio-volume-high-symbolic',
|
||
'audio-volume-overamplified-symbolic',
|
||
];
|
||
|
||
this.menu.setHeader('audio-headphones-symbolic', _('Sound Output'));
|
||
}
|
||
|
||
_connectStream(stream) {
|
||
super._connectStream(stream);
|
||
stream.connectObject('notify::port',
|
||
this._portChanged.bind(this), this);
|
||
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' ||
|
||
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.toLowerCase().includes('headphone');
|
||
|
||
return false;
|
||
}
|
||
|
||
_portChanged() {
|
||
const hasHeadphones = this._findHeadphones(this._stream);
|
||
if (hasHeadphones === this._hasHeadphones)
|
||
return;
|
||
|
||
this._hasHeadphones = hasHeadphones;
|
||
this._updateIcon();
|
||
}
|
||
|
||
_updateIcon() {
|
||
this.iconName = this._hasHeadphones
|
||
? 'audio-headphones-symbolic'
|
||
: this.getIcon();
|
||
}
|
||
});
|
||
|
||
const InputStreamSlider = GObject.registerClass(
|
||
class InputStreamSlider extends StreamSlider {
|
||
_init(control) {
|
||
super._init(control);
|
||
|
||
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);
|
||
|
||
this.iconName = 'audio-input-microphone-symbolic';
|
||
this._icons = [
|
||
'microphone-sensitivity-muted-symbolic',
|
||
'microphone-sensitivity-low-symbolic',
|
||
'microphone-sensitivity-medium-symbolic',
|
||
'microphone-sensitivity-high-symbolic',
|
||
];
|
||
|
||
this.menu.setHeader('audio-input-microphone-symbolic', _('Sound Input'));
|
||
}
|
||
|
||
_connectStream(stream) {
|
||
super._connectStream(stream);
|
||
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;
|
||
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 => !skippedApps.includes(output.get_application_id()));
|
||
}
|
||
|
||
this._showInput = showInput;
|
||
this._sync();
|
||
}
|
||
|
||
_shouldBeVisible() {
|
||
return super._shouldBeVisible() && this._showInput;
|
||
}
|
||
});
|
||
|
||
let VolumeIndicator = GObject.registerClass(
|
||
class VolumeIndicator extends SystemIndicator {
|
||
constructor() {
|
||
super();
|
||
|
||
this._indicator = this._addIndicator();
|
||
this._indicator.reactive = true;
|
||
}
|
||
|
||
_handleScrollEvent(item, event) {
|
||
const result = item.slider.scroll(event);
|
||
if (result === Clutter.EVENT_PROPAGATE || item.mapped)
|
||
return result;
|
||
|
||
const gicon = new Gio.ThemedIcon({name: item.getIcon()});
|
||
const level = item.getLevel();
|
||
const maxLevel = item.getMaxLevel();
|
||
Main.osdWindowManager.show(-1, gicon, null, level, maxLevel);
|
||
return result;
|
||
}
|
||
});
|
||
|
||
var OutputIndicator = GObject.registerClass(
|
||
class OutputIndicator extends VolumeIndicator {
|
||
constructor() {
|
||
super();
|
||
|
||
this._indicator.connect('scroll-event',
|
||
(actor, event) => this._handleScrollEvent(this._output, event));
|
||
|
||
this._control = getMixerControl();
|
||
this._control.connectObject(
|
||
'state-changed', () => this._onControlStateChanged(),
|
||
'default-sink-changed', () => this._readOutput(),
|
||
this);
|
||
|
||
this._output = new OutputStreamSlider(this._control);
|
||
this._output.connect('stream-updated', () => {
|
||
const icon = this._output.getIcon();
|
||
|
||
if (icon)
|
||
this._indicator.icon_name = icon;
|
||
this._indicator.visible = icon !== null;
|
||
});
|
||
|
||
this.quickSettingsItems.push(this._output);
|
||
|
||
this._onControlStateChanged();
|
||
}
|
||
|
||
_onControlStateChanged() {
|
||
if (this._control.get_state() === Gvc.MixerControlState.READY)
|
||
this._readOutput();
|
||
else
|
||
this._indicator.hide();
|
||
}
|
||
|
||
_readOutput() {
|
||
this._output.stream = this._control.get_default_sink();
|
||
}
|
||
});
|
||
|
||
var InputIndicator = GObject.registerClass(
|
||
class InputIndicator extends VolumeIndicator {
|
||
constructor() {
|
||
super();
|
||
|
||
this._indicator.add_style_class_name('privacy-indicator');
|
||
|
||
this._indicator.connect('scroll-event',
|
||
(actor, event) => this._handleScrollEvent(this._input, event));
|
||
|
||
this._control = getMixerControl();
|
||
this._control.connectObject(
|
||
'state-changed', () => this._onControlStateChanged(),
|
||
'default-source-changed', () => this._readInput(),
|
||
this);
|
||
|
||
this._input = new InputStreamSlider(this._control);
|
||
this._input.connect('stream-updated', () => {
|
||
const icon = this._input.getIcon();
|
||
|
||
if (icon)
|
||
this._indicator.icon_name = icon;
|
||
});
|
||
|
||
this._input.bind_property('visible',
|
||
this._indicator, 'visible',
|
||
GObject.BindingFlags.SYNC_CREATE);
|
||
|
||
this.quickSettingsItems.push(this._input);
|
||
|
||
this._onControlStateChanged();
|
||
}
|
||
|
||
_onControlStateChanged() {
|
||
if (this._control.get_state() === Gvc.MixerControlState.READY)
|
||
this._readInput();
|
||
}
|
||
|
||
_readInput() {
|
||
this._input.stream = this._control.get_default_source();
|
||
}
|
||
});
|