// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*-

const Clutter = imports.gi.Clutter;
const Lang = imports.lang;
const Gio = imports.gi.Gio;
const Gvc = imports.gi.Gvc;
const St = imports.gi.St;
const Signals = imports.signals;

const PanelMenu = imports.ui.panelMenu;
const PopupMenu = imports.ui.popupMenu;

const VOLUME_ADJUSTMENT_STEP = 0.05; /* Volume adjustment step in % */

const VOLUME_NOTIFY_ID = 1;

// 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;
}

const StreamSlider = new Lang.Class({
    Name: 'StreamSlider',

    _init: function(control, title) {
        this._control = control;

        this.item = new PopupMenu.PopupMenuSection();

        this._title = new PopupMenu.PopupMenuItem(title, { reactive: false });
        this._slider = new PopupMenu.PopupSliderMenuItem(0);
        this._slider.connect('value-changed', Lang.bind(this, this._sliderChanged));
        this._slider.connect('drag-end', Lang.bind(this, this._notifyVolumeChange));

        this.item.addMenuItem(this._title);
        this.item.addMenuItem(this._slider);

        this._stream = null;
        this._shouldShow = true;
    },

    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: function(stream) {
        stream.disconnect(this._mutedChangedId);
        this._mutedChangedId = 0;
        stream.disconnect(this._volumeChangedId);
        this._volumeChangedId = 0;
    },

    _connectStream: function(stream) {
        this._mutedChangedId = stream.connect('notify::is-muted', Lang.bind(this, this._updateVolume));
        this._volumeChangedId = stream.connect('notify::volume', Lang.bind(this, this._updateVolume));
    },

    _shouldBeVisible: function() {
        return this._stream != null;
    },

    _updateVisibility: function() {
        let visible = this._shouldBeVisible();
        this._title.actor.visible = visible;
        this._slider.actor.visible = visible;
    },

    scroll: function(event) {
        this._slider.scroll(event);
    },

    setValue: function(value) {
        // piggy-back off of sliderChanged
        this._slider.setValue(value);
    },

    _sliderChanged: function(slider, value, property) {
        if (!this._stream)
            return;

        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: function() {
        global.cancel_theme_sound(VOLUME_NOTIFY_ID);
        global.play_theme_sound(VOLUME_NOTIFY_ID,
                                'audio-volume-change',
                                _("Volume changed"),
                                Clutter.get_current_event ());
    },

    _updateVolume: function() {
        let muted = this._stream.is_muted;
        this._slider.setValue(muted ? 0 : (this._stream.volume / this._control.get_vol_max_norm()));
        this.emit('stream-updated');
    },

    getIcon: function() {
        if (!this._stream)
            return null;

        let volume = this._stream.volume;
        if (this._stream.is_muted || volume <= 0) {
            return 'audio-volume-muted-symbolic';
        } else {
            let n = Math.floor(3 * volume / this._control.get_vol_max_norm()) + 1;
            if (n < 2)
                return 'audio-volume-low-symbolic';
            if (n >= 3)
                return 'audio-volume-high-symbolic';
            return 'audio-volume-medium-symbolic';
        }
    }
});
Signals.addSignalMethods(StreamSlider.prototype);

const OutputStreamSlider = new Lang.Class({
    Name: 'OutputStreamSlider',
    Extends: StreamSlider,

    _connectStream: function(stream) {
        this.parent(stream);
        this._portChangedId = stream.connect('notify::port', Lang.bind(this, this._portChanged));
        this._portChanged();
    },

    _findHeadphones: function(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.indexOf('headphone') >= 0;

        return false;
    },

    _disconnectStream: function(stream) {
        this.parent(stream);
        stream.disconnect(this._portChangedId);
        this._portChangedId = 0;
    },

    _portChanged: function() {
        let hasHeadphones = this._findHeadphones(this._stream);
        if (hasHeadphones != this._hasHeadphones) {
            this._hasHeadphones = hasHeadphones;
            this.emit('headphones-changed', this._hasHeadphones);
        }
    }
});

const InputStreamSlider = new Lang.Class({
    Name: 'InputStreamSlider',
    Extends: StreamSlider,

    _init: function(control, title) {
        this.parent(control, title);
        this._control.connect('stream-added', Lang.bind(this, this._maybeShowInput));
        this._control.connect('stream-removed', Lang.bind(this, this._maybeShowInput));
    },

    _connectStream: function(stream) {
        this.parent(stream);
        this._maybeShowInput();
    },

    _maybeShowInput: function() {
        // only show input widgets if any application is recording audio
        let showInput = false;
        let recordingApps = this._control.get_source_outputs();
        if (this._stream && recordingApps) {
            for (let i = 0; i < recordingApps.length; i++) {
                let outputStream = recordingApps[i];
                let id = outputStream.get_application_id();
                // but skip gnome-volume-control and pavucontrol
                // (that appear as recording because they show the input level)
                if (!id || (id != 'org.gnome.VolumeControl' && id != 'org.PulseAudio.pavucontrol')) {
                    showInput = true;
                    break;
                }
            }
        }

        this._showInput = showInput;
        this._updateVisibility();
    },

    _shouldBeVisible: function() {
        return this.parent() && this._showInput;
    }
});

const VolumeMenu = new Lang.Class({
    Name: 'VolumeMenu',
    Extends: PopupMenu.PopupMenuSection,

    _init: function(control) {
        this.parent();

        this.hasHeadphones = false;

        this._control = control;
        this._control.connect('state-changed', Lang.bind(this, this._onControlStateChanged));
        this._control.connect('default-sink-changed', Lang.bind(this, this._readOutput));
        this._control.connect('default-source-changed', Lang.bind(this, this._readInput));

        /* Translators: This is the label for audio volume */
        this._output = new OutputStreamSlider(this._control, _("Volume"));
        this._output.connect('stream-updated', Lang.bind(this, function() {
            this.emit('icon-changed');
        }));
        this._output.connect('headphones-changed', Lang.bind(this, function(stream, value) {
            this.emit('headphones-changed', value);
        }));
        this.addMenuItem(this._output.item);

        this._input = new InputStreamSlider(this._control, _("Microphone"));
        this.addMenuItem(this._input.item);

        this.addMenuItem(new PopupMenu.PopupSeparatorMenuItem());

        this._onControlStateChanged();
    },

    scroll: function(event) {
        this._output.scroll(event);
    },

    _onControlStateChanged: function() {
        if (this._control.get_state() == Gvc.MixerControlState.READY) {
            this._readInput();
            this._readOutput();
        } else {
            this.emit('icon-changed');
        }
    },

    _readOutput: function() {
        this._output.stream = this._control.get_default_sink();
    },

    _readInput: function() {
        this._input.stream = this._control.get_default_source();
    },

    getIcon: function() {
        return this._output.getIcon();
    }
});

const Indicator = new Lang.Class({
    Name: 'VolumeIndicator',
    Extends: PanelMenu.SystemStatusButton,

    _init: function() {
        this.parent('audio-volume-muted-symbolic', _("Volume"));

        this._control = getMixerControl();
        this._volumeMenu = new VolumeMenu(this._control);
        this._volumeMenu.connect('icon-changed', Lang.bind(this, function(menu) {
            let icon = this._volumeMenu.getIcon();
            this.actor.visible = (icon != null);
            this.setIcon(icon);
        }));
        this._volumeMenu.connect('headphones-changed', Lang.bind(this, function(menu, value) {
            this._headphoneIcon.visible = value;
        }));

        this._headphoneIcon = this.addIcon(new Gio.ThemedIcon({ name: 'headphones-symbolic' }));
        this._headphoneIcon.visible = false;

        this.menu.addMenuItem(this._volumeMenu);

        this.menu.addMenuItem(new PopupMenu.PopupSeparatorMenuItem());
        this.menu.addSettingsAction(_("Sound Settings"), 'gnome-sound-panel.desktop');

        this.actor.connect('scroll-event', Lang.bind(this, this._onScrollEvent));
    },

    _onScrollEvent: function(actor, event) {
        this._volumeMenu.scroll(event);
    }
});
