// Copyright 2024 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'chrome://resources/cros_components/switch/switch.js';
import 'chrome://resources/mwc/@material/web/progress/circular-progress.js';
import './cra/cra-button.js';
import './cra/cra-dialog.js';
import './cra/cra-icon.js';
import './cra/cra-icon-button.js';
import './settings-row.js';
import './speaker-label-consent-dialog.js';
import './transcription-consent-dialog.js';
import {
Switch as CrosSwitch,
} from 'chrome://resources/cros_components/switch/switch.js';
import {
createRef,
css,
html,
live,
nothing,
ref,
} from 'chrome://resources/mwc/lit/index.js';
import {i18n} from '../core/i18n.js';
import {usePlatformHandler} from '../core/lit/context.js';
import {ReactiveLitElement} from '../core/reactive/lit.js';
import {
settings,
SpeakerLabelEnableState,
SummaryEnableState,
TranscriptionEnableState,
} from '../core/state/settings.js';
import {
assertExhaustive,
assertInstanceof,
assertNotReached,
} from '../core/utils/assert.js';
import {CraDialog} from './cra/cra-dialog.js';
import {SpeakerLabelConsentDialog} from './speaker-label-consent-dialog.js';
import {TranscriptionConsentDialog} from './transcription-consent-dialog.js';
/**
* Settings menu for Recording app.
*/
export class SettingsMenu extends ReactiveLitElement {
static override styles = css`
:host {
display: block;
}
cra-dialog {
--md-dialog-container-color: var(--cros-sys-surface3);
/* 16px margin for each side at minimum size. */
max-height: calc(100% - 32px);
max-width: calc(100% - 32px);
width: 512px;
@container style(--dark-theme: 1) {
/*
* TODO: b/336963138 - This is neutral5 in spec but there's no
* neutral5 in colors.css.
*/
--md-dialog-container-color: var(--cros-sys-app_base_shaded);
}
}
div[slot="content"] {
padding: 0;
}
#header {
color: var(--cros-sys-primary);
font: var(--cros-title-1-font);
padding: 24px;
position: relative;
& > cra-icon-button {
position: absolute;
right: 16px;
top: 16px;
}
}
#body {
background: var(--cros-sys-surface1);
border-radius: 20px;
display: flex;
flex-flow: column;
gap: 8px;
padding: 0 16px 16px;
@container style(--dark-theme: 1) {
background: var(--cros-sys-app_base);
}
}
.section {
padding-top: 8px;
& > .title {
color: var(--cros-sys-primary);
font: var(--cros-button-2-font);
padding: 8px;
}
& > .body {
border-radius: 16px;
display: flex;
flex-flow: column;
gap: 1px;
/* To have the border-radius applied to content. */
overflow: hidden;
}
}
settings-row cra-button md-circular-progress {
--md-circular-progress-active-indicator-color: var(--cros-sys-disabled);
/*
* This has a lower precedence than the size override in cros-button,
* but still need to be set to have correct line width.
*/
--md-circular-progress-size: 24px;
/*
* This is to override the size setting for slotted element in
* cros-button. On figma the circular progress have 2px padding, but
* md-circular-progres has a non-configurable 4px padding. Setting a
* negative margin so the extra padding doesn't expand the button size.
*/
height: 24px;
margin: -2px;
width: 24px;
}
`;
private readonly platformHandler = usePlatformHandler();
private readonly dialog = createRef<CraDialog>();
private readonly transcriptionConsentDialog =
createRef<TranscriptionConsentDialog>();
private readonly speakerLabelConsentDialog =
createRef<SpeakerLabelConsentDialog>();
show(): void {
this.dialog.value?.show();
}
private get summaryEnabled() {
return settings.value.summaryEnabled === SummaryEnableState.ENABLED;
}
private onDownloadSummaryClick() {
settings.mutate((s) => {
s.summaryEnabled = SummaryEnableState.ENABLED;
});
this.platformHandler.summaryModelLoader.download();
// The settings download both the model for summary and title suggestion.
this.platformHandler.titleSuggestionModelLoader.download();
}
private onSummaryToggle(ev: Event) {
const target = assertInstanceof(ev.target, CrosSwitch);
settings.mutate((s) => {
s.summaryEnabled = target.selected ? SummaryEnableState.ENABLED :
SummaryEnableState.DISABLED;
});
}
private renderSummaryModelDescriptionAndAction() {
const state = this.platformHandler.summaryModelLoader.state.value;
if (state.kind === 'notInstalled') {
// Shows the "download" button when the summary model is not installed,
// even if it's already enabled by user. This shouldn't happen in normal
// case, but might happen if DLC is cleared manually by any mean.
return html`
<span slot="description">
${i18n.settingsOptionsSummaryDescription}
<!-- TODO: b/336963138 - Add correct link -->
<a href="javascript:;">
${i18n.settingsOptionsSummaryLearnMoreLink}
</a>
</span>
<cra-button
slot="action"
button-style="secondary"
.label=${i18n.settingsOptionsSummaryDownloadButton}
@click=${this.onDownloadSummaryClick}
></cra-button>
`;
}
const summaryToggle = html`
<cros-switch
slot="action"
.selected=${this.summaryEnabled}
@change=${this.onSummaryToggle}
>
</cros-switch>
`;
if (!this.summaryEnabled) {
return summaryToggle;
}
switch (state.kind) {
case 'unavailable':
return assertNotReached(
'Summary model unavailable but the setting is rendered.',
);
case 'error':
// TODO: b/344784638 - Render error state.
return nothing;
case 'installing': {
const progressDescription =
i18n.settingsOptionsSummaryDownloadingProgressDescription(
state.progress,
);
return html`
<span slot="description">${progressDescription}</span>
<cra-button
slot="action"
button-style="secondary"
.label=${i18n.settingsOptionsSummaryDownloadingButton}
disabled
>
<md-circular-progress indeterminate slot="leading-icon">
</md-circular-progress>
</cra-button>
`;
}
case 'installed':
return summaryToggle;
default:
assertExhaustive(state.kind);
}
}
private renderSummaryModelSettings() {
if (this.platformHandler.summaryModelLoader.state.value.kind ===
'unavailable') {
return nothing;
}
return html`
<settings-row>
<span slot="label">${i18n.settingsOptionsSummaryLabel}</span>
${this.renderSummaryModelDescriptionAndAction()}
</settings-row>
`;
}
private onSpeakerLabelToggle() {
switch (settings.value.speakerLabelEnabled) {
case SpeakerLabelEnableState.ENABLED:
settings.mutate((s) => {
s.speakerLabelEnabled = SpeakerLabelEnableState.DISABLED;
});
return;
case SpeakerLabelEnableState.DISABLED:
settings.mutate((s) => {
s.speakerLabelEnabled = SpeakerLabelEnableState.ENABLED;
});
return;
case SpeakerLabelEnableState.UNKNOWN:
case SpeakerLabelEnableState.DISABLED_FIRST:
this.speakerLabelConsentDialog.value?.show();
// This force the switch to be re-rendered so it'll catch the "live"
// value and set selected back to false.
this.requestUpdate();
return;
default:
assertExhaustive(settings.value.speakerLabelEnabled);
}
}
private renderSpeakerLabelSettings() {
if (!this.platformHandler.canUseSpeakerLabel.value) {
return nothing;
}
const speakerLabelEnabled =
settings.value.speakerLabelEnabled === SpeakerLabelEnableState.ENABLED;
return html`
<settings-row>
<span slot="label">${i18n.settingsOptionsSpeakerLabelLabel}</span>
<span slot="description">
${i18n.settingsOptionsSpeakerLabelDescription}
</span>
<cros-switch
slot="action"
.selected=${live(speakerLabelEnabled)}
@change=${this.onSpeakerLabelToggle}
></cros-switch>
</settings-row>
`;
}
private renderTranscriptionDetailSettings() {
if (!this.transcriptionEnabled ||
this.platformHandler.sodaState.value.kind === 'notInstalled') {
return nothing;
}
return [
this.renderSpeakerLabelSettings(),
this.renderSummaryModelSettings(),
];
}
private onCloseClick() {
this.dialog.value?.close();
}
private onTranscriptionToggle() {
// TODO(pihsun): This is the same as in toggleTranscriptionEnabled in
// record-page.ts, consider how to centralize the logic for all
// transcription enable/available state transitions.
switch (settings.value.transcriptionEnabled) {
case TranscriptionEnableState.ENABLED:
settings.mutate((s) => {
s.transcriptionEnabled = TranscriptionEnableState.DISABLED;
});
return;
case TranscriptionEnableState.DISABLED:
settings.mutate((s) => {
s.transcriptionEnabled = TranscriptionEnableState.ENABLED;
});
return;
case TranscriptionEnableState.UNKNOWN:
case TranscriptionEnableState.DISABLED_FIRST:
this.transcriptionConsentDialog.value?.show();
// This force the switch to be re-rendered so it'll catch the "live"
// value and set selected back to false.
this.requestUpdate();
return;
default:
assertExhaustive(settings.value.transcriptionEnabled);
}
}
private onInstallSodaClick() {
// TODO(pihsun): This is the same as in toggleTranscriptionEnabled in
// record-page.ts, consider how to centralize the logic for all
// transcription enable/available state transitions.
switch (settings.value.transcriptionEnabled) {
case TranscriptionEnableState.ENABLED:
case TranscriptionEnableState.DISABLED:
settings.mutate((s) => {
s.transcriptionEnabled = TranscriptionEnableState.ENABLED;
});
this.platformHandler.installSoda();
return;
case TranscriptionEnableState.UNKNOWN:
case TranscriptionEnableState.DISABLED_FIRST:
this.transcriptionConsentDialog.value?.show();
return;
default:
assertExhaustive(settings.value.transcriptionEnabled);
}
}
private get transcriptionEnabled() {
return (
settings.value.transcriptionEnabled === TranscriptionEnableState.ENABLED
);
}
private renderTranscriptionDescriptionAndAction() {
const sodaState = this.platformHandler.sodaState.value;
if (sodaState.kind === 'notInstalled') {
// Shows the "download" button when SODA is not installed, even if it's
// already enabled by user. This shouldn't happen in normal case, but
// might happen if DLC is cleared manually by any mean.
return html`
<cra-button
slot="action"
button-style="secondary"
.label=${i18n.settingsOptionsTranscriptionDownloadButton}
@click=${this.onInstallSodaClick}
></cra-button>
`;
}
const transcriptionToggle = html`
<cros-switch
slot="action"
.selected=${live(this.transcriptionEnabled)}
@change=${this.onTranscriptionToggle}
>
</cros-switch>
`;
if (!this.transcriptionEnabled) {
return transcriptionToggle;
}
switch (sodaState.kind) {
case 'unavailable':
return assertNotReached(
'SODA unavailable but the setting is rendered.',
);
case 'error':
// TODO: b/344784638 - Render error state.
return nothing;
case 'installing': {
const progressDescription =
i18n.settingsOptionsTranscriptionDownloadingProgressDescription(
sodaState.progress,
);
return html`
<span slot="description">${progressDescription}</span>
<cra-button
slot="action"
button-style="secondary"
.label=${i18n.settingsOptionsTranscriptionDownloadingButton}
disabled
>
<md-circular-progress indeterminate slot="leading-icon">
</md-circular-progress>
</cra-button>
`;
}
case 'installed':
return transcriptionToggle;
default:
assertExhaustive(sodaState.kind);
}
}
private renderTranscriptionSection() {
if (this.platformHandler.sodaState.value.kind === 'unavailable') {
return nothing;
}
return html`
<div class="section">
<div class="title">
${i18n.settingsSectionTranscriptionSummaryHeader}
</div>
<div class="body">
<settings-row>
<span slot="label">
${i18n.settingsOptionsTranscriptionLabel}
</span>
${this.renderTranscriptionDescriptionAndAction()}
</settings-row>
${this.renderTranscriptionDetailSettings()}
</div>
</div>
`;
}
private onDoNotDisturbToggle() {
this.platformHandler.quietMode.update((s) => !s);
}
private renderDoNotDisturbSettingsRow() {
return html`
<settings-row>
<span slot="label">${i18n.settingsOptionsDoNotDisturbLabel}</span>
<span slot="description">
${i18n.settingsOptionsDoNotDisturbDescription}
</span>
<cros-switch
slot="action"
.selected=${live(this.platformHandler.quietMode.value)}
@change=${this.onDoNotDisturbToggle}
></cros-switch>
</settings-row>
`;
}
private onKeepScreenOnToggle() {
settings.mutate((s) => {
s.keepScreenOn = !s.keepScreenOn;
});
}
private renderKeepScreenOnSettingsRow() {
return html`
<settings-row>
<span slot="label">${i18n.settingsOptionsKeepScreenOnLabel}</span>
<cros-switch
slot="action"
.selected=${live(settings.value.keepScreenOn)}
@change=${this.onKeepScreenOnToggle}
></cros-switch>
</settings-row>
`;
}
override render(): RenderResult {
// TODO: b/354109582 - Implement actual functionality of keep screen on.
return html`<cra-dialog ${ref(this.dialog)}>
<div slot="content">
<div id="header">
${i18n.settingsHeader}
<cra-icon-button
buttonstyle="floating"
size="small"
shape="circle"
@click=${this.onCloseClick}
>
<cra-icon slot="icon" name="close"></cra-icon>
</cra-icon-button>
</div>
<div id="body">
<div class="section">
<div class="title">${i18n.settingsSectionGeneralHeader}</div>
<div class="body">
${this.renderDoNotDisturbSettingsRow()}
${this.renderKeepScreenOnSettingsRow()}
</div>
</div>
${this.renderTranscriptionSection()}
</div>
</div>
</cra-dialog>
<transcription-consent-dialog ${ref(this.transcriptionConsentDialog)}>
</transcription-consent-dialog>
<speaker-label-consent-dialog ${ref(this.speakerLabelConsentDialog)}>
</speaker-label-consent-dialog>`;
}
}
window.customElements.define('settings-menu', SettingsMenu);
declare global {
interface HTMLElementTagNameMap {
'settings-menu': SettingsMenu;
}
}