chromium/ash/webui/recorder_app_ui/resources/pages/playback-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 'chrome://resources/cros_components/menu/menu_separator.js';
import 'chrome://resources/cros_components/slider/slider.js';
import 'chrome://resources/mwc/@material/web/icon/icon.js';
import 'chrome://resources/mwc/@material/web/iconbutton/icon-button.js';
import '../components/audio-waveform.js';
import '../components/cra/cra-image.js';
import '../components/cra/cra-menu-item.js';
import '../components/cra/cra-menu.js';
import '../components/delete-recording-dialog.js';
import '../components/export-dialog.js';
import '../components/recording-file-list.js';
import '../components/recording-info-dialog.js';
import '../components/recording-title.js';
import '../components/secondary-button.js';
import '../components/summarization-view.js';
import '../components/transcription-view.js';

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

import {CraMenu} from '../components/cra/cra-menu.js';
import {DeleteRecordingDialog} from '../components/delete-recording-dialog.js';
import {ExportDialog} from '../components/export-dialog.js';
import {RecordingInfoDialog} from '../components/recording-info-dialog.js';
import {RecordingTitle} from '../components/recording-title.js';
import {SummarizationView} from '../components/summarization-view.js';
import {i18n} from '../core/i18n.js';
import {
  AnimationFrameController,
} from '../core/lit/animation_frame_controller.js';
import {useRecordingDataManager} from '../core/lit/context.js';
import {
  ComputedState,
  ReactiveLitElement,
  ScopedAsyncComputed,
  ScopedAsyncEffect,
} from '../core/reactive/lit.js';
import {computed, Dispose, effect, signal} from '../core/reactive/signal.js';
import {navigateTo} from '../core/state/route.js';
import {
  assert,
  assertExists,
  assertInstanceof,
} from '../core/utils/assert.js';
import {formatDuration} from '../core/utils/datetime.js';
import {InteriorMutableArray} from '../core/utils/interior_mutable_array.js';

/**
 * Mapping from playback speed to icon names.
 *
 * Note that the playback speed numbers can be precisely represented by IEEE 754
 * floating point numbers, so using those as key shouldn't pose precision
 * issues.
 */
const PLAYBACK_SPEED_ICON_MAP = new Map([
  [0.25, 'rate_0_25'],
  [0.5, 'rate_0_5'],
  [0.75, 'rate_0_75'],
  [1.0, 'rate_1_0'],
  [1.25, 'rate_1_25'],
  [1.5, 'rate_1_5'],
  [1.75, 'rate_1_75'],
  [2.0, 'rate_2_0'],
]);

const PLAYBACK_SPEEDS = Array.from(PLAYBACK_SPEED_ICON_MAP.keys());

/**
 * Playback page of Recorder App.
 */
export class PlaybackPage extends ReactiveLitElement {
  static override styles = css`
    :host {
      background-color: var(--cros-sys-app_base_shaded);
      box-sizing: border-box;
      display: flex;
      flex-flow: column;
      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);
      border-radius: 4px;
    }

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

      & > recording-title {
        margin: 0 auto 0 -4px;
      }
    }

    #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 {
      display: none;
    }

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

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

    #transcription-empty {
      align-items: center;
      display: flex;
      flex-flow: column;
      font: var(--cros-headline-1-font);
      gap: 16px;
      height: 100%;
      justify-content: center;
      width: 100%;
    }

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

    #audio-waveform-container {
      align-items: center;
      display: flex;
      flex-flow: column;
      justify-content: center;

      & > div {
        color: var(--cros-sys-on_surface_variant);
        font: 440 24px/32px var(--monospace-font-family);

        /*
         * TODO: b/361221415 - Remove the old properties when stable Chrome
         * supports new one.
         */
        inset-area: top;
        letter-spacing: 0.03em;
        margin-bottom: 4px;
        position: absolute;
        position-anchor: --waveform;
        position-area: top;
      }
    }

    audio-waveform {
      height: 180px;
      anchor-name: --waveform;
    }

    #footer {
      align-items: stretch;
      display: flex;
      flex-flow: column;
      gap: 16px;
      padding: 16px 8px;
    }

    #audio-timeline {
      align-items: stretch;
      display: flex;
      flex-flow: column;

      & > div {
        display: flex;
        flex-flow: row;
        font: var(--cros-body-2-font);
        justify-content: space-between;
        padding: 0 16px;
      }
    }

    #actions {
      align-items: center;
      display: flex;
      justify-content: center;
      position: relative;
    }

    #volume-controls {
      left: 0;
      position: absolute;
    }

    #inline-slider {
      align-items: center;
      display: flex;

      & > cra-icon-button {
        margin-right: 0;
      }

      & > cros-slider {
        min-inline-size: initial;
        width: 180px;
      }

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

    #floating-slider-base {
      display: none;

      @container style(--small-viewport: 1) {
        display: block;
      }

      & > cra-icon-button {
        anchor-name: --volume-button;
        margin: 0;
        padding: 4px;
      }

      /* TODO(pihsun): Animate the opening/closing */
      & > [popover]:popover-open {
        align-items: center;
        background: var(--cros-sys-base_elevated);
        border: none;
        border-radius: 8px;
        box-shadow: var(--cros-sys-app_elevation3);
        display: flex;
        flex-flow: row;
        inset-area: center span-right;
        margin: initial;
        overflow: initial;
        padding: 0;
        position: absolute;
        position-anchor: --volume-button;

        & > cros-slider {
          min-inline-size: initial;
          width: 180px;
        }
      }
    }

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

    #speed-controls {
      position: absolute;
      right: 0;
    }

    #play-button {
      --cra-icon-button-container-color: var(--cros-sys-primary);
      --cra-icon-button-container-height: 96px;
      --cra-icon-button-container-width: 152px;
      --cra-icon-button-hover-state-layer-color: var(
        --cros-sys-hover_on_prominent
      );
      --cra-icon-button-pressed-state-layer-color: var(
        --cros-sys-ripple_primary
      );
      --cros-icon-button-color-override: var(--cros-sys-on_primary);
      --cros-icon-button-icon-size: 32px;

      @container style(--small-viewport: 1) {
        --cra-icon-button-container-height: 80px;
        --cra-icon-button-container-width: 136px;
      }

      margin: 0;
    }

    summarization-view {
      padding: 0 12px;
    }

    #menu {
      --cros-menu-width: 200px;
    }

    #speed-menu {
      --cros-menu-width: 160px;
    }
  `;

  static override properties: PropertyDeclarations = {
    recordingId: {type: String},
  };

  recordingId: string|null = null;

  private readonly recordingIdSignal = this.propSignal('recordingId');

  private latestAudioSrc: string|null = null;

  private readonly currentTime = signal(0);

  private readonly audioPlaying = signal(false);

  private readonly audio = new Audio();

  private readonly menu = createRef<CraMenu>();

  private readonly deleteRecordingDialog = createRef<DeleteRecordingDialog>();

  private readonly exportDialog = createRef<ExportDialog>();

  private readonly recordingInfoDialog = createRef<RecordingInfoDialog>();

  private readonly recordingDataManager = useRecordingDataManager();

  private readonly playbackSpeed = signal(1);

  private readonly playbackSpeedMenu = createRef<CraMenu>();

  private readonly playbackSpeedMenuOpened = signal(false);

  private readonly floatingVolume = createRef<HTMLElement>();

  private readonly backButton = createRef<HTMLButtonElement>();

  private readonly playPauseButton = createRef<HTMLButtonElement>();

  private readonly transcriptionButtonRef = createRef<HTMLButtonElement>();

  private readonly recordingTitle = createRef<RecordingTitle>();

  private readonly summarizationView = createRef<SummarizationView>();

  // TODO(pihsun): Loading spinner when loading metadata.
  private readonly recordingMetadata = computed(() => {
    const id = this.recordingIdSignal.value;
    if (id === null) {
      return null;
    }
    return this.recordingDataManager.getMetadata(id).value;
  });

  // This is marked as protected to suppress the unused member error.
  protected readonly loadAudioData = new ScopedAsyncEffect(
    this,
    async (signal) => {
      const id = this.recordingIdSignal.value;
      if (id === null) {
        return;
      }
      const data = await this.recordingDataManager.getAudioFile(id);
      signal.throwIfAborted();
      this.revokeAudio();
      this.latestAudioSrc = URL.createObjectURL(data);
      this.audio.src = this.latestAudioSrc;
      this.audio.load();
    },
  );

  private readonly transcription = new ScopedAsyncComputed(this, async () => {
    if (this.recordingIdSignal.value === null) {
      return null;
    }
    return this.recordingDataManager.getTranscription(
      this.recordingIdSignal.value,
    );
  });

  private readonly powers = new ScopedAsyncComputed(this, async () => {
    if (this.recordingIdSignal.value === null) {
      return null;
    }
    const {powers} = await this.recordingDataManager.getAudioPower(
      this.recordingIdSignal.value,
    );
    return powers;
  });

  private readonly showTranscription = signal(false);

  // TODO(pihsun): ScopedEffect without the async part?
  private autoOpenTranscription: Dispose|null = null;

  constructor() {
    super();

    // TODO(pihsun): These will look much better as decorator...
    this.addController(
      new AnimationFrameController(() => {
        // We use AnimationFrameController instead of the timeupdate event
        // since timeupdate fires infrequently and doesn't look smooth while
        // playing.
        // TODO(shik): Pause/Resume the animation frame loop properly,
        // especially when the audio is fully played and stopped so we
        // won't
        // keep the audio stream open as the audio server would be kept
        // awake as well.
        this.currentTime.value = this.audio.currentTime;
        this.audioPlaying.value = !this.audio.paused;
      }),
    );

    this.audio.addEventListener('ratechange', () => {
      if (this.audio.playbackRate !== this.playbackSpeed.value) {
        // TODO(pihsun): Integrate with error reporting.
        // TODO(pihsun): Check if this will be fired on pause.
        console.warn(
          'Audio playback speed mismatch',
          this.audio.playbackRate,
          this.playbackSpeed.value,
        );
      }
    });
  }

  get pauseButtonForTest(): HTMLButtonElement {
    assert(this.audioPlaying.value, 'The playback is already paused');
    return assertExists(this.playPauseButton.value);
  }

  get backButtonForTest(): HTMLButtonElement {
    return assertExists(this.backButton.value);
  }

  get transcriptionToggleButtonForTest(): HTMLButtonElement {
    return assertExists(this.transcriptionButtonRef.value);
  }

  get recordingTitleForTest(): RecordingTitle {
    return assertExists(this.recordingTitle.value);
  }

  get summarizationViewForTest(): SummarizationView {
    return assertExists(this.summarizationView.value);
  }

  private revokeAudio() {
    if (this.latestAudioSrc !== null) {
      URL.revokeObjectURL(this.latestAudioSrc);
      this.latestAudioSrc = null;
    }
  }

  override connectedCallback(): void {
    super.connectedCallback();
    if (this.autoOpenTranscription === null) {
      this.autoOpenTranscription = effect(() => {
        if (this.transcription.state === ComputedState.DONE) {
          // We only updates the transcription open state when it's just
          // computed.
          const transcription = this.transcription.valueSignal.peek();

          // Default to show transcription panel if there's a non-empty
          // transcription.
          this.showTranscription.value =
            transcription !== null && !transcription.isEmpty();
        }
      });
    }
  }

  override disconnectedCallback(): void {
    super.disconnectedCallback();
    this.audio.pause();
    this.revokeAudio();
    this.autoOpenTranscription?.();
    this.autoOpenTranscription = null;
  }

  private onWordClick(ev: CustomEvent<{startMs: number}>) {
    // TODO(pihsun): Currently there's a small time difference between the
    // seeked audio and the real timing of the word being spoken.
    // Investigate if it's from audio playing inaccuracy or from inherit
    // soda event timestamp inaccuracy.
    this.audio.currentTime = ev.detail.startMs / 1000;
  }

  private onPlayPauseClick() {
    if (this.audio.paused) {
      // TODO(pihsun): This is async, should we await it?
      void this.audio.play();
    } else {
      this.audio.pause();
    }
  }

  private toggleTranscription() {
    this.showTranscription.update((s) => !s);
  }

  private renderAudioWaveform() {
    if (this.powers.value === null) {
      return nothing;
    }
    const recordingLength = formatDuration(
      {
        seconds: this.currentTime.value,
      },
      1,
    );
    return html`
      <div>${recordingLength}</div>
      <audio-waveform
        .values=${new InteriorMutableArray(this.powers.value)}
        .currentTime=${this.currentTime.value}
        .transcription=${this.transcription.value}
      >
      </audio-waveform>
    `;
  }

  private renderTranscription() {
    const transcription = this.transcription.value;
    if (transcription === null) {
      return nothing;
    }
    if (transcription.isEmpty()) {
      return html`<div id="transcription-empty">
        <cra-image name="transcription_no_speech"></cra-image>
        ${i18n.transcriptionNoSpeechText}
      </div>`;
    }
    // TODO: b/336963138 - Animation while opening/closing the panel.
    return html`<transcription-view
      .transcription=${transcription}
      @word-clicked=${this.onWordClick}
      .currentTime=${this.currentTime.value}
      seekable
    >
      <summarization-view
        .transcription=${transcription}
        ${ref(this.summarizationView)}
      ></summarization-view>
    </transcription-view>`;
  }

  private renderPlayPauseButton() {
    return html`<cra-icon-button
      id="play-button"
      shape="circle"
      @click=${this.onPlayPauseClick}
      ${ref(this.playPauseButton)}
    >
      <cra-icon
        slot="icon"
        .name=${this.audioPlaying.value ? 'pause_hero' : 'play_hero'}
      ></cra-icon>
    </cra-icon-button>`;
  }

  private onTimelineInput(ev: Event) {
    const target = assertInstanceof(ev.target, CrosSlider);
    this.audio.currentTime = target.value;
  }

  private onForward10Secs() {
    this.audio.currentTime += 10;
  }

  private onRewind10Secs() {
    this.audio.currentTime -= 10;
  }

  private onDeleteClick() {
    this.deleteRecordingDialog.value?.show();
  }

  private deleteRecording() {
    if (this.recordingId !== null) {
      this.recordingDataManager.remove(this.recordingId);
      navigateTo('/');
    }
  }

  private onExportClick() {
    this.exportDialog.value?.show();
  }

  private onShowDetailClick() {
    this.recordingInfoDialog.value?.show();
  }

  private renderMenu() {
    // TODO: b/344789992 - Implements show detail.
    return html`
      <cra-menu ${ref(this.menu)} anchor="show-menu" id="menu">
        <cra-menu-item
          headline=${i18n.playbackMenuExportOption}
          @cros-menu-item-triggered=${this.onExportClick}
        ></cra-menu-item>
        <cra-menu-item
          headline=${i18n.playbackMenuShowDetailOption}
          @cros-menu-item-triggered=${this.onShowDetailClick}
        ></cra-menu-item>
        <cros-menu-separator></cros-menu-separator>
        <cra-menu-item
          headline=${i18n.playbackMenuDeleteOption}
          @cros-menu-item-triggered=${this.onDeleteClick}
        ></cra-menu-item>
      </cra-menu>
      <recording-info-dialog
        ${ref(this.recordingInfoDialog)}
        .recordingId=${this.recordingId}
      ></recording-info-dialog>
      <export-dialog ${ref(this.exportDialog)} .recordingId=${this.recordingId}>
      </export-dialog>
      <delete-recording-dialog
        ${ref(this.deleteRecordingDialog)}
        @delete=${this.deleteRecording}
      >
      </delete-recording-dialog>
    `;
  }

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

  private renderHeader() {
    const transcriptionToggleButton =
      this.transcription.value === null ? nothing : html`
            <cra-icon-button
              buttonstyle="toggle"
              .selected=${live(this.showTranscription.value)}
              @click=${this.toggleTranscription}
              ${ref(this.transcriptionButtonRef)}
            >
              <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=${() => navigateTo('/')}
          ${ref(this.backButton)}
        >
          <cra-icon slot="icon" name="arrow_back"></cra-icon>
        </cra-icon-button>
        <recording-title
          .recordingMetadata=${this.recordingMetadata.value}
          ${ref(this.recordingTitle)}
        >
        </recording-title>
        ${transcriptionToggleButton}
        <cra-icon-button
          buttonstyle="floating"
          id="show-menu"
          @click=${this.toggleMenu}
        >
          <cra-icon slot="icon" name="more_vertical"></cra-icon>
        </cra-icon-button>
        ${this.renderMenu()}
      </div>
    `;
  }

  private renderAudioTimeline() {
    if (this.recordingMetadata.value === null) {
      return nothing;
    }

    const currentTimeString = formatDuration({
      seconds: this.currentTime.value,
    });
    const totalTimeString = formatDuration({
      milliseconds: this.recordingMetadata.value.durationMs,
    });

    // TODO(pihsun): The "step" variable controls both the smallest unit of the
    // slider, and what left/right key would step backward/forward, but we
    // might want the step of left/right key to be larger than the precision
    // of dragging the slider?
    return html`<div id="audio-timeline">
      <cros-slider
        min="0"
        max=${this.recordingMetadata.value.durationMs / 1000}
        step="0.1"
        .value=${this.currentTime.value}
        @input=${this.onTimelineInput}
      ></cros-slider>
      <div>
        <span>${currentTimeString}</span>
        <span>${totalTimeString}</span>
      </div>
    </div>`;
  }

  private renderSpeedControl(): RenderResult {
    const iconName = assertExists(
      PLAYBACK_SPEED_ICON_MAP.get(this.playbackSpeed.value),
    );
    const menuItems = PLAYBACK_SPEEDS.map((speed) => {
      const label =
        speed === 1.0 ? i18n.playbackSpeedNormalOption : speed.toString();
      const onClick = () => {
        this.playbackSpeed.value = speed;
        this.audio.playbackRate = speed;
      };

      return html`<cra-menu-item
        headline=${label}
        ?checked=${this.playbackSpeed.value === speed}
        @cros-menu-item-triggered=${onClick}
      ></cra-menu-item>`;
    });

    const onMenuOpen = () => {
      this.playbackSpeedMenuOpened.value = true;
    };
    const onMenuClose = () => {
      this.playbackSpeedMenuOpened.value = false;
    };
    const togglePlaybackSpeedMenu = () => {
      this.playbackSpeedMenu.value?.toggle();
    };

    return html`
      <cra-menu
        ${ref(this.playbackSpeedMenu)}
        anchor="show-speed-menu"
        id="speed-menu"
        @opened=${onMenuOpen}
        @closed=${onMenuClose}
      >
        ${menuItems}
      </cra-menu>
      <cra-icon-button
        buttonstyle="toggle"
        id="show-speed-menu"
        @click=${togglePlaybackSpeedMenu}
        .selected=${live(this.playbackSpeedMenuOpened.value)}
      >
        <cra-icon slot="icon" .name=${iconName}></cra-icon>
        <cra-icon slot="selectedIcon" .name=${iconName}></cra-icon>
      </cra-icon-button>
    `;
  }

  private onVolumeInput(ev: Event) {
    const slider = assertInstanceof(ev.target, CrosSlider);
    this.audio.muted = false;
    this.audio.volume = slider.value / 100;
    this.requestUpdate();
  }

  private toggleMuted() {
    this.audio.muted = !this.audio.muted;
    this.requestUpdate();
  }

  private renderVolumeIcon() {
    const {muted, volume} = this.audio;
    const iconName = (() => {
      if (muted) {
        return 'volume_mute';
      }
      if (volume === 0) {
        return 'volume_off';
      }
      return volume < 0.5 ? 'volume_down' : 'volume_up';
    })();
    return html`<cra-icon slot="icon" .name=${iconName}></cra-icon>`;
  }

  private renderVolumeSlider() {
    const {muted, volume} = this.audio;
    const volumeDisplay = muted ? 0 : Math.round(volume * 100);
    return html`
      <cros-slider
        withlabel
        .value=${volumeDisplay}
        min="0"
        max="100"
        @input=${this.onVolumeInput}
      ></cros-slider>
    `;
  }

  private showFloatingVolume() {
    this.floatingVolume.value?.showPopover();
  }

  private hideFloatingVolume(ev: FocusEvent) {
    const newTarget = ev.relatedTarget;
    if (newTarget !== null && newTarget instanceof Node &&
        this.floatingVolume.value?.contains(newTarget)) {
      return;
    }
    this.floatingVolume.value?.hidePopover();
  }

  private renderVolumeControl(): RenderResult {
    return html`
      <div id="inline-slider">
        <cra-icon-button buttonstyle="floating" @click=${this.toggleMuted}>
          ${this.renderVolumeIcon()}
        </cra-icon-button>
        ${this.renderVolumeSlider()}
      </div>
      <div id="floating-slider-base">
        <cra-icon-button
          buttonstyle="floating"
          @click=${this.showFloatingVolume}
        >
          ${this.renderVolumeIcon()}
        </cra-icon-button>
        <div
          popover
          ${ref(this.floatingVolume)}
          @focusout=${this.hideFloatingVolume}
        >
          <cra-icon-button buttonstyle="floating" @click=${this.toggleMuted}>
            ${this.renderVolumeIcon()}
          </cra-icon-button>
          ${this.renderVolumeSlider()}
          <cra-icon-button
            buttonstyle="floating"
            @click=${this.hideFloatingVolume}
          >
            <cra-icon slot="icon" name="close"></cra-icon>
          </cra-icon-button>
        </div>
      </div>
    `;
  }

  override render(): RenderResult {
    const mainSectionClasses = {
      'show-transcription': this.showTranscription.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"
        aria-label=${i18n.playbackControlsLandmarkAriaLabel}
        role="complementary"
      >
        ${this.renderAudioTimeline()}
        <div id="actions">
          <div id="volume-controls">${this.renderVolumeControl()}</div>
          <div id="middle-controls">
            <secondary-button @click=${this.onRewind10Secs}>
              <cra-icon slot="icon" name="replay_10"></cra-icon>
            </secondary-button>
            ${this.renderPlayPauseButton()}
            <secondary-button @click=${this.onForward10Secs}>
              <cra-icon slot="icon" name="forward_10"></cra-icon>
            </secondary-button>
          </div>
          <div id="speed-controls">${this.renderSpeedControl()}</div>
        </div>
      </div>
    `;
  }
}

window.customElements.define('playback-page', PlaybackPage);

declare global {
  interface HTMLElementTagNameMap {
    'playback-page': PlaybackPage;
  }
}