chromium/ash/webui/focus_mode/resources/app.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 {MediaClientInterface, MediaClientReceiver, PlaybackState, TrackDefinition, TrackProvider, TrackProviderInterface} from './focus_mode.mojom-webui.js';

const UNTRUSTED_ORIGIN = 'chrome-untrusted://focus-mode-player';

// Implemented here, used by Ash to directly start a different track.
let clientInstance: MediaClientImpl|null = null;

// Implemented by Ash, provides the tracks that we will play.
let providerInstance: TrackProviderInterface|null = null;

interface PlaybackReportingConfig {
  intervalShort: number;
  intervalLong: number;
  intervalThreshold: number;
  intervalId: number;
}
const reportConfig: PlaybackReportingConfig = {
  intervalShort: 10,
  intervalLong: 40,
  intervalThreshold: 40,
  intervalId: setInterval(() => postQueryPlaybackStatusRequest(), 2000),
};

interface PlaybackStatus {
  state: string;
  position: number;
  initial: boolean;
}
let playbackStatus: PlaybackStatus|null = null;

let currentTrack: TrackDefinition|null = null;

// Valid playback state string values.
const validStates = ['playing', 'paused', 'ended', 'switchedtonext'];

// Check if the playback state comes with valid values.
function isValidPlaybackStatus(playbackStatus: PlaybackStatus): boolean {
  return validStates.includes(playbackStatus.state) &&
      playbackStatus.position >= 0 && playbackStatus.position <= 18000;
}

function getPlaybackState(playbackStateString: string): PlaybackState {
  switch (playbackStateString) {
    case 'playing':
      return PlaybackState.kPlaying;
    case 'paused':
      return PlaybackState.kPaused;
    case 'switchedtonext':
      return PlaybackState.kSwitchedToNext;
    case 'ended':
      return PlaybackState.kEnded;
  }
  return PlaybackState.kNone;
}

function getDuration(
    oldPlaybackStatus: PlaybackStatus|null,
    newPlaybackStatus: PlaybackStatus|null): [number, number] {
  return [
    Math.floor(oldPlaybackStatus?.position ?? 0),
    Math.floor(newPlaybackStatus?.position ?? 0),
  ];
}

function shouldReportInitialPlayback(newPlaybackStatus: PlaybackStatus):
    boolean {
  const [start, end] = getDuration(playbackStatus, newPlaybackStatus);
  return newPlaybackStatus.initial && start == end;
}

function shouldReportSubsequentPlayback(newPlaybackStatus: PlaybackStatus):
    boolean {
  const [start, end] = getDuration(playbackStatus, newPlaybackStatus);
  const interval = end <= reportConfig.intervalThreshold ?
      reportConfig.intervalShort :
      reportConfig.intervalLong;

  // The condition for minimal interval needs to be a little more permissive by
  // 1s in case the previous timer fires at 30.01s and the current timer fires
  // at 39.98s.
  return !newPlaybackStatus.initial && start < end &&
      (end - start + 1 >= interval || newPlaybackStatus.state == 'ended' ||
       newPlaybackStatus.state == 'switchedtonext');
}

function onReceiveNewPlaybackStatus(newPlaybackStatus: PlaybackStatus) {
  if (currentTrack == null || !currentTrack.enablePlaybackReporting ||
      !isValidPlaybackStatus(newPlaybackStatus)) {
    return;
  }

  const reportInitial = shouldReportInitialPlayback(newPlaybackStatus);
  const reportSubsequent = shouldReportSubsequentPlayback(newPlaybackStatus);
  if (reportInitial || reportSubsequent) {
    const [start, end] = getDuration(playbackStatus, newPlaybackStatus);
    getProvider().reportPlayback({
      state: getPlaybackState(newPlaybackStatus.state),
      title: currentTrack.title,
      url: currentTrack.mediaUrl,
      mediaStart: reportInitial ? null : start,
      mediaEnd: reportInitial ? null : end,
      initialPlayback: newPlaybackStatus.initial,
    });
    playbackStatus = newPlaybackStatus;
  }

  // Track playback is complete. Reset `currentTrack` to null until the new
  // track is loaded.
  if (newPlaybackStatus.state == 'ended' ||
      newPlaybackStatus.state == 'switchedtonext') {
    currentTrack = null;
  }
}

function isEventData(data: any): boolean {
  return data && typeof data == 'object' && typeof data.cmd == 'string';
}

function isNextTrackEventData(data: any): boolean {
  return isEventData(data) && data.cmd == 'gettrack';
}

function isPlaybackStatus(data: any): boolean {
  return (
      isEventData(data) && data.cmd == 'replyplaybackstatus' &&
      typeof data.state == 'string' && typeof data.position == 'number' &&
      typeof data.initial == 'boolean');
}

function getProvider(): TrackProviderInterface {
  if (!providerInstance) {
    providerInstance = TrackProvider.getRemote();
  }
  return providerInstance;
}

// Post a track play request to the iframe.
function postPlayRequest(track: TrackDefinition) {
  if (!track.mediaUrl.url) {
    // If there is no valid URL, then there's no point in continuing.
    return;
  }

  const child = document.getElementById('child') as HTMLIFrameElement;
  if (child.contentWindow) {
    playbackStatus = null;
    currentTrack = track;
    child.contentWindow.postMessage(
        {
          cmd: 'play',
          arg: {
            mediaUrl: track.mediaUrl.url,
            thumbnailUrl: track.thumbnailUrl.url,
            title: track.title,
            artist: track.artist,
          },
        },
        UNTRUSTED_ORIGIN);
  }
}

// Post a query request for playback status to the iframe.
function postQueryPlaybackStatusRequest() {
  if (currentTrack == null || !currentTrack.enablePlaybackReporting) {
    return;
  }

  const child = document.getElementById('child') as HTMLIFrameElement;
  if (child.contentWindow) {
    child.contentWindow.postMessage(
        {
          cmd: 'queryplaybackstatus',
        },
        UNTRUSTED_ORIGIN);
  }
}

class MediaClientImpl implements MediaClientInterface {
  static init() {
    if (!clientInstance) {
      clientInstance = new MediaClientImpl();
    }
  }

  static shutdown() {
    if (clientInstance) {
      clientInstance.receiver_.$.close();
      clientInstance = null;
    }
  }

  startPlay(track: TrackDefinition): void {
    postPlayRequest(track);
  }

  private receiver_: MediaClientReceiver = this.initReceiver_();

  private initReceiver_(): MediaClientReceiver {
    const receiver = new MediaClientReceiver(this);
    getProvider().setMediaClient(receiver.$.bindNewPipeAndPassRemote());
    return receiver;
  }
}

globalThis.addEventListener('load', async () => {
  MediaClientImpl.init();
});

// Tracks whether we are currently requesting a track.
let requestInProgress = false;

globalThis.addEventListener('message', async (event: MessageEvent) => {
  if (event.origin != UNTRUSTED_ORIGIN) {
    return;
  }

  const data = event.data;
  if (isEventData(data)) {
    if (isNextTrackEventData(data)) {
      if (requestInProgress) {
        // There is no point in doing concurrent requests, so if we get a new
        // request while another is pending (this can happen if the user hammers
        // the next track button in the media controls), then the request is
        // simply dropped.
        return;
      }

      requestInProgress = true;
      const result = await getProvider().getTrack();
      requestInProgress = false;

      postPlayRequest(result.track);
    } else if (isPlaybackStatus(data)) {
      onReceiveNewPlaybackStatus({
        state: data.state,
        position: data.position,
        initial: data.initial,
      });
    }
  }
});