chromium/ash/webui/recorder_app_ui/resources/pages/record-page.ts

// 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 '../components/audio-waveform.js';
import '../components/cra/cra-button.js';
import '../components/cra/cra-dialog.js';
import '../components/cra/cra-icon-button.js';
import '../components/cra/cra-image.js';
import '../components/cra/cra-menu.js';
import '../components/cra/cra-menu-item.js';
import '../components/delete-recording-dialog.js';
import '../components/recording-file-list.js';
import '../components/secondary-button.js';
import '../components/transcription-view.js';
import '../components/transcription-consent-dialog.js';

import {
  classMap,
  createRef,
  css,
  html,
  live,
  nothing,
  PropertyDeclarations,
  ref,
} from 'chrome://resources/mwc/lit/index.js';

import {CraDialog} from '../components/cra/cra-dialog.js';
import {CraMenu} from '../components/cra/cra-menu.js';
import {DeleteRecordingDialog} from '../components/delete-recording-dialog.js';
import {
  TranscriptionConsentDialog,
} from '../components/transcription-consent-dialog.js';
import {i18n, replacePlaceholderWithHtml} from '../core/i18n.js';
import {
  usePlatformHandler,
  useRecordingDataManager,
} from '../core/lit/context.js';
import {ReactiveLitElement} from '../core/reactive/lit.js';
import {computed, Dispose, effect, signal} from '../core/reactive/signal.js';
import {RecordingCreateParams} from '../core/recording_data_manager.js';
import {RecordingSession} from '../core/recording_session.js';
import {navigateTo} from '../core/state/route.js';
import {
  settings,
  SpeakerLabelEnableState,
  TranscriptionEnableState,
} from '../core/state/settings.js';
import {
  assertExhaustive,
  assertExists,
  assertInstanceof,
} from '../core/utils/assert.js';
import {AsyncJobQueue} from '../core/utils/async_job_queue.js';
import {formatDuration} from '../core/utils/datetime.js';

function getDefaultTitle(): string {
  // The default title is always in English and not translated, since it's also
  // used as exported filename.
  const now = new Date();
  const year = now.getFullYear();
  const month = (now.getMonth() + 1).toString().padStart(2, '0');
  const day = now.getDate().toString().padStart(2, '0');
  const time = new Intl.DateTimeFormat('en-US', {
    hour: 'numeric',
    minute: '2-digit',
    second: '2-digit',
    hour12: true,
  });
  return `Audio recording ${year}-${month}-${day} ${time.format(now)}`;
}

/**
 * Record page of Recorder App.
 */
export class RecordPage extends ReactiveLitElement {
  static override styles = css`
    :host {
      background-color: var(--cros-sys-app_base);
      box-sizing: border-box;
      display: flex;
      flex-flow: column;
      gap: 4px;
      height: 100%;
      padding: 16px;
      width: 100%;
    }

    #main-area {
      border-radius: 16px;
      display: flex;
      flex: 1;
      flex-flow: column;
      gap: 4px;

      /* To have the border-radius applied to content. */
      overflow: hidden;
    }

    .sheet {
      background-color: var(--cros-sys-app_base_shaded);
      border-radius: 4px;
    }

    #header {
      align-items: center;
      display: flex;
      flex-flow: row;
      padding: 8px;
    }

    #title {
      flex: 1;
      font: var(--cros-button-1-font);
      margin: 0 0 0 12px;
    }

    #middle {
      display: flex;
      flex: 1;
      flex-flow: row;
      gap: 4px;
    }

    #audio-waveform-container,
    #transcription-container {
      /*
       * Makes both side full height without having these "expand" the main
       * area height when it's too high.
       */
      box-sizing: border-box;
      flex: 1;
      height: 0;
      min-height: 100%;
    }

    #transcription-container {
      align-items: center;
      display: none;
      justify-content: center;
    }

    .show-transcription {
      #transcription-container {
        display: flex;
      }

      @container style(--small-viewport: 1) {
        #audio-waveform-container {
          display: none;
        }
      }
    }

    #transcription-waiting {
      font: var(--cros-display-6-font);
    }

    #transcription-consent {
      align-items: center;
      display: flex;
      flex-flow: column;
      text-align: center;
      width: 352px;

      & > .header {
        font: var(--cros-headline-1-font);
        margin-top: 16px;
      }

      & > .description {
        font: var(--cros-body-2-font);
        margin-top: 8px;

        & > cra-icon {
          display: inline-block;
          height: 20px;
          vertical-align: middle;
          width: 20px;
        }
      }

      & > .actions {
        display: flex;
        flex-flow: row;
        gap: 8px;
        margin-top: 16px;
      }
    }

    audio-waveform,
    transcription-view {
      box-sizing: border-box;
      height: 100%;
      width: 100%;
    }

    #audio-waveform-container {
      position: relative;

      & > cra-icon-button {
        bottom: 28px;
        left: 0;
        margin: 0 auto;
        position: absolute;
        right: 0;
        width: fit-content;
      }
    }

    #footer {
      align-items: center;
      display: flex;
      flex-flow: column;
      gap: 24px;
      padding: 24px 0 16px;
    }

    #timer {
      align-items: center;
      display: flex;
      flex-flow: row;
      font: 440 24px/32px var(--monospace-font-family);
      gap: 8px;
      height: 32px;
      letter-spacing: 0.03em;

      & > svg {
        color: var(--cros-sys-on_error_container);
        height: 12px;
        transition:
          color 500ms var(--cros-ref-motion-easing-emphasized-accelerate),
          opacity 500ms var(--cros-ref-motion-easing-emphasized-accelerate);
        width: 12px;

        .paused & {
          color: var(--cros-sys-secondary);
          opacity: 0.5;
        }
      }

      & > span {
        vertical-align: middle;
      }
    }

    #actions {
      align-items: center;
      display: flex;
      flex-flow: row;
      gap: 24px;
    }

    #pause-button {
      transition: opacity 500ms
        var(--cros-ref-motion-easing-emphasized-accelerate);

      .paused & {
        opacity: 0.5;
      }
    }

    #stop-record {
      --cra-button-container-height: 96px;
      --cra-button-icon-gap: 10px;
      --cra-button-label-text-font-family: var(
        --cros-display-6_regular-font-family
      );
      --cra-button-label-text-line-height: var(
        --cros-display-6_regular-line-height
      );
      --cra-button-label-text-size: var(--cros-display-6_regular-font-size);
      --cra-button-label-text-weight: var(--cros-display-6_regular-font-weight);
      --cra-button-leading-space: 48px;
      --cra-button-trailing-space: 48px;
      --cra-button-hover-state-layer-color: var(--cros-sys-hover_on_subtle);
      --cra-button-pressed-state-layer-color: var(
        --cros-sys-ripple_neutral_on_subtle
      );
      --cros-button-max-width: 400px;

      /*
       * TODO: b/336963138 - Currently the ripple still use the primary color,
       * which looks very bad.
       */
      --md-filled-button-container-color: var(--cros-sys-error_container);
      --md-filled-button-label-text-color: var(--cros-sys-on_error_container);
      --md-filled-button-hover-label-text-color: var(
        --cros-sys-on_error_container
      );
      --md-filled-button-pressed-label-text-color: var(
        --cros-sys-on_error_container
      );
      --md-filled-button-focus-label-text-color: var(
        --cros-sys-on_error_container
      );

      margin: 0;

      @container style(--small-viewport: 1) {
        --cra-button-container-height: 80px;
        --cra-button-leading-space: 32px;
        --cra-button-trailing-space: 32px;
      }

      & > cra-icon {
        height: 32px;
        width: 32px;
      }
    }

    #exit-dialog {
      width: 440px;

      & div[slot="actions"] cra-button:first-child {
        align-self: flex-start;
        margin-right: auto;
      }
    }
  `;

  static override properties: PropertyDeclarations = {
    includeSystemAudio: {type: Boolean},
    micId: {type: String},
  };

  includeSystemAudio: boolean = false;

  micId: string|null = null;

  private readonly recordingTitle: string = getDefaultTitle();

  private readonly recordingSession = signal<RecordingSession|null>(null);

  private readonly recordingDataManager = useRecordingDataManager();

  private readonly platformHandler = usePlatformHandler();

  // TODO: b/336963138 - Handle when transcription isn't available.
  private readonly transcriptionShown = signal(false);

  private readonly transcriptionEnabled = computed(
    () =>
      settings.value.transcriptionEnabled === TranscriptionEnableState.ENABLED,
  );

  private readonly transcriptionAvailable = computed(
    () => this.platformHandler.sodaState.value.kind !== 'unavailable',
  );

  private transcriptionEnableDispose: Dispose|null = null;

  private readonly menu = createRef<CraMenu>();

  private readonly deleteDialog = createRef<DeleteRecordingDialog>();

  private readonly transcriptionConsentDialog =
    createRef<TranscriptionConsentDialog>();

  private readonly stopRecordingButton = createRef<HTMLButtonElement>();

  private wakeLock: WakeLockSentinel|null = null;

  private readonly wakeLockRequestQueue = new AsyncJobQueue('keepLatest');

  private readonly micMuted = signal(false);

  private readonly recordingPaused = signal(false);

  private readonly recordingControlQueue = new AsyncJobQueue('enqueue');

  get stopRecordingButtonForTest(): HTMLButtonElement {
    return assertExists(this.stopRecordingButton.value);
  }

  private async startRecording() {
    if (this.recordingSession.value !== null) {
      return;
    }

    const speakerLabelEnabled = this.platformHandler.canUseSpeakerLabel.value &&
      settings.value.speakerLabelEnabled === SpeakerLabelEnableState.ENABLED;

    const session = await RecordingSession.create({
      micId: assertExists(this.micId),
      includeSystemAudio: this.includeSystemAudio,
      platformHandler: this.platformHandler,
      speakerLabelEnabled,
    });

    try {
      // Don't enable SODA if it's unavailable. All UI to enable transcription
      // are gated behind transcriptionAvailable.
      await session.start(
        this.transcriptionEnabled.value && this.transcriptionAvailable.value,
      );
    } catch (e) {
      if (e instanceof DOMException &&
          e.message.includes('Permission denied')) {
        // Permission denied, maybe user clicked cancel. Return to the main
        // page in this case.
        // TODO(pihsun): Better error handling/reporting and ask user to retry.
        navigateTo('/');
      } else {
        console.error(e);
      }
      return;
    }

    this.transcriptionEnableDispose = effect(() => {
      // TODO(pihsun): This is a bit fragile now since this relies on the
      // startNewSodaSession and stopSodaSession both calls AsyncJobQueue,
      // which always run things async so signal won't be tracked as
      // dependency. Since we only want the transcriptionEnabled as dependency
      // here, add either watch() to manually specify dependencies for effect,
      // or add untrack() to specify region that dependencies shouldn't be
      // tracked.
      if (this.transcriptionEnabled.value &&
          this.transcriptionAvailable.value) {
        session.startNewSodaSession();
      } else {
        session.stopSodaSession();
      }
    });
    this.recordingSession.value = session;
    if (settings.value.keepScreenOn) {
      this.requestWakeLock();
    }
  }

  private requestWakeLock() {
    this.wakeLockRequestQueue.push(async () => {
      // Don't request a new wake lock when the old one is still in effect,
      // since according to
      // https://w3c.github.io/screen-wake-lock/#garbage-collection we
      // shouldn't drop the wake lock that is not released.
      if (this.wakeLock === null || this.wakeLock.released) {
        this.wakeLock = await navigator.wakeLock.request('screen');
      }
    });
  }

  private releaseWakeLock() {
    this.wakeLockRequestQueue.push(async () => {
      if (this.wakeLock !== null) {
        await this.wakeLock.release();
        this.wakeLock = null;
      }
    });
  }

  private async cancelRecording() {
    if (this.recordingSession.value === null) {
      return;
    }
    this.releaseWakeLock();
    await this.recordingSession.value.finish();
    this.transcriptionEnableDispose?.();
    this.transcriptionEnableDispose = null;
    this.recordingSession.value = null;
  }

  /**
   * Stops and saves the recording.
   *
   * @return The id of the saved recording.
   */
  private async stopRecording(): Promise<string|null> {
    if (this.recordingSession.value === null) {
      return null;
    }
    this.releaseWakeLock();
    const session = this.recordingSession.value;
    const audioData = await session.finish();
    const params: RecordingCreateParams = {
      title: this.recordingTitle,
      durationMs: Math.round(session.progress.value.length * 1000),
      recordedAt: Date.now(),
      powers: session.progress.value.powers.array,
      transcription: session.progress.value.transcription,
    };
    const id = await this.recordingDataManager.createRecording(
      params,
      audioData,
    );

    this.transcriptionEnableDispose?.();
    this.transcriptionEnableDispose = null;
    this.recordingSession.value = null;
    return id;
  }

  private onStopRecording() {
    this.recordingControlQueue.push(async () => {
      const id = await this.stopRecording();
      if (id !== null) {
        navigateTo(`/playback?id=${id}`);
      }
    });
  }

  private readonly onVisibilityChange = () => {
    if (this.wakeLock !== null && this.wakeLock.released &&
        document.visibilityState === 'visible') {
      // Re-acquire the wake lock if the recorder app is brought back to
      // foreground.
      // See https://developer.chrome.com/docs/capabilities/web-apis/wake-lock/
      // TODO(pihsun): We need to have a private API if it's required to have
      // screen kept on when recording in background.
      this.requestWakeLock();
    }
  };

  override async connectedCallback(): Promise<void> {
    super.connectedCallback();
    // TODO(pihsun): auto-starting the recording since this page is arrived
    // from clicking "record" button from the main page. Reconsider how to do
    // this properly.
    await this.startRecording();
    document.addEventListener('visibilitychange', this.onVisibilityChange);
  }

  override async disconnectedCallback(): Promise<void> {
    super.disconnectedCallback();
    document.removeEventListener('visibilitychange', this.onVisibilityChange);
    // Cancel current recording when leaving page / hot reloading.
    // TODO: b/336963138 - Have a confirmation before leaving.
    // TODO: b/336963138 - Exit handler for the whole page.
    await this.cancelRecording();
  }

  private toggleTranscriptionShown() {
    this.transcriptionShown.update((s) => !s);
  }

  private toggleTranscriptionEnabled() {
    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;
        });
        this.platformHandler.installSoda();
        return;
      case TranscriptionEnableState.UNKNOWN:
      case TranscriptionEnableState.DISABLED_FIRST:
        this.transcriptionConsentDialog.value?.show();
        return;
      default:
        assertExhaustive(settings.value.transcriptionEnabled);
    }
  }

  private get exitDialog(): CraDialog|null {
    const el = this.shadowRoot?.querySelector('#exit-dialog') ?? null;
    if (el === null) {
      return null;
    }
    return assertInstanceof(el, CraDialog);
  }

  private onDeleteButtonClick() {
    this.deleteDialog.value?.show();
  }

  private onPauseButtonClick() {
    this.recordingControlQueue.push(async () => {
      this.recordingPaused.update((s) => !s);
      // TODO(pihsun): Animate when paused state change.
      await this.recordingSession.value?.setPaused(this.recordingPaused.value);
    });
  }

  private async deleteRecording() {
    // TODO(pihsun): Make this function sync since it's called as event handler.
    await this.cancelRecording();
    navigateTo('/');
  }

  private onToggleMuted() {
    this.micMuted.update((s) => !s);
    this.recordingSession.value?.setMicMuted(this.micMuted.value);
  }

  private renderAudioWaveform() {
    if (this.recordingSession.value === null) {
      return nothing;
    }
    const session = this.recordingSession.value;
    return html`
      <audio-waveform .values=${session.progress.value.powers}>
      </audio-waveform>
      <cra-icon-button shape="circle" @click=${this.onToggleMuted}>
        <cra-icon
          slot="icon"
          .name=${this.micMuted.value ? 'mic_mute' : 'mic'}
        ></cra-icon>
      </cra-icon-button>
    `;
  }

  private renderTranscription() {
    if (this.recordingSession.value === null) {
      return nothing;
    }
    // TODO: b/336963138 - Animation while opening/closing the panel.
    const session = this.recordingSession.value;
    const {transcription} = session.progress.value;
    if (transcription !== null && !transcription.isEmpty()) {
      // If there are existing transcription, it is always shown even if the
      // transcription is disabled afterwards.
      return html`<transcription-view .transcription=${transcription}>
      </transcription-view>`;
    }

    // Note that the image transcript.svg is currently placeholders and don't
    // use dynamic color tokens yet.
    // TODO: b/344785475 - Change to final illustration when ready.
    switch (settings.value.transcriptionEnabled) {
      case TranscriptionEnableState.ENABLED:
        return html`<div id="transcription-waiting">
          ${i18n.transcriptionWaitingSpeechText}
        </div>`;
      case TranscriptionEnableState.DISABLED:
      case TranscriptionEnableState.DISABLED_FIRST: {
        const description = replacePlaceholderWithHtml(
          i18n.recordTranscriptionOffDescription,
          '[3dot]',
          html`<cra-icon name="more_vertical"></cra-icon>`,
        );
        return html`
          <div id="transcription-consent">
            <cra-image name="transcription_off"></cra-image>
            <div class="header">${i18n.recordTranscriptionOffHeader}</div>
            <div class="description">${description}</div>
          </div>
        `;
      }
      case TranscriptionEnableState.UNKNOWN: {
        function disableTranscription() {
          settings.mutate((s) => {
            s.transcriptionEnabled = TranscriptionEnableState.DISABLED_FIRST;
          });
        }
        return html`
          <div id="transcription-consent">
            <cra-image name="transcription_enable"></cra-image>
            <div class="header">
              ${i18n.recordTranscriptionEntryPointHeader}
            </div>
            <div class="description">
              ${i18n.recordTranscriptionEntryPointDescription}
            </div>
            <div class="actions">
              <cra-button
                .label=${i18n.recordTranscriptionEntryPointDisableButton}
                button-style="secondary"
                @click=${disableTranscription}
              ></cra-button>
              <cra-button
                .label=${i18n.recordTranscriptionEntryPointEnableButton}
                @click=${this.toggleTranscriptionEnabled}
              ></cra-button>
            </div>
          </div>
        `;
      }
      default:
        assertExhaustive(settings.value.transcriptionEnabled);
    }
  }

  private renderTimer() {
    if (this.recordingSession.value === null) {
      return nothing;
    }

    const recordingLength = formatDuration(
      {
        seconds: this.recordingSession.value.progress.value.length,
      },
      1,
    );
    return html`<svg viewbox="0 0 12 12">
        <circle cx="6" cy="6" r="6" fill="currentColor" />
      </svg>
      <span>${recordingLength}</span>`;
  }

  private renderStopRecordButton() {
    return html`<cra-button
      id="stop-record"
      shape="circle"
      .label=${i18n.recordStopButton}
      @click=${this.onStopRecording}
      ${ref(this.stopRecordingButton)}
    >
      <cra-icon slot="leading-icon" name="stop"></cra-icon>
    </cra-button>`;
  }

  private async saveAndExitRecording() {
    await this.stopRecording();
    navigateTo('/');
  }

  private onSaveClick() {
    this.recordingControlQueue.push(async () => {
      await this.saveAndExitRecording();
    });
  }

  private onBackClick() {
    this.recordingControlQueue.push(async () => {
      if (this.recordingPaused.value) {
        await this.saveAndExitRecording();
      } else {
        this.exitDialog?.show();
      }
    });
  }

  private closeExitDialog() {
    this.exitDialog?.close();
  }

  private renderExitRecordingDialog() {
    return html`<cra-dialog id="exit-dialog">
      <div slot="headline">${i18n.recordExitDialogHeader}</div>
      <div slot="content">${i18n.recordExitDialogDescription}</div>
      <div slot="actions">
        <cra-button
          .label=${i18n.recordExitDialogDeleteButton}
          button-style="secondary"
          @click=${this.deleteRecording}
        ></cra-button>
        <cra-button
          .label=${i18n.recordExitDialogCancelButton}
          button-style="secondary"
          @click=${this.closeExitDialog}
        ></cra-button>
        <cra-button
          .label=${i18n.recordExitDialogSaveAndExitButton}
          @click=${this.onSaveClick}
        ></cra-button>
      </div>
    </cra-dialog>`;
  }

  private renderMenu() {
    const transcriptionMenuItem = html`
      <cra-menu-item
        headline=${i18n.recordMenuToggleTranscriptionOption}
        itemEnd="switch"
        .switchSelected=${live(this.transcriptionEnabled.value)}
        @cros-menu-item-triggered=${this.toggleTranscriptionEnabled}
      >
      </cra-menu-item>
    `;
    return html`
      <cra-menu ${ref(this.menu)} anchor="show-menu">
        <cra-menu-item
          headline=${i18n.recordMenuDeleteOption}
          @cros-menu-item-triggered=${this.onDeleteButtonClick}
        >
        </cra-menu-item>
        ${this.transcriptionAvailable.value ? transcriptionMenuItem : nothing}
      </cra-menu>
    `;
  }

  private toggleMenu() {
    this.menu.value?.toggle();
  }

  private renderHeader() {
    const toggleTranscriptionButton = html`
      <cra-icon-button
        buttonstyle="toggle"
        @click=${this.toggleTranscriptionShown}
      >
        <cra-icon slot="icon" name="notes"></cra-icon>
        <cra-icon slot="selectedIcon" name="notes"></cra-icon>
      </cra-icon-button>
    `;
    return html`
      <div id="header" class="sheet">
        <cra-icon-button buttonstyle="floating" @click=${this.onBackClick}>
          <cra-icon slot="icon" name="arrow_back"></cra-icon>
        </cra-icon-button>
        <span id="title">${this.recordingTitle}</span>
        ${
      this.transcriptionAvailable.value ? toggleTranscriptionButton : nothing}
        <cra-icon-button
          buttonstyle="floating"
          @click=${this.toggleMenu}
          id="show-menu"
        >
          <cra-icon slot="icon" name="more_vertical"></cra-icon>
        </cra-icon-button>
      </div>
      ${this.renderMenu()}
    `;
  }

  override render(): RenderResult {
    const mainSectionClasses = {
      'show-transcription': this.transcriptionShown.value,
    };

    const footerClasses = {
      paused: this.recordingPaused.value,
    };

    return html`
      <div id="main-area">
        ${this.renderHeader()}
        <div id="middle" class=${classMap(mainSectionClasses)}>
          <div id="audio-waveform-container" class="sheet">
            ${this.renderAudioWaveform()}
          </div>
          <div id="transcription-container" class="sheet">
            ${this.renderTranscription()}
          </div>
        </div>
      </div>
      <div id="footer" class=${classMap(footerClasses)}>
        <div id="timer">${this.renderTimer()}</div>
        <div id="actions">
          <secondary-button @click=${this.onDeleteButtonClick}>
            <cra-icon slot="icon" name="delete"></cra-icon>
          </secondary-button>
          ${this.renderStopRecordButton()}
          <secondary-button id="pause-button" @click=${this.onPauseButtonClick}>
            <cra-icon slot="icon" name="pause"></cra-icon>
          </secondary-button>
        </div>
      </div>
      <delete-recording-dialog
        current
        @delete=${this.deleteRecording}
        ${ref(this.deleteDialog)}
      >
      </delete-recording-dialog>
      ${this.renderExitRecordingDialog()}
      <transcription-consent-dialog ${ref(this.transcriptionConsentDialog)}>
      </transcription-consent-dialog>
    `;
  }
}

window.customElements.define('record-page', RecordPage);

declare global {
  interface HTMLElementTagNameMap {
    'record-page': RecordPage;
  }
}