Source: ui/watermark.js

/*! @license
 * Shaka Player
 * Copyright 2016 Google LLC
 * SPDX-License-Identifier: Apache-2.0
 */

goog.provide('shaka.ui.Watermark');

goog.requireType('shaka.ui.Controls');

goog.require('shaka.ui.Element');
goog.require('shaka.log');

/**
 * A UI component that adds watermark functionality to the Shaka Player.
 * Allows adding text watermarks with various customization options.
 * @extends {shaka.ui.Element}
 * @final
 * @export
 */
shaka.ui.Watermark = class extends shaka.ui.Element {
  /**
   * Creates a new Watermark instance.
   * @param {!HTMLElement} parent The parent element for the watermark canvas
   * @param {!shaka.ui.Controls} controls The controls instance
   */
  constructor(parent, controls) {
    super(parent, controls);

    /** @private {!HTMLCanvasElement} */
    this.canvas_ = /** @type {!HTMLCanvasElement} */ (
      document.createElement('canvas')
    );
    this.canvas_.style.position = 'absolute';
    this.canvas_.style.top = '0';
    this.canvas_.style.left = '0';
    this.canvas_.style.pointerEvents = 'none';

    this.parent.appendChild(this.canvas_);
    this.resizeCanvas_();

    /** @private {number|null} */
    this.animationId_ = null;

    /** @private {ResizeObserver|null} */
    this.resizeObserver_ = null;

    // Use ResizeObserver if available, fallback to window resize event
    if (window.ResizeObserver) {
      this.resizeObserver_ = new ResizeObserver(() => this.resizeCanvas_());
      this.resizeObserver_.observe(this.parent);
    } else {
      // Fallback for older browsers
      window.addEventListener('resize', () => this.resizeCanvas_());
    }
  }

  /**
   * Gets the 2D rendering context safely
   * @return {?CanvasRenderingContext2D}
   * @private
   */
  getContext2D_() {
    const ctx = this.canvas_.getContext('2d');
    if (!ctx) {
      shaka.log.error('2D context is not available');
      return null;
    }
    return /** @type {!CanvasRenderingContext2D} */ (ctx);
  }

  /**
   * Resize canvas to match video container
   * @private
   */
  resizeCanvas_() {
    this.canvas_.width = this.parent.offsetWidth;
    this.canvas_.height = this.parent.offsetHeight;
  }

  /**
   * Sets a text watermark on the video with customizable options.
   * The watermark can be either static (fixed position) or dynamic (moving).
   * @param {string} text The text to display as watermark
   * @param {?shaka.ui.Watermark.Options=} options  configuration options
   * @export
   */
  setTextWatermark(text, options) {
    /** @type {!shaka.ui.Watermark.Options} */
    const defaultOptions = {
      type: 'static',
      text: text,
      position: 'top-right',
      color: 'rgba(255, 255, 255, 0.7)',
      size: 20,
      alpha: 0.7,
      interval: 2 * 1000,
      skip: 0.5 * 1000,
      displayDuration: 2 * 1000,
      transitionDuration: 0.5,
    };

    /** @type {!shaka.ui.Watermark.Options} */
    const config = /** @type {!shaka.ui.Watermark.Options} */ (
      Object.assign({}, defaultOptions, options || defaultOptions)
    );

    if (config.type === 'static') {
      this.drawStaticWatermark_(config);
    } else if (config.type === 'dynamic') {
      this.startDynamicWatermark_(config);
    }
  }

  /**
   * Draws a static watermark on the canvas.
   * @param {!shaka.ui.Watermark.Options} config  configuration options
   * @private
   */
  drawStaticWatermark_(config) {
    const ctx = this.getContext2D_();
    if (!ctx) {
      return;
    }

    ctx.clearRect(0, 0, this.canvas_.width, this.canvas_.height);

    ctx.globalAlpha = config.alpha;
    ctx.fillStyle = config.color;
    ctx.font = `${config.size}px Arial`;

    const metrics = ctx.measureText(config.text);
    const padding = 20;
    let x;
    let y;

    switch (config.position) {
      case 'top-left':
        x = padding;
        y = config.size + padding;
        break;
      case 'top-right':
        x = this.canvas_.width - metrics.width - padding;
        y = config.size + padding;
        break;
      case 'bottom-left':
        x = padding;
        y = this.canvas_.height - padding;
        break;
      case 'bottom-right':
        x = this.canvas_.width - metrics.width - padding;
        y = this.canvas_.height - padding;
        break;
      default:
        x = (this.canvas_.width - metrics.width) / 2;
        y = (this.canvas_.height + config.size) / 2;
    }

    ctx.fillText(config.text, x, y);
  }

  /**
   * Starts a dynamic watermark animation on the canvas.
   * @param {!shaka.ui.Watermark.Options} config  configuration options
   * @private
   */
  startDynamicWatermark_(config) {
    const ctx = /** @type {!CanvasRenderingContext2D} */ (
      this.canvas_.getContext('2d')
    );
    let currentPosition = {left: 0, top: 0};
    let currentAlpha = 0;
    let phase = 'fadeIn'; // States: fadeIn, display, fadeOut, transition

    let displayFrames = Math.round(config.displayDuration * 60); // 60fps
    const transitionFrames = Math.round(config.transitionDuration * 60);
    const fadeSpeed = 1 / (transitionFrames / 2); // Smoother fade speed

    /** @private {number} */
    let positionIndex = 0;

    const getNextPosition = () => {
      ctx.font = `${config.size}px Arial`;
      const textMetrics = ctx.measureText(config.text);
      const textWidth = textMetrics.width;
      const textHeight = config.size;
      const padding = 20;

      // Define fixed positions
      const positions = [
        // Top-left
        {
          left: padding,
          top: textHeight + padding,
        },
        // Top-right
        {
          left: this.canvas_.width - textWidth - padding,
          top: textHeight + padding,
        },
        // Bottom-left
        {
          left: padding,
          top: this.canvas_.height - padding,
        },
        // Bottom-right
        {
          left: this.canvas_.width - textWidth - padding,
          top: this.canvas_.height - padding,
        },
        // Center
        {
          left: (this.canvas_.width - textWidth) / 2,
          top: (this.canvas_.height + textHeight) / 2,
        },
      ];

      // Cycle through positions
      const position = positions[positionIndex];
      positionIndex = (positionIndex + 1) % positions.length;
      return position;
    };

    currentPosition = getNextPosition();

    const updateWatermark = () => {
      if (!this.animationId_) {
        return;
      }

      const width = this.canvas_.width;
      const height = this.canvas_.height;
      ctx.clearRect(0, 0, width, height);

      // State machine for watermark phases
      switch (phase) {
        case 'fadeIn':
          currentAlpha = Math.min(config.alpha, currentAlpha + fadeSpeed);
          if (currentAlpha >= config.alpha) {
            phase = 'display';
          }
          break;
        case 'display':
          if (--displayFrames <= 0) {
            phase = 'fadeOut';
          }
          break;
        case 'fadeOut':
          currentAlpha = Math.max(0, currentAlpha - fadeSpeed);
          if (currentAlpha <= 0) {
            phase = 'transition';
            currentPosition = getNextPosition();
            displayFrames = Math.round(config.displayDuration * 60);
            phase = 'fadeIn';
          }
          break;
      }

      // Draw watermark if visible
      if (currentAlpha > 0) {
        ctx.globalAlpha = currentAlpha;
        ctx.fillStyle = config.color;
        ctx.font = `${config.size}px Arial`;
        ctx.fillText(config.text, currentPosition.left, currentPosition.top);
      }

      // Request next frame if animation is still active
      if (this.animationId_) {
        this.animationId_ = requestAnimationFrame(updateWatermark);
      }
    };

    // Start the animation loop
    this.animationId_ = requestAnimationFrame(updateWatermark);
  }

  /**
   * Removes the current watermark from the video and stops any animations.
   * @export
   */
  removeWatermark() {
    if (this.animationId_) {
      cancelAnimationFrame(this.animationId_);
      this.animationId_ = null;
    }
    const ctx = this.getContext2D_();
    if (!ctx) {
      return;
    }
    ctx.clearRect(0, 0, this.canvas_.width, this.canvas_.height);
  }

  /**
   * Releases the watermark instance and cleans up the canvas element.
   * @override
   */
  release() {
    if (this.canvas_ && this.canvas_.parentNode) {
      this.canvas_.parentNode.removeChild(this.canvas_);
    }

    // Clean up resize observer if it exists
    if (this.resizeObserver_) {
      this.resizeObserver_.disconnect();
      this.resizeObserver_ = null;
    } else {
      // Remove window resize listener if we were using that
      window.removeEventListener('resize', () => this.resizeCanvas_());
    }

    super.release();
  }
};