diff options
Diffstat (limited to 'js/ui/status/volume.js')
-rw-r--r-- | js/ui/status/volume.js | 458 |
1 files changed, 458 insertions, 0 deletions
diff --git a/js/ui/status/volume.js b/js/ui/status/volume.js new file mode 100644 index 0000000..bd49cc3 --- /dev/null +++ b/js/ui/status/volume.js @@ -0,0 +1,458 @@ +// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- +/* exported Indicator */ + +const {Clutter, Gio, GLib, GObject, Gvc} = imports.gi; + +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'; + +// 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(); + + 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._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.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 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.includes('headphone'); + + return false; + } + + _portChanged() { + const hasHeadphones = this._findHeadphones(this._stream); + if (hasHeadphones === this._hasHeadphones) + return; + + this._hasHeadphones = hasHeadphones; + this.iconName = this._hasHeadphones + ? 'audio-headphones-symbolic' + : 'audio-speakers-symbolic'; + } +}); + +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; + } +}); + +var Indicator = GObject.registerClass( +class Indicator extends SystemIndicator { + _init() { + super._init(); + + this._primaryIndicator = this._addIndicator(); + this._inputIndicator = this._addIndicator(); + + this._primaryIndicator.reactive = true; + this._inputIndicator.reactive = true; + + this._primaryIndicator.connect('scroll-event', + (actor, event) => this._handleScrollEvent(this._output, event)); + this._inputIndicator.connect('scroll-event', + (actor, event) => this._handleScrollEvent(this._input, event)); + + this._control = getMixerControl(); + this._control.connectObject( + 'state-changed', () => this._onControlStateChanged(), + 'default-sink-changed', () => this._readOutput(), + 'default-source-changed', () => this._readInput(), + this); + + this._output = new OutputStreamSlider(this._control); + this._output.connect('stream-updated', () => { + const icon = this._output.getIcon(); + + if (icon) + this._primaryIndicator.icon_name = icon; + this._primaryIndicator.visible = icon !== null; + }); + + this._input = new InputStreamSlider(this._control); + this._input.connect('stream-updated', () => { + const icon = this._input.getIcon(); + + if (icon) + this._inputIndicator.icon_name = icon; + }); + + this._input.bind_property('visible', + this._inputIndicator, 'visible', + GObject.BindingFlags.SYNC_CREATE); + + this.quickSettingsItems.push(this._output); + this.quickSettingsItems.push(this._input); + + this._onControlStateChanged(); + } + + _onControlStateChanged() { + if (this._control.get_state() === Gvc.MixerControlState.READY) { + this._readInput(); + this._readOutput(); + } else { + this._primaryIndicator.hide(); + } + } + + _readOutput() { + this._output.stream = this._control.get_default_sink(); + } + + _readInput() { + this._input.stream = this._control.get_default_source(); + } + + _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; + } +}); |