chromium/ash/webui/personalization_app/resources/js/wallpaper/wallpaper_fullscreen_element.ts

// Copyright 2021 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

/**
 * @fileoverview A polymer component that displays a transparent full screen
 * viewing mode of the currently selected wallpaper.
 */

import 'chrome://resources/ash/common/cr_elements/cr_button/cr_button.js';
import 'chrome://resources/polymer/v3_0/iron-icon/iron-icon.js';
import '../../common/icons.html.js';

import {FullscreenPreviewState} from 'chrome://resources/ash/common/personalization/wallpaper_state.js';
import {isNonEmptyFilePath} from 'chrome://resources/ash/common/sea_pen/sea_pen_utils.js';
import {assert} from 'chrome://resources/js/assert.js';

import {CurrentWallpaper, WallpaperLayout} from '../../personalization_app.mojom-webui.js';
import {WithPersonalizationStore} from '../personalization_store.js';

import {DisplayableImage} from './constants.js';
import {getWallpaperLayoutEnum, isGooglePhotosPhoto} from './utils.js';
import {setFullscreenStateAction} from './wallpaper_actions.js';
import {cancelPreviewWallpaper, confirmPreviewWallpaper, selectWallpaper} from './wallpaper_controller.js';
import {getTemplate} from './wallpaper_fullscreen_element.html.js';
import {getWallpaperProvider} from './wallpaper_interface_provider.js';

const fullscreenClass = 'fullscreen-preview';
const fullscreenTransitionClass = 'fullscreen-preview-transition';

let shouldWaitForOpacityTransitions = true;

export function setShouldWaitForFullscreenOpacityTransitionsForTesting(
    value: boolean) {
  shouldWaitForOpacityTransitions = value;
}

// Wait for document.body opacity transition to end. Set a timeout for safety in
// case the opacity transition has been canceled.
async function waitForOpacityTransition(): Promise<void> {
  if (!shouldWaitForOpacityTransitions) {
    return;
  }
  const handler =
      await new Promise<(event: TransitionEvent) => void>(resolve => {
        const handler = (event: TransitionEvent) => {
          if (event.propertyName === 'opacity') {
            window.clearTimeout(timeoutId);
            resolve(handler);
          }
        };
        const timeoutId = window.setTimeout(() => resolve(handler), 250);
        document.body.addEventListener('transitionend', handler);
      });

  document.body.removeEventListener('transitionend', handler);
}

export interface WallpaperFullscreenElement {
  $: {container: HTMLDivElement, exit: HTMLElement};
}

export class WallpaperFullscreenElement extends WithPersonalizationStore {
  static get is() {
    return 'wallpaper-fullscreen';
  }

  static get template() {
    return getTemplate();
  }

  static get properties() {
    return {
      fullscreenState_: {
        type: Object,
        observer: 'onFullscreenStateChanged_',
      },

      showLayoutOptions_: Boolean,

      /**
       * Note that this contains information about the non-preview wallpaper
       * that was set before entering fullscreen mode.
       */
      currentSelected_: Object,

      /** This will be set during the duration of preview mode. */
      pendingSelected_: Object,

      /**
       * When preview mode starts, this is set to the default layout. If the
       * user changes layout option, this will be updated locally to track which
       * option the user has selected (currentSelected.layout does not change
       * until confirmPreviewWallpaper is called).
       */
      selectedLayout_: {
        type: Number,
        value: null,
      },

      /**
       * Whether the full screen container and the corresponding controls (exit,
       * confirm, layout buttons) are shown.
       */
      showContainer_: {
        type: Boolean,
        value: false,
      },
    };
  }

  private fullscreenState_: FullscreenPreviewState;
  private showLayoutOptions_: boolean;
  private currentSelected_: CurrentWallpaper|null;
  private pendingSelected_: DisplayableImage|null;
  private selectedLayout_: WallpaperLayout|null;
  private showContainer_: boolean;

  private onVisibilityChange_ = () => {
    if (document.visibilityState === 'hidden' &&
        this.fullscreenState_ !== FullscreenPreviewState.OFF) {
      // The user has probably switched to a different application. Cancel
      // preview immediately instead of waiting for fullscreenchange event.
      this.dispatch(setFullscreenStateAction(FullscreenPreviewState.OFF));
    }
  };

  private onPopState_ = () => {
    if (this.fullscreenState_ !== FullscreenPreviewState.OFF) {
      // The user has probably pressed the back button on an external mouse, or
      // swiped left to go back.
      this.dispatch(setFullscreenStateAction(FullscreenPreviewState.OFF));
    }
  };

  override connectedCallback() {
    super.connectedCallback();
    this.$.container.addEventListener(
        'fullscreenchange', this.fullscreenChangeEventHander_.bind(this));

    this.watch<WallpaperFullscreenElement['fullscreenState_']>(
        'fullscreenState_', state => state.wallpaper.fullscreen);
    this.watch<WallpaperFullscreenElement['showLayoutOptions_']>(
        'showLayoutOptions_',
        state => !!state.wallpaper.pendingSelected &&
            (isNonEmptyFilePath(state.wallpaper.pendingSelected) ||
             isGooglePhotosPhoto(state.wallpaper.pendingSelected)));
    this.watch<WallpaperFullscreenElement['currentSelected_']>(
        'currentSelected_', state => state.wallpaper.currentSelected);
    this.watch<WallpaperFullscreenElement['pendingSelected_']>(
        'pendingSelected_', state => state.wallpaper.pendingSelected);

    // Visibility change will fire in case of alt+tab, closing the window, or
    // anything else that exits out of full screen mode.
    window.addEventListener('visibilitychange', this.onVisibilityChange_);
    window.addEventListener('popstate', this.onPopState_);
  }

  override disconnectedCallback() {
    super.disconnectedCallback();
    window.removeEventListener('visibilitychange', this.onVisibilityChange_);
    window.removeEventListener('popstate', this.onPopState_);
  }

  /** Wrapper function to mock out for testing. */
  getFullscreenElement(): Element|null {
    return document.fullscreenElement;
  }

  /** Wrapper function to mock out for testing.  */
  exitFullscreen(): Promise<void> {
    return document.exitFullscreen();
  }

  private async onFullscreenStateChanged_(value: FullscreenPreviewState) {
    switch (value) {
      case FullscreenPreviewState.OFF:
        this.showContainer_ = false;
        document.body.classList.remove(fullscreenClass);
        await waitForOpacityTransition();
        document.body.classList.remove(fullscreenTransitionClass);
        this.selectedLayout_ = null;
        if (this.getFullscreenElement()) {
          await this.exitFullscreen();
        }
        cancelPreviewWallpaper(getWallpaperProvider());
        return;
      case FullscreenPreviewState.LOADING:
        // Do not assign this.showContainer_ here. If last state was
        // FullScreenPreviewState.OFF, showContainer_ should stay false. If last
        // state was FullScreenPreviewState.VISIBLE, showContainer_ should stay
        // true. This accounts for both entering full screen preview for the
        // first time, and for users clicking on the layout buttons.
        if (!this.getFullscreenElement()) {
          this.$.container.requestFullscreen();
        }
        return;
      case FullscreenPreviewState.VISIBLE:
        document.body.classList.add(fullscreenTransitionClass);
        document.body.classList.add(fullscreenClass);
        if (typeof this.selectedLayout_ !== 'number') {
          this.selectedLayout_ = WallpaperLayout.kCenterCropped;
        }
        await waitForOpacityTransition();
        this.showContainer_ = true;
        return;
    }
  }

  private fullscreenChangeEventHander_() {
    const hidden = !this.getFullscreenElement();
    if (hidden) {
      this.dispatch(setFullscreenStateAction(FullscreenPreviewState.OFF));
    } else {
      this.$.exit.focus();
    }
  }

  private async onClickExit_() {
    this.dispatch(setFullscreenStateAction(FullscreenPreviewState.OFF));
  }

  private async onClickConfirm_() {
    // Confirm the preview wallpaper before exiting fullscreen. In tablet
    // splitscreen, this prevents `WallpaperController::OnOverviewModeWillStart`
    // from triggering first, which leads to preview wallpaper getting canceled
    // before it gets confirmed (b/289133203).
    confirmPreviewWallpaper(getWallpaperProvider());
    this.dispatch(setFullscreenStateAction(FullscreenPreviewState.OFF));
  }

  private async onClickLayout_(event: MouseEvent) {
    assert(
        isNonEmptyFilePath(this.pendingSelected_) ||
            isGooglePhotosPhoto(this.pendingSelected_),
        'pendingSelected must be a local image or a Google Photos image to set layout');
    const layout = getWallpaperLayoutEnum(
        (event.currentTarget as HTMLButtonElement).dataset['layout']!);
    await selectWallpaper(
        this.pendingSelected_, getWallpaperProvider(), this.getStore(), layout);
    this.selectedLayout_ = layout;
  }

  private getLayoutAriaPressed_(
      selectedLayout: WallpaperLayout, str: 'FILL'|'CENTER'): string {
    assert(str === 'FILL' || str === 'CENTER');
    const layout = getWallpaperLayoutEnum(str);
    return (selectedLayout === layout).toString();
  }
}

customElements.define(
    WallpaperFullscreenElement.is, WallpaperFullscreenElement);