Source: ui/watermark.js

  1. /*! @license
  2. * Shaka Player
  3. * Copyright 2016 Google LLC
  4. * SPDX-License-Identifier: Apache-2.0
  5. */
  6. goog.provide('shaka.ui.Watermark');
  7. goog.requireType('shaka.ui.Controls');
  8. goog.require('shaka.ui.Element');
  9. goog.require('shaka.log');
  10. /**
  11. * A UI component that adds watermark functionality to the Shaka Player.
  12. * Allows adding text watermarks with various customization options.
  13. * @extends {shaka.ui.Element}
  14. * @final
  15. * @export
  16. */
  17. shaka.ui.Watermark = class extends shaka.ui.Element {
  18. /**
  19. * Creates a new Watermark instance.
  20. * @param {!HTMLElement} parent The parent element for the watermark canvas
  21. * @param {!shaka.ui.Controls} controls The controls instance
  22. */
  23. constructor(parent, controls) {
  24. super(parent, controls);
  25. /** @private {!HTMLCanvasElement} */
  26. this.canvas_ = /** @type {!HTMLCanvasElement} */ (
  27. document.createElement('canvas')
  28. );
  29. this.canvas_.style.position = 'absolute';
  30. this.canvas_.style.top = '0';
  31. this.canvas_.style.left = '0';
  32. this.canvas_.style.pointerEvents = 'none';
  33. this.parent.appendChild(this.canvas_);
  34. this.resizeCanvas_();
  35. /** @private {number|null} */
  36. this.animationId_ = null;
  37. /** @private {ResizeObserver|null} */
  38. this.resizeObserver_ = null;
  39. // Use ResizeObserver if available, fallback to window resize event
  40. if (window.ResizeObserver) {
  41. this.resizeObserver_ = new ResizeObserver(() => this.resizeCanvas_());
  42. this.resizeObserver_.observe(this.parent);
  43. } else {
  44. // Fallback for older browsers
  45. window.addEventListener('resize', () => this.resizeCanvas_());
  46. }
  47. }
  48. /**
  49. * Gets the 2D rendering context safely
  50. * @return {?CanvasRenderingContext2D}
  51. * @private
  52. */
  53. getContext2D_() {
  54. const ctx = this.canvas_.getContext('2d');
  55. if (!ctx) {
  56. shaka.log.error('2D context is not available');
  57. return null;
  58. }
  59. return /** @type {!CanvasRenderingContext2D} */ (ctx);
  60. }
  61. /**
  62. * Resize canvas to match video container
  63. * @private
  64. */
  65. resizeCanvas_() {
  66. this.canvas_.width = this.parent.offsetWidth;
  67. this.canvas_.height = this.parent.offsetHeight;
  68. }
  69. /**
  70. * Sets a text watermark on the video with customizable options.
  71. * The watermark can be either static (fixed position) or dynamic (moving).
  72. * @param {string} text The text to display as watermark
  73. * @param {?shaka.ui.Watermark.Options=} options configuration options
  74. * @export
  75. */
  76. setTextWatermark(text, options) {
  77. /** @type {!shaka.ui.Watermark.Options} */
  78. const defaultOptions = {
  79. type: 'static',
  80. text: text,
  81. position: 'top-right',
  82. color: 'rgba(255, 255, 255, 0.7)',
  83. size: 20,
  84. alpha: 0.7,
  85. interval: 2 * 1000,
  86. skip: 0.5 * 1000,
  87. displayDuration: 2 * 1000,
  88. transitionDuration: 0.5,
  89. };
  90. /** @type {!shaka.ui.Watermark.Options} */
  91. const config = /** @type {!shaka.ui.Watermark.Options} */ (
  92. Object.assign({}, defaultOptions, options || defaultOptions)
  93. );
  94. if (config.type === 'static') {
  95. this.drawStaticWatermark_(config);
  96. } else if (config.type === 'dynamic') {
  97. this.startDynamicWatermark_(config);
  98. }
  99. }
  100. /**
  101. * Draws a static watermark on the canvas.
  102. * @param {!shaka.ui.Watermark.Options} config configuration options
  103. * @private
  104. */
  105. drawStaticWatermark_(config) {
  106. const ctx = this.getContext2D_();
  107. if (!ctx) {
  108. return;
  109. }
  110. ctx.clearRect(0, 0, this.canvas_.width, this.canvas_.height);
  111. ctx.globalAlpha = config.alpha;
  112. ctx.fillStyle = config.color;
  113. ctx.font = `${config.size}px Arial`;
  114. const metrics = ctx.measureText(config.text);
  115. const padding = 20;
  116. let x;
  117. let y;
  118. switch (config.position) {
  119. case 'top-left':
  120. x = padding;
  121. y = config.size + padding;
  122. break;
  123. case 'top-right':
  124. x = this.canvas_.width - metrics.width - padding;
  125. y = config.size + padding;
  126. break;
  127. case 'bottom-left':
  128. x = padding;
  129. y = this.canvas_.height - padding;
  130. break;
  131. case 'bottom-right':
  132. x = this.canvas_.width - metrics.width - padding;
  133. y = this.canvas_.height - padding;
  134. break;
  135. default:
  136. x = (this.canvas_.width - metrics.width) / 2;
  137. y = (this.canvas_.height + config.size) / 2;
  138. }
  139. ctx.fillText(config.text, x, y);
  140. }
  141. /**
  142. * Starts a dynamic watermark animation on the canvas.
  143. * @param {!shaka.ui.Watermark.Options} config configuration options
  144. * @private
  145. */
  146. startDynamicWatermark_(config) {
  147. const ctx = /** @type {!CanvasRenderingContext2D} */ (
  148. this.canvas_.getContext('2d')
  149. );
  150. let currentPosition = {left: 0, top: 0};
  151. let currentAlpha = 0;
  152. let phase = 'fadeIn'; // States: fadeIn, display, fadeOut, transition
  153. let displayFrames = Math.round(config.displayDuration * 60); // 60fps
  154. const transitionFrames = Math.round(config.transitionDuration * 60);
  155. const fadeSpeed = 1 / (transitionFrames / 2); // Smoother fade speed
  156. /** @private {number} */
  157. let positionIndex = 0;
  158. const getNextPosition = () => {
  159. ctx.font = `${config.size}px Arial`;
  160. const textMetrics = ctx.measureText(config.text);
  161. const textWidth = textMetrics.width;
  162. const textHeight = config.size;
  163. const padding = 20;
  164. // Define fixed positions
  165. const positions = [
  166. // Top-left
  167. {
  168. left: padding,
  169. top: textHeight + padding,
  170. },
  171. // Top-right
  172. {
  173. left: this.canvas_.width - textWidth - padding,
  174. top: textHeight + padding,
  175. },
  176. // Bottom-left
  177. {
  178. left: padding,
  179. top: this.canvas_.height - padding,
  180. },
  181. // Bottom-right
  182. {
  183. left: this.canvas_.width - textWidth - padding,
  184. top: this.canvas_.height - padding,
  185. },
  186. // Center
  187. {
  188. left: (this.canvas_.width - textWidth) / 2,
  189. top: (this.canvas_.height + textHeight) / 2,
  190. },
  191. ];
  192. // Cycle through positions
  193. const position = positions[positionIndex];
  194. positionIndex = (positionIndex + 1) % positions.length;
  195. return position;
  196. };
  197. currentPosition = getNextPosition();
  198. const updateWatermark = () => {
  199. if (!this.animationId_) {
  200. return;
  201. }
  202. const width = this.canvas_.width;
  203. const height = this.canvas_.height;
  204. ctx.clearRect(0, 0, width, height);
  205. // State machine for watermark phases
  206. switch (phase) {
  207. case 'fadeIn':
  208. currentAlpha = Math.min(config.alpha, currentAlpha + fadeSpeed);
  209. if (currentAlpha >= config.alpha) {
  210. phase = 'display';
  211. }
  212. break;
  213. case 'display':
  214. if (--displayFrames <= 0) {
  215. phase = 'fadeOut';
  216. }
  217. break;
  218. case 'fadeOut':
  219. currentAlpha = Math.max(0, currentAlpha - fadeSpeed);
  220. if (currentAlpha <= 0) {
  221. phase = 'transition';
  222. currentPosition = getNextPosition();
  223. displayFrames = Math.round(config.displayDuration * 60);
  224. phase = 'fadeIn';
  225. }
  226. break;
  227. }
  228. // Draw watermark if visible
  229. if (currentAlpha > 0) {
  230. ctx.globalAlpha = currentAlpha;
  231. ctx.fillStyle = config.color;
  232. ctx.font = `${config.size}px Arial`;
  233. ctx.fillText(config.text, currentPosition.left, currentPosition.top);
  234. }
  235. // Request next frame if animation is still active
  236. if (this.animationId_) {
  237. this.animationId_ = requestAnimationFrame(updateWatermark);
  238. }
  239. };
  240. // Start the animation loop
  241. this.animationId_ = requestAnimationFrame(updateWatermark);
  242. }
  243. /**
  244. * Removes the current watermark from the video and stops any animations.
  245. * @export
  246. */
  247. removeWatermark() {
  248. if (this.animationId_) {
  249. cancelAnimationFrame(this.animationId_);
  250. this.animationId_ = null;
  251. }
  252. const ctx = this.getContext2D_();
  253. if (!ctx) {
  254. return;
  255. }
  256. ctx.clearRect(0, 0, this.canvas_.width, this.canvas_.height);
  257. }
  258. /**
  259. * Releases the watermark instance and cleans up the canvas element.
  260. * @override
  261. */
  262. release() {
  263. if (this.canvas_ && this.canvas_.parentNode) {
  264. this.canvas_.parentNode.removeChild(this.canvas_);
  265. }
  266. // Clean up resize observer if it exists
  267. if (this.resizeObserver_) {
  268. this.resizeObserver_.disconnect();
  269. this.resizeObserver_ = null;
  270. } else {
  271. // Remove window resize listener if we were using that
  272. window.removeEventListener('resize', () => this.resizeCanvas_());
  273. }
  274. super.release();
  275. }
  276. };