chromium/ash/webui/recorder_app_ui/resources/components/recording-info-dialog.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 './cra/cra-button.js';
import './cra/cra-dialog.js';
import './cra/cra-icon.js';
import './cra/cra-icon-button.js';

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

import {i18n} from '../core/i18n.js';
import {
  usePlatformHandler,
  useRecordingDataManager,
} from '../core/lit/context.js';
import {
  ReactiveLitElement,
  ScopedAsyncComputed,
} from '../core/reactive/lit.js';
import {computed} from '../core/reactive/signal.js';
import {assert} from '../core/utils/assert.js';
import {formatDuration, formatFullDatetime} from '../core/utils/datetime.js';

import {CraDialog} from './cra/cra-dialog.js';

export class RecordingInfoDialog extends ReactiveLitElement {
  static override styles: CSSResultGroup = css`
    :host {
      display: contents;
    }

    cra-dialog {
      width: 420px;

      & > [slot="headline"] > cra-icon-button {
        position: absolute;
        right: 12px;
        top: 12px;
      }

      & > [slot="content"] {
        padding: 20px 32px 32px;
      }
    }

    .row {
      align-items: flex-start;
      display: flex;
      flex-flow: row;
      gap: 16px;
      padding: 8px 8px 8px 16px;

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

      & > .content {
        color: var(--cros-sys-on_surface);
        display: flex;
        flex: 1;
        flex-flow: column;
        font: var(--cros-body-2-font);
      }
    }
  `;

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

  recordingId: string|null = null;

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

  private readonly dialog = createRef<CraDialog>();

  private readonly recordingDataManager = useRecordingDataManager();

  private readonly platformHandler = usePlatformHandler();

  private readonly recordingMetadata = computed(() => {
    const id = this.recordingIdSignal.value;
    if (id === null) {
      return null;
    }
    return this.recordingDataManager.getMetadata(id).value;
  });

  private readonly recordingSize = new ScopedAsyncComputed(this, async () => {
    const id = this.recordingIdSignal.value;
    if (id === null) {
      return null;
    }
    const file = await this.recordingDataManager.getAudioFile(id);
    return file.size;
  });

  show(): void {
    // There's no user waiting for the dialog open animation to be done.
    void this.dialog.value?.show();
  }

  hide(): void {
    this.dialog.value?.close();
  }

  private formatFileSize(size: number|null): string {
    if (size === null) {
      return '';
    }

    const buckets = [
      {maxVal: 2 ** 10, unit: 'byte'},
      {maxVal: 2 ** 20, unit: 'kilobyte'},
      {maxVal: 2 ** 30, unit: 'megabyte'},
      {maxVal: 2 ** 40, unit: 'gigabyte'},
      {maxVal: 2 ** 50, unit: 'terabyte'},
      {maxVal: Infinity, unit: 'petabyte'},
    ] as const;

    const bucket = buckets.find((i) => size < i.maxVal);
    assert(bucket !== undefined);

    const displayValue = (size / bucket.maxVal) * 2 ** 10;
    const formatOptions: Intl.NumberFormatOptions = {
      style: 'unit',
      unit: bucket.unit,
    };
    if (displayValue < 1000) {
      // Only show 3 maximum significant digits when the value is less than
      // 1000.
      formatOptions.maximumSignificantDigits = 3;
    } else {
      // Don't show fractions but show full integer part when value is not less
      // than 1000.
      formatOptions.maximumFractionDigits = 0;
    }
    const formatter = new Intl.NumberFormat(
      this.platformHandler.getLocale(),
      formatOptions,
    );
    return formatter.format(displayValue);
  }

  private renderRow(icon: string, label: string, value: string) {
    return html`<div class="row">
      <cra-icon .name=${icon}></cra-icon>
      <div class="content">
        <div>${label}</div>
        <div>${value}</div>
      </div>
    </div>`;
  }

  private renderContent() {
    const meta = this.recordingMetadata.value;
    if (meta === null) {
      return nothing;
    }
    return [
      this.renderRow(
        'duration',
        i18n.recordInfoDialogDurationLabel,
        formatDuration({
          milliseconds: meta.durationMs,
        }),
      ),
      this.renderRow(
        'time_created',
        i18n.recordInfoDialogDateLabel,
        formatFullDatetime(this.platformHandler.getLocale(), meta.recordedAt),
      ),
      this.renderRow('page', i18n.recordInfoDialogTitleLabel, meta.title),
      this.renderRow(
        'music_note',
        i18n.recordInfoDialogSizeLabel,
        this.formatFileSize(this.recordingSize.value),
      ),
    ];
  }

  override render(): RenderResult {
    return html`<cra-dialog ${ref(this.dialog)}>
      <div slot="headline">
        ${i18n.recordInfoDialogHeader}
        <cra-icon-button
          buttonstyle="floating"
          size="small"
          shape="circle"
          @click=${this.hide}
        >
          <cra-icon slot="icon" name="close"></cra-icon>
        </cra-icon-button>
      </div>
      <div slot="content">${this.renderContent()}</div>
    </cra-dialog>`;
  }
}

window.customElements.define('recording-info-dialog', RecordingInfoDialog);

declare global {
  interface HTMLElementTagNameMap {
    'recording-info-dialog': RecordingInfoDialog;
  }
}