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(); + } +};