// Copyright 2022 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
/**
* @fileoverview
* 'audio-settings' allow users to configure their audio settings in system
* settings.
*/
import 'chrome://resources/ash/common/cr_elements/cr_icon_button/cr_icon_button.js';
import 'chrome://resources/ash/common/cr_elements/policy/cr_policy_indicator.js';
import 'chrome://resources/ash/common/cr_elements/cr_slider/cr_slider.js';
import 'chrome://resources/cros_components/badge/badge.js'; // side-effect
import '../icons.html.js';
import '../settings_shared.css.js';
import {PrefsMixin} from '/shared/settings/prefs/prefs_mixin.js';
import {CrSliderElement} from 'chrome://resources/ash/common/cr_elements/cr_slider/cr_slider.js';
import {CrToggleElement} from 'chrome://resources/ash/common/cr_elements/cr_toggle/cr_toggle.js';
import {I18nMixin} from 'chrome://resources/ash/common/cr_elements/i18n_mixin.js';
import {WebUiListenerMixin} from 'chrome://resources/ash/common/cr_elements/web_ui_listener_mixin.js';
import {strictQuery} from 'chrome://resources/ash/common/typescript_utils/strict_query.js';
import {assert} from 'chrome://resources/js/assert.js';
import {loadTimeData} from 'chrome://resources/js/load_time_data.js';
import {PolymerElement} from 'chrome://resources/polymer/v3_0/polymer/polymer_bundled.min.js';
import {DeepLinkingMixin} from '../common/deep_linking_mixin.js';
import {RouteObserverMixin} from '../common/route_observer_mixin.js';
import {AudioDevice, AudioDeviceType, AudioEffectState, AudioSystemProperties, AudioSystemPropertiesObserverReceiver, MuteState} from '../mojom-webui/cros_audio_config.mojom-webui.js';
import {Setting} from '../mojom-webui/setting.mojom-webui.js';
import {AudioAndCaptionsPageBrowserProxy, AudioAndCaptionsPageBrowserProxyImpl} from '../os_a11y_page/audio_and_captions_page_browser_proxy.js';
import {Route, routes} from '../router.js';
import {getTemplate} from './audio.html.js';
import {CrosAudioConfigInterface, getCrosAudioConfig} from './cros_audio_config.js';
import {BatteryStatus, DevicePageBrowserProxy, DevicePageBrowserProxyImpl} from './device_page_browser_proxy.js';
import {FakeCrosAudioConfig} from './fake_cros_audio_config.js';
/** Utility for keeping percent in inclusive range of [0,100]. */
function clampPercent(percent: number): number {
return Math.max(0, Math.min(percent, 100));
}
const SettingsAudioElementBase = WebUiListenerMixin(DeepLinkingMixin(
PrefsMixin(RouteObserverMixin(I18nMixin(PolymerElement)))));
const VOLUME_ICON_OFF_LEVEL = 0;
// TODO(b/271871947): Match volume icon logic to QS revamp sliders.
// Matches level calculated in unified_volume_view.cc.
const VOLUME_ICON_LOUD_LEVEL = 34;
const SETTINGS_20PX_ICON_PREFIX = 'settings20:';
export class SettingsAudioElement extends SettingsAudioElementBase {
static get is() {
return 'settings-audio';
}
static get template() {
return getTemplate();
}
static get properties() {
return {
crosAudioConfig_: {
type: Object,
},
audioSystemProperties_: {
type: Object,
},
isOutputMuted_: {
type: Boolean,
reflectToAttribute: true,
},
isInputMuted_: {
type: Boolean,
reflectToAttribute: true,
},
isNoiseCancellationEnabled_: {
type: Boolean,
},
isNoiseCancellationSupported_: {
type: Boolean,
},
isStyleTransferEnabled_: {
type: Boolean,
},
isStyleTransferSupported_: {
type: Boolean,
},
outputVolume_: {
type: Number,
},
powerSoundsHidden_: {
type: Boolean,
computed: 'computePowerSoundsHidden_(batteryStatus_)',
},
startupSoundEnabled_: {
type: Boolean,
value: false,
},
/**
* Used by DeepLinkingMixin to focus this page's deep links.
*/
supportedSettingIds: {
type: Object,
value: () => new Set<Setting>([
Setting.kChargingSounds,
Setting.kLowBatterySound,
]),
},
showAllowAGC: {
type: Boolean,
value: loadTimeData.getBoolean('enableForceRespectUiGainsToggle'),
readonly: true,
},
isAllowAGCEnabled: {
type: Boolean,
value: true,
observer: SettingsAudioElement.prototype.onAllowAGCEnabledChanged,
},
isHfpMicSrEnabled: {
type: Boolean,
},
showHfpMicSr: {
type: Boolean,
},
showStyleTransfer: {
type: Boolean,
},
};
}
protected isAllowAGCEnabled: boolean;
protected showAllowAGC: boolean;
protected isHfpMicSrEnabled: boolean;
protected showHfpMicSr: boolean;
protected showStyleTransfer: boolean;
private audioAndCaptionsBrowserProxy_: AudioAndCaptionsPageBrowserProxy;
private devicePageBrowserProxy_: DevicePageBrowserProxy;
private audioSystemProperties_: AudioSystemProperties;
private audioSystemPropertiesObserverReceiver_:
AudioSystemPropertiesObserverReceiver;
private crosAudioConfig_: CrosAudioConfigInterface;
private isOutputMuted_: boolean;
private isInputMuted_: boolean;
private isNoiseCancellationEnabled_: boolean;
private isNoiseCancellationSupported_: boolean;
private isStyleTransferEnabled_: boolean;
private isStyleTransferSupported_: boolean;
private outputVolume_: number;
private startupSoundEnabled_: boolean;
private batteryStatus_: BatteryStatus|undefined;
private powerSoundsHidden_: boolean;
private isHfpMicSrSupported_: boolean;
constructor() {
super();
this.crosAudioConfig_ = getCrosAudioConfig();
this.audioSystemPropertiesObserverReceiver_ =
new AudioSystemPropertiesObserverReceiver(this);
this.audioAndCaptionsBrowserProxy_ =
AudioAndCaptionsPageBrowserProxyImpl.getInstance();
this.devicePageBrowserProxy_ = DevicePageBrowserProxyImpl.getInstance();
}
override ready(): void {
super.ready();
this.observeAudioSystemProperties_();
this.addWebUiListener(
'startup-sound-setting-retrieved', (startupSoundEnabled: boolean) => {
this.startupSoundEnabled_ = startupSoundEnabled;
});
this.addWebUiListener(
'battery-status-changed', this.set.bind(this, 'batteryStatus_'));
// Manually call updatePowerStatus to ensure batteryStatus_ is initialized
// and up to date.
this.devicePageBrowserProxy_.updatePowerStatus();
}
/**
* AudioSystemPropertiesObserverInterface override
*/
onPropertiesUpdated(properties: AudioSystemProperties): void {
this.audioSystemProperties_ = properties;
this.isOutputMuted_ =
this.audioSystemProperties_.outputMuteState !== MuteState.kNotMuted;
this.isInputMuted_ =
this.audioSystemProperties_.inputMuteState !== MuteState.kNotMuted;
const activeInputDevice = this.audioSystemProperties_.inputDevices.find(
(device: AudioDevice) => device.isActive);
this.isNoiseCancellationEnabled_ =
(activeInputDevice?.noiseCancellationState ===
AudioEffectState.kEnabled);
this.isNoiseCancellationSupported_ =
!(activeInputDevice?.noiseCancellationState ===
AudioEffectState.kNotSupported);
this.isStyleTransferEnabled_ =
(activeInputDevice?.styleTransferState === AudioEffectState.kEnabled);
this.isStyleTransferSupported_ = activeInputDevice?.styleTransferState !==
AudioEffectState.kNotSupported;
this.isAllowAGCEnabled =
(activeInputDevice?.forceRespectUiGainsState ===
AudioEffectState.kNotEnabled);
this.outputVolume_ = this.audioSystemProperties_.outputVolumePercent;
this.isHfpMicSrEnabled =
(activeInputDevice?.hfpMicSrState === AudioEffectState.kEnabled);
this.isHfpMicSrSupported_ = activeInputDevice !== undefined &&
activeInputDevice?.hfpMicSrState !== AudioEffectState.kNotSupported;
this.showHfpMicSr =
(this.isHfpMicSrSupported_ &&
loadTimeData.getBoolean('enableAudioHfpMicSRToggle'));
}
getIsOutputMutedForTest(): boolean {
return this.isOutputMuted_;
}
getIsInputMutedForTest(): boolean {
return this.isInputMuted_;
}
private observeAudioSystemProperties_(): void {
// Use fake observer implementation to access additional properties not
// available on mojo interface.
if (this.crosAudioConfig_ instanceof FakeCrosAudioConfig) {
this.crosAudioConfig_.observeAudioSystemProperties(this);
return;
}
this.crosAudioConfig_.observeAudioSystemProperties(
this.audioSystemPropertiesObserverReceiver_.$
.bindNewPipeAndPassRemote());
}
/** Determines if audio output is muted by policy. */
protected isOutputMutedByPolicy_(): boolean {
return this.audioSystemProperties_.outputMuteState ===
MuteState.kMutedByPolicy;
}
protected onInputMuteClicked(): void {
this.crosAudioConfig_.setInputMuted(!this.isInputMuted_);
}
/** Handles updating active input device. */
protected onInputDeviceChanged(): void {
const inputDeviceSelect = this.shadowRoot!.querySelector<HTMLSelectElement>(
'#audioInputDeviceDropdown');
assert(!!inputDeviceSelect);
this.crosAudioConfig_.setActiveDevice(BigInt(inputDeviceSelect.value));
}
/** Handles updates to force respect ui gains state. */
protected onAllowAGCEnabledChanged(
enabled: SettingsAudioElement['isAllowAGCEnabled'],
previousEnabled: SettingsAudioElement['isAllowAGCEnabled']): void {
// Polymer triggers change event on all assignment to
// `isAllowAGCEnabled` even if the value is logically unchanged.
// Check previous value before calling `setAllowAGCEnabled` to
// test if value actually updated.
if (previousEnabled === undefined || previousEnabled === enabled) {
return;
}
this.crosAudioConfig_.setForceRespectUiGainsEnabled(!enabled);
}
/**
* Handles the event where the input volume slider is being changed.
*/
protected onInputVolumeSliderChanged(): void {
const sliderValue = this.shadowRoot!
.querySelector<CrSliderElement>(
'#audioInputGainVolumeSlider')!.value;
this.crosAudioConfig_.setInputGainPercent(clampPercent(sliderValue));
}
/**
* Handles the event where the output volume slider is being changed.
*/
private onOutputVolumeSliderChanged_(): void {
const sliderValue =
this.shadowRoot!.querySelector<CrSliderElement>(
'#outputVolumeSlider')!.value;
this.crosAudioConfig_.setOutputVolumePercent(clampPercent(sliderValue));
}
/** Handles updating active output device. */
protected onOutputDeviceChanged(): void {
const outputDeviceSelect =
this.shadowRoot!.querySelector<HTMLSelectElement>(
'#audioOutputDeviceDropdown');
assert(!!outputDeviceSelect);
this.crosAudioConfig_.setActiveDevice(BigInt(outputDeviceSelect.value));
}
/** Handles updating outputMuteState. */
protected onOutputMuteButtonClicked(): void {
this.crosAudioConfig_.setOutputMuted(!this.isOutputMuted_);
}
override currentRouteChanged(route: Route): void {
// Does not apply to this page.
// TODO(crbug.com/1092970): Add DeepLinkingMixin and attempt deep link.
if (route !== routes.AUDIO) {
return;
}
this.audioAndCaptionsBrowserProxy_.getStartupSoundEnabled();
}
/** Handles updating the mic icon depending on the input mute state. */
protected getInputIcon_(): string {
return this.isInputMuted_ ? 'settings:mic-off' : 'cr:mic';
}
/**
* Handles updating the output icon depending on the output mute state and
* volume.
*/
protected getOutputIcon_(): string {
if (this.isOutputMuted_) {
return SETTINGS_20PX_ICON_PREFIX + 'volume-up-off';
}
if (this.outputVolume_ === VOLUME_ICON_OFF_LEVEL) {
return SETTINGS_20PX_ICON_PREFIX + 'volume-zero';
}
if (this.outputVolume_ < VOLUME_ICON_LOUD_LEVEL) {
return SETTINGS_20PX_ICON_PREFIX + 'volume-down';
}
return SETTINGS_20PX_ICON_PREFIX + 'volume-up';
}
/**
* Handles the case when there are no output devices. The output section
* should be hidden in this case.
*/
protected getOutputHidden_(): boolean {
return this.audioSystemProperties_.outputDevices.length === 0;
}
/**
* Handles the case when there are no input devices. The input section should
* be hidden in this case.
*/
protected getInputHidden_(): boolean {
return this.audioSystemProperties_.inputDevices.length === 0;
}
/**
* Returns true if input is muted by physical switch; otherwise, return false.
*/
protected shouldDisableInputGainControls(): boolean {
return this.audioSystemProperties_.inputMuteState ===
MuteState.kMutedExternally;
}
/** Translates the device name if applicable. */
private getDeviceName_(audioDevice: AudioDevice): string {
switch (audioDevice.deviceType) {
case AudioDeviceType.kHeadphone:
return this.i18n('audioDeviceHeadphoneLabel');
case AudioDeviceType.kMic:
return this.i18n('audioDeviceMicJackLabel');
case AudioDeviceType.kUsb:
return this.i18n('audioDeviceUsbLabel', audioDevice.displayName);
case AudioDeviceType.kBluetooth:
case AudioDeviceType.kBluetoothNbMic:
return this.i18n('audioDeviceBluetoothLabel', audioDevice.displayName);
case AudioDeviceType.kHdmi:
return this.i18n('audioDeviceHdmiLabel', audioDevice.displayName);
case AudioDeviceType.kInternalSpeaker:
return this.i18n('audioDeviceInternalSpeakersLabel');
case AudioDeviceType.kInternalMic:
return this.i18n('audioDeviceInternalMicLabel');
case AudioDeviceType.kFrontMic:
return this.i18n('audioDeviceFrontMicLabel');
case AudioDeviceType.kRearMic:
return this.i18n('audioDeviceRearMicLabel');
default:
return audioDevice.displayName;
}
}
/**
* Returns the appropriate tooltip for output and input device mute buttons
* based on `muteState`.
*/
private getMuteTooltip_(muteState: MuteState): string {
switch (muteState) {
case MuteState.kNotMuted:
return this.i18n('audioToggleToMuteTooltip');
case MuteState.kMutedByUser:
return this.i18n('audioToggleToUnmuteTooltip');
case MuteState.kMutedByPolicy:
return this.i18n('audioMutedByPolicyTooltip');
case MuteState.kMutedExternally:
return this.i18n('audioMutedExternallyTooltip');
default:
return '';
}
}
/** Returns the appropriate aria-label for input mute button. */
protected getInputMuteButtonAriaLabel(): string {
if (this.audioSystemProperties_.inputMuteState ===
MuteState.kMutedExternally) {
return this.i18n('audioInputMuteButtonAriaLabelMutedByHardwareSwitch');
}
return this.isInputMuted_ ?
this.i18n('audioInputMuteButtonAriaLabelMuted') :
this.i18n('audioInputMuteButtonAriaLabelNotMuted');
}
/** Returns the appropriate aria-label for output mute button. */
protected getOutputMuteButtonAriaLabel(): string {
return this.isOutputMuted_ ?
this.i18n('audioOutputMuteButtonAriaLabelMuted') :
this.i18n('audioOutputMuteButtonAriaLabelNotMuted');
}
private toggleNoiseCancellationEnabled_(e: CustomEvent<boolean>): void {
this.crosAudioConfig_.setNoiseCancellationEnabled(e.detail);
}
private toggleStyleTransferEnabled_(e: CustomEvent<boolean>): void {
this.crosAudioConfig_.setStyleTransferEnabled(e.detail);
}
private toggleHfpMicSrEnabled_(e: CustomEvent<boolean>): void {
this.crosAudioConfig_.setHfpMicSrEnabled(e.detail);
}
private toggleStartupSoundEnabled_(e: CustomEvent<boolean>): void {
this.audioAndCaptionsBrowserProxy_.setStartupSoundEnabled(e.detail);
}
private computePowerSoundsHidden_(): boolean {
return !this.batteryStatus_?.present;
}
private onDeviceStartupSoundRowClicked_(): void {
this.startupSoundEnabled_ = !this.startupSoundEnabled_;
this.audioAndCaptionsBrowserProxy_.setStartupSoundEnabled(
this.startupSoundEnabled_);
}
private onNoiseCancellationRowClicked_(): void {
const noiseCancellationToggle = strictQuery(
'#audioInputNoiseCancellationToggle', this.shadowRoot, CrToggleElement);
this.crosAudioConfig_.setNoiseCancellationEnabled(
!noiseCancellationToggle.checked);
}
private onStyleTransferRowClicked_(): void {
const styleTransferToggle = strictQuery(
'#audioInputStyleTransferToggle', this.shadowRoot, CrToggleElement);
this.crosAudioConfig_.setStyleTransferEnabled(!styleTransferToggle.checked);
}
}
declare global {
interface HTMLElementTagNameMap {
'settings-audio': SettingsAudioElement;
}
}
customElements.define(SettingsAudioElement.is, SettingsAudioElement);