From 56523e49bfeb461f514a0148b035c1a68f2fcbf9 Mon Sep 17 00:00:00 2001 From: Chafroud Tarek Date: Tue, 14 Jan 2025 16:59:34 +0100 Subject: [PATCH] feat: Add Watermark Support to Shaka Player UI (#7877) This pull request introduces watermark support for the Shaka Player UI. Added functionality to overlay static or dynamic watermarks on videos. Included options to customize position, size, color, and opacity. Enhanced content security with dynamic watermarking capabilities. Resolves #7726 . --- CONTRIBUTORS | 1 + build/types/ui | 2 + demo/config.js | 5 + demo/main.js | 38 ++++ test/test/util/fake_demo_main.js | 13 ++ ui/externs/watermark.js | 64 +++++++ ui/ui.js | 28 +++ ui/watermark.js | 313 +++++++++++++++++++++++++++++++ 8 files changed, 464 insertions(+) create mode 100644 ui/externs/watermark.js create mode 100644 ui/watermark.js diff --git a/CONTRIBUTORS b/CONTRIBUTORS index ade2ba7e2..ca3782002 100644 --- a/CONTRIBUTORS +++ b/CONTRIBUTORS @@ -164,3 +164,4 @@ Koen Romers Zhenghang Chen Ashley Manners Bidisha Das +Chafroud Tarek diff --git a/build/types/ui b/build/types/ui index 8b97439db..f8fab3e1b 100644 --- a/build/types/ui +++ b/build/types/ui @@ -6,6 +6,7 @@ +../../ui/ad_statistics_button.js +../../ui/audio_language_selection.js +../../ui/externs/ui.js ++../../ui/externs/watermark.js +../../ui/play_button.js +../../ui/big_play_button.js +../../ui/airplay_button.js @@ -50,5 +51,6 @@ +../../ui/vr_utils.js +../../ui/vr_webgl.js ++../../ui/watermark.js +../../ui/gl_matrix/matrix_4x4.js +../../ui/gl_matrix/matrix_quaternion.js diff --git a/demo/config.js b/demo/config.js index 07ba1dd35..8d379def9 100644 --- a/demo/config.js +++ b/demo/config.js @@ -767,6 +767,11 @@ shakaDemo.Config = class { this.latestInput_.input().checked = true; } + this.addCustomTextInput_('Watermark text', (input) => { + shakaDemoMain.setWatermarkText(input.value); + }); + this.latestInput_.input().value = shakaDemoMain.getWatermarkText(); + // shaka.log is not set if logging isn't enabled. // I.E. if using the release version of shaka. if (!shaka['log']) { diff --git a/demo/main.js b/demo/main.js index bffc8b5e2..333d364e1 100644 --- a/demo/main.js +++ b/demo/main.js @@ -62,6 +62,9 @@ shakaDemo.Main = class { /** @private {boolean} */ this.customContextMenu_ = false; + /** @private {string} */ + this.watermarkText_ = ''; + /** @private {boolean} */ this.nativeControlsEnabled_ = false; @@ -397,6 +400,11 @@ shakaDemo.Main = class { uiConfig.overflowMenuButtons.push('visualizer'); } ui.configure(uiConfig); + if (this.watermarkText_) { + ui.setTextWatermark(this.watermarkText_); + } else { + ui.removeWatermark(); + } } /** @private */ @@ -829,6 +837,27 @@ shakaDemo.Main = class { return this.customContextMenu_; } + /** + * Set the text for watermark. + * + * @param {string} text + */ + setWatermarkText(text) { + this.watermarkText_ = text; + // Configure the UI, to add or remove the controls. + this.configureUI_(); + this.remakeHash(); + } + + /** + * Get the current text for watermark. + * + * @return {string} + */ + getWatermarkText() { + return this.watermarkText_; + } + /** * Enable or disable the native controls. * Goes into effect during the next load. @@ -1020,6 +1049,10 @@ shakaDemo.Main = class { this.configureUI_(); } + if ('watermarkText' in params) { + this.watermarkText_ = params['watermarkText']; + } + if ('visualizer' in params) { this.setIsVisualizerActive(true); } else { @@ -1601,6 +1634,10 @@ shakaDemo.Main = class { params.push('customContextMenu'); } + if (this.watermarkText_) { + params.push('watermarkText=' + this.watermarkText_); + } + if (this.getIsVisualizerActive()) { params.push('visualizer'); } @@ -2038,3 +2075,4 @@ document.addEventListener('shaka-ui-load-failed', (event) => { shakaDemoMain.initFailed(reasonCode); }); }); + diff --git a/test/test/util/fake_demo_main.js b/test/test/util/fake_demo_main.js index 75a8e6973..8873debf8 100644 --- a/test/test/util/fake_demo_main.js +++ b/test/test/util/fake_demo_main.js @@ -106,6 +106,19 @@ shaka.test.FakeDemoMain = class { this.getAssetUnsupportedReason = jasmine.createSpy('getAssetUnsupportedReason'); this.getAssetUnsupportedReason.and.returnValue(null); + + /** @private {string} */ + this.watermarkText_ = ''; + + /** @type {!jasmine.Spy} */ + this.getWatermarkText = jasmine.createSpy('getWatermarkText'); + this.getWatermarkText.and.callFake(() => this.watermarkText_); + + /** @type {!jasmine.Spy} */ + this.setWatermarkText = jasmine.createSpy('setWatermarkText'); + this.setWatermarkText.and.callFake((text) => { + this.watermarkText_ = text; + }); } /** Creates and assigns the mock demo main (and all of the real tab). */ diff --git a/ui/externs/watermark.js b/ui/externs/watermark.js new file mode 100644 index 000000000..6f7a783d0 --- /dev/null +++ b/ui/externs/watermark.js @@ -0,0 +1,64 @@ +/*! @license + * Shaka Player + * Copyright 2016 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + + +/** + * @externs + * @suppress {duplicate} To prevent compiler errors with the namespace + * being declared both here and by goog.provide in the library. + */ + + +/** + * @typedef {{ + * type: string, + * text: string, + * position: string, + * color: string, + * size: number, + * alpha: number, + * interval: number, + * skip: number, + * displayDuration: number, + * transitionDuration: number + * }} + * + * @property {string} type + * The type of watermark ('static' or 'dynamic'). + * Defaults to 'static'. + * @property {string} text + * The text content of the watermark. Required. + * @property {string} position + * Position of the watermark. + * Defaults to 'top-left'. + * @property {string} color + * The color of the watermark text. + * Defaults to 'white'. + * @property {number} size + * Font size of the watermark text in pixels. + * Defaults to 24. + * @property {number} alpha + * Opacity of the watermark (0.0 to 1.0). + * Defaults to 0.7. + * @property {number} interval + * Interval between position updates for dynamic watermarks (in seconds). + * Only used when type is 'dynamic'. + * Defaults to 2. + * @property {number} skip + * Skip duration for dynamic watermarks (in seconds). + * Only used when type is 'dynamic'. + * Defaults to 0.5. + * @property {number} displayDuration + * Duration to display watermark at each position (in seconds). + * Only used when type is 'dynamic'. + * Defaults to 2. + * @property {number} transitionDuration + * Duration of fade transitions between positions (in seconds). + * Only used when type is 'dynamic'. + * Defaults to 0.5. + * @exportDoc + */ +shaka.ui.Watermark.Options; diff --git a/ui/ui.js b/ui/ui.js index 2310c2ad2..430218a5a 100644 --- a/ui/ui.js +++ b/ui/ui.js @@ -14,6 +14,7 @@ goog.require('shaka.Player'); goog.require('shaka.log'); goog.require('shaka.polyfill'); goog.require('shaka.ui.Controls'); +goog.require('shaka.ui.Watermark'); goog.require('shaka.util.ConfigUtils'); goog.require('shaka.util.Dom'); goog.require('shaka.util.FakeEvent'); @@ -66,6 +67,11 @@ shaka.ui.Overlay = class { videoContainer['ui'] = this; video['ui'] = this; + /** @private {shaka.ui.Watermark} */ + this.watermark_ = new shaka.ui.Watermark( + this.videoContainer_, + this.controls_, + ); } @@ -83,6 +89,7 @@ shaka.ui.Overlay = class { await this.player_.destroy(); } this.player_ = null; + this.watermark_ = null; } @@ -168,6 +175,27 @@ shaka.ui.Overlay = class { } + /** + * @param {string} text + * @param {?shaka.ui.Watermark.Options=} options + * @export + */ + setTextWatermark(text, options) { + if (this.watermark_) { + this.watermark_.setTextWatermark(text, options); + } + } + + /** + * @export + */ + removeWatermark() { + if (this.watermark_) { + this.watermark_.removeWatermark(); + } + } + + /** * @return {!shaka.extern.UIConfiguration} * @private diff --git a/ui/watermark.js b/ui/watermark.js new file mode 100644 index 000000000..6c9e99191 --- /dev/null +++ b/ui/watermark.js @@ -0,0 +1,313 @@ +/*! @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(); + } +};