From c0bd26cb85c3c7e7f48dde5af1dcf88eb59995b1 Mon Sep 17 00:00:00 2001 From: ahmad-arbisoft Date: Fri, 18 Apr 2025 17:56:25 +0500 Subject: [PATCH 01/21] vanila js conversion :: async_process.js --- .../assets/video/public/js/async_process.js | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 xmodule/assets/video/public/js/async_process.js diff --git a/xmodule/assets/video/public/js/async_process.js b/xmodule/assets/video/public/js/async_process.js new file mode 100644 index 000000000000..a4d997e28e45 --- /dev/null +++ b/xmodule/assets/video/public/js/async_process.js @@ -0,0 +1,43 @@ +/** + * Provides a convenient way to process a large amount of data without UI blocking. + * + * @param {Array} list - The array to process. + * @param {Function} process - The function to execute on each item. + * @returns {Promise} - Resolves with the processed array. + */ +export const AsyncProcess = { + array(list, process) { + // Validate input + if (!Array.isArray(list)) { + return Promise.reject(new Error('Input is not an array')); + } + + if (typeof process !== 'function' || list.length === 0) { + return Promise.resolve(list); + } + + const MAX_DELAY = 50; + const result = []; + let index = 0; + const len = list.length; + + const handler = (resolve) => { + const start = Date.now(); + + while (index < len && Date.now() - start < MAX_DELAY) { + result[index] = process(list[index], index); + index++; + } + + if (index < len) { + setTimeout(() => handler(resolve), 25); + } else { + resolve(result); + } + }; + + return new Promise((resolve) => { + setTimeout(() => handler(resolve), 25); + }); + } +}; From a78b1951a0bdd1bdcdcecfe3ac5a55b1bcd34d5f Mon Sep 17 00:00:00 2001 From: ahmad-arbisoft Date: Fri, 18 Apr 2025 18:04:30 +0500 Subject: [PATCH 02/21] vanila js conversion :: i18n.js, iterator.js, component.js --- xmodule/assets/video/public/js/component.js | 68 ++++++++++++++++++ xmodule/assets/video/public/js/i18n.js | 18 +++++ xmodule/assets/video/public/js/iterator.js | 76 +++++++++++++++++++++ 3 files changed, 162 insertions(+) create mode 100644 xmodule/assets/video/public/js/component.js create mode 100644 xmodule/assets/video/public/js/i18n.js create mode 100644 xmodule/assets/video/public/js/iterator.js diff --git a/xmodule/assets/video/public/js/component.js b/xmodule/assets/video/public/js/component.js new file mode 100644 index 000000000000..ffa1d47d7bcd --- /dev/null +++ b/xmodule/assets/video/public/js/component.js @@ -0,0 +1,68 @@ +function isObject(val) { + return typeof val === 'object' && val !== null; +} + +function isFunction(val) { + return typeof val === 'function'; +} + +/** + * Polyfill-style safe object inheritance. + * Equivalent to `Object.create()` but more robust for legacy use cases. + * @param {Object} o + * @returns {Object} + */ +function inherit(o) { + if (arguments.length > 1) { + throw new Error('Second argument not supported'); + } + if (o === null || o === undefined) { + throw new Error('Cannot set a null [[Prototype]]'); + } + if (!isObject(o)) { + throw new TypeError('Argument must be an object'); + } + + return Object.create(o); +} + +/** + * Base Component class with extend() support + */ +export class Component { + constructor(...args) { + if (isFunction(this.initialize)) { + return this.initialize(...args); + } + } + + /** + * Creates a subclass of the current Component. + * @param {Object} protoProps - Prototype methods + * @param {Object} staticProps - Static methods + * @returns {Function} Subclass + */ + static extend(protoProps = {}, staticProps = {}) { + const Parent = this; + + class Child extends Parent { + constructor(...args) { + super(...args); + if (isFunction(this.initialize)) { + return this.initialize(...args); + } + } + } + + // Extend prototype with instance methods + Object.assign(Child.prototype, protoProps); + + // Extend constructor with static methods + Object.assign(Child, Parent, staticProps); + + // Reference to parent’s prototype (optional, for legacy support) + Child.__super__ = Parent.prototype; + + return Child; + } +} diff --git a/xmodule/assets/video/public/js/i18n.js b/xmodule/assets/video/public/js/i18n.js new file mode 100644 index 000000000000..24186a06a837 --- /dev/null +++ b/xmodule/assets/video/public/js/i18n.js @@ -0,0 +1,18 @@ +export const i18n = { + Play: gettext('Play'), + Pause: gettext('Pause'), + Mute: gettext('Mute'), + Unmute: gettext('Unmute'), + 'Exit full browser': gettext('Exit full browser'), + 'Fill browser': gettext('Fill browser'), + Speed: gettext('Speed'), + 'Auto-advance': gettext('Auto-advance'), + Volume: gettext('Volume'), + Muted: gettext('Muted'), // Volume level = 0% + 'Very low': gettext('Very low'), // 0-20% + Low: gettext('Low'), // 20-40% + Average: gettext('Average'), // 40-60% + Loud: gettext('Loud'), // 60-80% + 'Very loud': gettext('Very loud'), // 80-99% + Maximum: gettext('Maximum') // 100% +}; diff --git a/xmodule/assets/video/public/js/iterator.js b/xmodule/assets/video/public/js/iterator.js new file mode 100644 index 000000000000..121169a4699d --- /dev/null +++ b/xmodule/assets/video/public/js/iterator.js @@ -0,0 +1,76 @@ +export class Iterator { + /** + * @param {Array} list - Array to be iterated. + */ + constructor(list) { + this.list = Array.isArray(list) ? list : []; + this.index = 0; + this.size = this.list.length; + this.lastIndex = this.size - 1; + } + + /** + * Checks if the provided index is valid. + * @param {number} index + * @return {boolean} + * @protected + */ + _isValid(index) { + return typeof index === 'number' && + Number.isInteger(index) && + index >= 0 && + index < this.size; + } + + /** + * Returns the next element and updates the current position. + * @param {number} [index] + * @return {any} + */ + next(index = this.index) { + if (!this._isValid(index)) { + index = this.index; + } + + this.index = (index >= this.lastIndex) ? 0 : index + 1; + return this.list[this.index]; + } + + /** + * Returns the previous element and updates the current position. + * @param {number} [index] + * @return {any} + */ + prev(index = this.index) { + if (!this._isValid(index)) { + index = this.index; + } + + this.index = (index < 1) ? this.lastIndex : index - 1; + return this.list[this.index]; + } + + /** + * Returns the last element in the list. + * @return {any} + */ + last() { + return this.list[this.lastIndex]; + } + + /** + * Returns the first element in the list. + * @return {any} + */ + first() { + return this.list[0]; + } + + /** + * Checks if the iterator is at the end. + * @return {boolean} + */ + isEnd() { + return this.index === this.lastIndex; + } +} From c5af8501dd6f36f112b0f1e3af7f28ba6d4b7018 Mon Sep 17 00:00:00 2001 From: ahmad-arbisoft Date: Mon, 21 Apr 2025 12:56:12 +0500 Subject: [PATCH 03/21] fix: update function calling and definitions --- xmodule/assets/video/public/js/component.js | 107 +-- xmodule/assets/video/public/js/initialize.js | 825 ++++++++++++++++++ xmodule/assets/video/public/js/iterator.js | 60 +- .../video/public/js/video_block_main.js | 7 +- 4 files changed, 914 insertions(+), 85 deletions(-) create mode 100644 xmodule/assets/video/public/js/initialize.js diff --git a/xmodule/assets/video/public/js/component.js b/xmodule/assets/video/public/js/component.js index ffa1d47d7bcd..6833eeafe388 100644 --- a/xmodule/assets/video/public/js/component.js +++ b/xmodule/assets/video/public/js/component.js @@ -1,68 +1,69 @@ -function isObject(val) { - return typeof val === 'object' && val !== null; -} - -function isFunction(val) { - return typeof val === 'function'; -} +'use strict'; /** - * Polyfill-style safe object inheritance. - * Equivalent to `Object.create()` but more robust for legacy use cases. - * @param {Object} o - * @returns {Object} + * Creates a new object with the specified prototype object and properties. + * @param {Object} o The object which should be the prototype of the newly-created object. + * @private + * @throws {TypeError, Error} + * @return {Object} */ -function inherit(o) { - if (arguments.length > 1) { - throw new Error('Second argument not supported'); - } - if (o === null || o === undefined) { - throw new Error('Cannot set a null [[Prototype]]'); - } - if (!isObject(o)) { - throw new TypeError('Argument must be an object'); - } +const inherit = Object.create || (function () { + const F = function () { }; - return Object.create(o); -} + return function (o) { + if (arguments.length > 1) { + throw Error('Second argument not supported'); + } + if (_.isNull(o) || _.isUndefined(o)) { + throw Error('Cannot set a null [[Prototype]]'); + } + if (!_.isObject(o)) { + throw TypeError('Argument must be an object'); + } + + F.prototype = o; + return new F(); + }; +}()); /** - * Base Component class with extend() support + * Component constructor function. + * Calls `initialize()` if defined. + * @returns {any} */ -export class Component { - constructor(...args) { - if (isFunction(this.initialize)) { - return this.initialize(...args); - } +function Component() { + if ($.isFunction(this.initialize)) { + return this.initialize.apply(this, arguments); } +} - /** - * Creates a subclass of the current Component. - * @param {Object} protoProps - Prototype methods - * @param {Object} staticProps - Static methods - * @returns {Function} Subclass - */ - static extend(protoProps = {}, staticProps = {}) { - const Parent = this; +/** + * Adds an `extend` method to the Component constructor. + * Creates a subclass that inherits from Component. + * @param {Object} protoProps - Prototype methods and properties. + * @param {Object} staticProps - Static methods and properties. + * @returns {Function} Child constructor. + */ +Component.extend = function (protoProps, staticProps) { + const Parent = this; - class Child extends Parent { - constructor(...args) { - super(...args); - if (isFunction(this.initialize)) { - return this.initialize(...args); - } - } + const Child = function () { + if ($.isFunction(this.initialize)) { + return this.initialize.apply(this, arguments); } + }; - // Extend prototype with instance methods - Object.assign(Child.prototype, protoProps); + Child.prototype = inherit(Parent.prototype); + Child.constructor = Parent; + Child.__super__ = Parent.prototype; - // Extend constructor with static methods - Object.assign(Child, Parent, staticProps); + if (protoProps) { + $.extend(Child.prototype, protoProps); + } - // Reference to parent’s prototype (optional, for legacy support) - Child.__super__ = Parent.prototype; + $.extend(Child, Parent, staticProps); - return Child; - } -} + return Child; +}; + +export { Component }; diff --git a/xmodule/assets/video/public/js/initialize.js b/xmodule/assets/video/public/js/initialize.js new file mode 100644 index 000000000000..879c76eb5030 --- /dev/null +++ b/xmodule/assets/video/public/js/initialize.js @@ -0,0 +1,825 @@ + +console.log("Within Initialize.js!"); +/** + * @function + * + * Initialize module exports this function. + * + * @param {object} state The object containg the state of the video player. + * All other modules, their parameters, public letiables, etc. are + * available via this object. + * @param {DOM element} element Container of the entire Video DOM element. + */ +let Initialize = function (state, element) { + _makeFunctionsPublic(state); + + state.initialize(element) + .done(function () { + if (state.isYoutubeType()) { + state.parseSpeed(); + } + // On iPhones and iPods native controls are used. + if (/iP(hone|od)/i.test(state.isTouch[0])) { + _hideWaitPlaceholder(state); + state.el.trigger('initialize', arguments); + + return false; + } + + _initializeModules(state, i18n) + .done(function () { + // On iPad ready state occurs just after start playing. + // We hide controls before video starts playing. + if (/iPad|Android/i.test(state.isTouch[0])) { + state.el.on('play', _.once(function () { + state.trigger('videoControl.show', null); + })); + } else { + // On PC show controls immediately. + state.trigger('videoControl.show', null); + } + + _hideWaitPlaceholder(state); + state.el.trigger('initialize', arguments); + }); + }); +}, + + /* eslint-disable no-use-before-define */ + methodsDict = { + bindTo, + fetchMetadata, + getCurrentLanguage, + getDuration, + getPlayerMode, + getVideoMetadata, + initialize, + isHtml5Mode, + isFlashMode, + isYoutubeType, + parseSpeed, + parseYoutubeStreams, + setPlayerMode, + setSpeed, + setAutoAdvance, + speedToString, + trigger, + youtubeId, + loadHtmlPlayer, + loadYoutubePlayer, + loadYouTubeIFrameAPI + }, + /* eslint-enable no-use-before-define */ + + _youtubeApiDeferred = null, + _oldOnYouTubeIframeAPIReady; + +Initialize.prototype = methodsDict; + +// *************************************************************** +// Private functions start here. Private functions start with underscore. +// *************************************************************** + +/** +* @function _makeFunctionsPublic +* +* Functions which will be accessible via 'state' object. When called, +* these functions will get the 'state' +* object as a context. +* +* @param {object} state The object containg the state (properties, +* methods, modules) of the Video player. +*/ +function _makeFunctionsPublic(state) { + bindTo(methodsDict, state, state); +} + +// function _renderElements(state) +// +// Create any necessary DOM elements, attach them, and set their +// initial configuration. Also make the created DOM elements available +// via the 'state' object. Much easier to work this way - you don't +// have to do repeated jQuery element selects. +function _renderElements(state) { + // Launch embedding of actual video content, or set it up so that it + // will be done as soon as the appropriate video player (YouTube or + // stand-alone HTML5) is loaded, and can handle embedding. + // + // Note that the loading of stand alone HTML5 player API is handled by + // Require JS. At the time when we reach this code, the stand alone + // HTML5 player is already loaded, so no further testing in that case + // is required. + let video, onYTApiReady, setupOnYouTubeIframeAPIReady; + + if (state.videoType === 'youtube') { + state.youtubeApiAvailable = false; + + onYTApiReady = function () { + console.log('[Video info]: YouTube API is available and is loaded.'); + if (state.htmlPlayerLoaded) { return; } + + console.log('[Video info]: Starting YouTube player.'); + video = VideoPlayer(state); + + state.modules.push(video); + state.__dfd__.resolve(); + state.youtubeApiAvailable = true; + }; + + if (window.YT) { + // If we have a Deferred object responsible for calling OnYouTubeIframeAPIReady + // callbacks, make sure that they have all been called by trying to resolve the + // Deferred object. Upon resolving, all the OnYouTubeIframeAPIReady will be + // called. If the object has been already resolved, the callbacks will not + // be called a second time. + if (_youtubeApiDeferred) { + _youtubeApiDeferred.resolve(); + } + + window.YT.ready(onYTApiReady); + } else { + // There is only one global letiable window.onYouTubeIframeAPIReady which + // is supposed to be a function that will be called by the YouTube API + // when it finished initializing. This function will update this global function + // so that it resolves our Deferred object, which will call all of the + // OnYouTubeIframeAPIReady callbacks. + // + // If this global function is already defined, we store it first, and make + // sure that it gets executed when our Deferred object is resolved. + setupOnYouTubeIframeAPIReady = function () { + _oldOnYouTubeIframeAPIReady = window.onYouTubeIframeAPIReady || undefined; + + window.onYouTubeIframeAPIReady = function () { + _youtubeApiDeferred.resolve(); + }; + + window.onYouTubeIframeAPIReady.done = _youtubeApiDeferred.done; + + if (_oldOnYouTubeIframeAPIReady) { + window.onYouTubeIframeAPIReady.done(_oldOnYouTubeIframeAPIReady); + } + }; + + // If a Deferred object hasn't been created yet, create one now. It will + // be responsible for calling OnYouTubeIframeAPIReady callbacks once the + // YouTube API loads. After creating the Deferred object, load the YouTube + // API. + if (!_youtubeApiDeferred) { + _youtubeApiDeferred = $.Deferred(); + setupOnYouTubeIframeAPIReady(); + } else if (!window.onYouTubeIframeAPIReady || !window.onYouTubeIframeAPIReady.done) { + // The Deferred object could have been already defined in a previous + // initialization of the video module. However, since then the global letiable + // window.onYouTubeIframeAPIReady could have been overwritten. If so, + // we should set it up again. + setupOnYouTubeIframeAPIReady(); + } + + // Attach a callback to our Deferred object to be called once the + // YouTube API loads. + window.onYouTubeIframeAPIReady.done(function () { + window.YT.ready(onYTApiReady); + }); + } + } else { + video = VideoPlayer(state); + + state.modules.push(video); + state.__dfd__.resolve(); + state.htmlPlayerLoaded = true; + } +} + +function _waitForYoutubeApi(state) { + console.log('[Video info]: Starting to wait for YouTube API to load.'); + window.setTimeout(function () { + // If YouTube API will load OK, it will run `onYouTubeIframeAPIReady` + // callback, which will set `state.youtubeApiAvailable` to `true`. + // If something goes wrong at this stage, `state.youtubeApiAvailable` is + // `false`. + if (!state.youtubeApiAvailable) { + console.log('[Video info]: YouTube API is not available.'); + if (!state.htmlPlayerLoaded) { + state.loadHtmlPlayer(); + } + } + state.el.trigger('youtube_availability', [state.youtubeApiAvailable]); + }, state.config.ytTestTimeout); +} + +function loadYouTubeIFrameAPI(scriptTag) { + let firstScriptTag = document.getElementsByTagName('script')[0]; + firstScriptTag.parentNode.insertBefore(scriptTag, firstScriptTag); +} + +// function _parseYouTubeIDs(state) +// The function parse YouTube stream ID's. +// @return +// false: We don't have YouTube video IDs to work with; most likely +// we have HTML5 video sources. +// true: Parsing of YouTube video IDs went OK, and we can proceed +// onwards to play YouTube videos. +function _parseYouTubeIDs(state) { + if (state.parseYoutubeStreams(state.config.streams)) { + state.videoType = 'youtube'; + + return true; + } + + console.log( + '[Video info]: Youtube Video IDs are incorrect or absent.' + ); + + return false; +} + +/** +* Extract HLS video URLs from available video URLs. +* +* @param {object} state The object contaning the state (properties, methods, modules) of the Video player. +* @returns Array of available HLS video source urls. +*/ +function extractHLSVideoSources(state) { + return _.filter(state.config.sources, function (source) { + return /\.m3u8(\?.*)?$/.test(source); + }); +} + +// function _prepareHTML5Video(state) +// The function prepare HTML5 video, parse HTML5 +// video sources etc. +function _prepareHTML5Video(state) { + state.speeds = ['0.75', '1.0', '1.25', '1.50', '2.0']; + // If none of the supported video formats can be played and there is no + // short-hand video links, than hide the spinner and show error message. + if (!state.config.sources.length) { + _hideWaitPlaceholder(state); + state.el + .find('.video-player div') + .addClass('hidden'); + state.el + .find('.video-player .video-error') + .removeClass('is-hidden'); + + return false; + } + + state.videoType = 'html5'; + + if (!_.keys(state.config.transcriptLanguages).length) { + state.config.showCaptions = false; + } + state.setSpeed(state.speed); + + return true; +} + +function _hideWaitPlaceholder(state) { + state.el + .addClass('is-initialized') + .find('.spinner') + .attr({ + 'aria-hidden': 'true', + tabindex: -1 + }); +} + +function _setConfigurations(state) { + state.setPlayerMode(state.config.mode); + // Possible value are: 'visible', 'hiding', and 'invisible'. + state.controlState = 'visible'; + state.controlHideTimeout = null; + state.captionState = 'invisible'; + state.captionHideTimeout = null; + state.HLSVideoSources = extractHLSVideoSources(state); +} + +// eslint-disable-next-line no-shadow +function _initializeModules(state, i18n) { + let dfd = $.Deferred(), + modulesList = $.map(state.modules, function (module) { + let options = state.options[module.moduleName] || {}; + if (_.isFunction(module)) { + return module(state, i18n, options); + } else if ($.isPlainObject(module)) { + return module; + } + }); + + $.when.apply(null, modulesList) + .done(dfd.resolve); + + return dfd.promise(); +} + +function _getConfiguration(data, storage) { + let isBoolean = function (value) { + let regExp = /^true$/i; + return regExp.test(value.toString()); + }, + // List of keys that will be extracted form the configuration. + extractKeys = [], + // Compatibility keys used to change names of some parameters in + // the final configuration. + compatKeys = { + start: 'startTime', + end: 'endTime' + }, + // Conversions used to pre-process some configuration data. + conversions = { + showCaptions: isBoolean, + autoplay: isBoolean, + autohideHtml5: isBoolean, + autoAdvance: function (value) { + let shouldAutoAdvance = storage.getItem('auto_advance'); + if (_.isUndefined(shouldAutoAdvance)) { + return isBoolean(value) || false; + } else { + return shouldAutoAdvance; + } + }, + savedVideoPosition: function (value) { + return storage.getItem('savedVideoPosition', true) + || Number(value) + || 0; + }, + speed: function (value) { + return storage.getItem('speed', true) || value; + }, + generalSpeed: function (value) { + return storage.getItem('general_speed') + || value + || '1.0'; + }, + transcriptLanguage: function (value) { + return storage.getItem('language') + || value + || 'en'; + }, + ytTestTimeout: function (value) { + value = parseInt(value, 10); + + if (!isFinite(value)) { + value = 1500; + } + + return value; + }, + startTime: function (value) { + value = parseInt(value, 10); + if (!isFinite(value) || value < 0) { + return 0; + } + + return value; + }, + endTime: function (value) { + value = parseInt(value, 10); + + if (!isFinite(value) || value === 0) { + return null; + } + + return value; + } + }, + config = {}; + + data = _.extend({ + startTime: 0, + endTime: null, + sub: '', + streams: '' + }, data); + + $.each(data, function (option, value) { + // Extract option that is in `extractKeys`. + if ($.inArray(option, extractKeys) !== -1) { + return; + } + + // Change option name to key that is in `compatKeys`. + if (compatKeys[option]) { + option = compatKeys[option]; + } + + // Pre-process data. + if (conversions[option]) { + if (_.isFunction(conversions[option])) { + value = conversions[option].call(this, value); + } else { + throw new TypeError(option + ' is not a function.'); + } + } + config[option] = value; + }); + + return config; +} + +// *************************************************************** +// Public functions start here. +// These are available via the 'state' object. Their context ('this' +// keyword) is the 'state' object. The magic private function that makes +// them available and sets up their context is makeFunctionsPublic(). +// *************************************************************** + +// function bindTo(methodsDict, obj, context, rewrite) +// Creates a new function with specific context and assigns it to the provided +// object. +// eslint-disable-next-line no-shadow +function bindTo(methodsDict, obj, context, rewrite) { + $.each(methodsDict, function (name, method) { + if (_.isFunction(method)) { + if (_.isUndefined(rewrite)) { + rewrite = true; + } + + if (_.isUndefined(obj[name]) || rewrite) { + obj[name] = _.bind(method, context); + } + } + }); +} + +function loadYoutubePlayer() { + if (this.htmlPlayerLoaded) { return; } + + console.log( + '[Video info]: Fetch metadata for YouTube video.' + ); + + this.fetchMetadata(); + this.parseSpeed(); +} + +function loadHtmlPlayer() { + // When the youtube link doesn't work for any reason + // (for example, firewall) any + // alternate sources should automatically play. + if (!_prepareHTML5Video(this)) { + console.log( + '[Video info]: Continue loading ' + + 'YouTube video.' + ); + + // Non-YouTube sources were not found either. + + this.el.find('.video-player div') + .removeClass('hidden'); + this.el.find('.video-player .video-error') + .addClass('is-hidden'); + + // If in reality the timeout was to short, try to + // continue loading the YouTube video anyways. + this.loadYoutubePlayer(); + } else { + console.log( + '[Video info]: Start HTML5 player.' + ); + + // In-browser HTML5 player does not support quality + // control. + this.el.find('.quality_control').hide(); + _renderElements(this); + } +} + +// function initialize(element) +// The function set initial configuration and preparation. + +function initialize(element) { + let self = this, + el = this.el, + id = this.id, + container = el.find('.video-wrapper'), + __dfd__ = $.Deferred(), + isTouch = onTouchBasedDevice() || ''; + + if (isTouch) { + el.addClass('is-touch'); + } + + $.extend(this, { + __dfd__: __dfd__, + container: container, + isFullScreen: false, + isTouch: isTouch + }); + + console.log('[Video info]: Initializing video with id "%s".', id); + + // We store all settings passed to us by the server in one place. These + // are "read only", so don't modify them. All letiable content lives in + // 'state' object. + // jQuery .data() return object with keys in lower camelCase format. + this.config = $.extend({}, _getConfiguration(this.metadata, this.storage), { + element: element, + fadeOutTimeout: 1400, + captionsFreezeTime: 10000, + mode: $.cookie('edX_video_player_mode'), + // Available HD qualities will only be accessible once the video has + // been played once, via player.getAvailableQualityLevels. + availableHDQualities: [] + }); + + if (this.config.endTime < this.config.startTime) { + this.config.endTime = null; + } + + this.lang = this.config.transcriptLanguage; + this.speed = this.speedToString( + this.config.speed || this.config.generalSpeed + ); + this.auto_advance = this.config.autoAdvance; + this.htmlPlayerLoaded = false; + this.duration = this.metadata.duration; + + _setConfigurations(this); + + // If `prioritizeHls` is set to true than `hls` is the primary playback + if (this.config.prioritizeHls || !(_parseYouTubeIDs(this))) { + // If we do not have YouTube ID's, try parsing HTML5 video sources. + if (!_prepareHTML5Video(this)) { + __dfd__.reject(); + // Non-YouTube sources were not found either. + return __dfd__.promise(); + } + + console.log('[Video info]: Start player in HTML5 mode.'); + _renderElements(this); + } else { + _renderElements(this); + + _waitForYoutubeApi(this); + + let scriptTag = document.createElement('script'); + + scriptTag.src = this.config.ytApiUrl; + scriptTag.async = true; + + $(scriptTag).on('load', function () { + self.loadYoutubePlayer(); + }); + $(scriptTag).on('error', function () { + console.log( + '[Video info]: YouTube returned an error for ' + + 'video with id "' + self.id + '".' + ); + // If the video is already loaded in `_waitForYoutubeApi` by the + // time we get here, then we shouldn't load it again. + if (!self.htmlPlayerLoaded) { + self.loadHtmlPlayer(); + } + }); + + window.Video.loadYouTubeIFrameAPI(scriptTag); + } + return __dfd__.promise(); +} + +// function parseYoutubeStreams(state, youtubeStreams) +// +// Take a string in the form: +// "iCawTYPtehk:0.75,KgpclqP-LBA:1.0,9-2670d5nvU:1.5" +// parse it, and make it available via the 'state' object. If we are +// not given a string, or it's length is zero, then we return false. +// +// @return +// false: We don't have YouTube video IDs to work with; most likely +// we have HTML5 video sources. +// true: Parsing of YouTube video IDs went OK, and we can proceed +// onwards to play YouTube videos. +function parseYoutubeStreams(youtubeStreams) { + if (_.isUndefined(youtubeStreams) || !youtubeStreams.length) { + return false; + } + + this.videos = {}; + + _.each(youtubeStreams.split(/,/), function (video) { + let speed; + video = video.split(/:/); + speed = this.speedToString(video[0]); + this.videos[speed] = video[1]; + }, this); + + return _.isString(this.videos['1.0']); +} + +// function fetchMetadata() +// +// When dealing with YouTube videos, we must fetch meta data that has +// certain key facts not available while the video is loading. For +// example the length of the video can be determined from the meta +// data. +function fetchMetadata() { + let self = this, + metadataXHRs = []; + + this.metadata = {}; + + metadataXHRs = _.map(this.videos, function (url, speed) { + return self.getVideoMetadata(url, function (data) { + if (data.items.length > 0) { + let metaDataItem = data.items[0]; + self.metadata[metaDataItem.id] = metaDataItem.contentDetails; + } + }); + }); + + $.when.apply(this, metadataXHRs).done(function () { + self.el.trigger('metadata_received'); + + // Not only do we trigger the "metadata_received" event, we also + // set a flag to notify that metadata has been received. This + // allows for code that will miss the "metadata_received" event + // to know that metadata has been received. This is important in + // cases when some code will subscribe to the "metadata_received" + // event after it has been triggered. + self.youtubeMetadataReceived = true; + }); +} + +// function parseSpeed() +// +// Create a separate array of available speeds. +function parseSpeed() { + this.speeds = _.keys(this.videos).sort(); +} + +function setSpeed(newSpeed) { + // Possible speeds for each player type. + // HTML5 = [0.75, 1, 1.25, 1.5, 2] + // Youtube Flash = [0.75, 1, 1.25, 1.5] + // Youtube HTML5 = [0.25, 0.5, 1, 1.5, 2] + let map = { + 0.25: '0.75', // Youtube HTML5 -> HTML5 or Youtube Flash + '0.50': '0.75', // Youtube HTML5 -> HTML5 or Youtube Flash + 0.75: '0.50', // HTML5 or Youtube Flash -> Youtube HTML5 + 1.25: '1.50', // HTML5 or Youtube Flash -> Youtube HTML5 + 2.0: '1.50' // HTML5 or Youtube HTML5 -> Youtube Flash + }; + + if (_.contains(this.speeds, newSpeed)) { + this.speed = newSpeed; + } else { + newSpeed = map[newSpeed]; + this.speed = _.contains(this.speeds, newSpeed) ? newSpeed : '1.0'; + } + this.speed = parseFloat(this.speed); +} + +function setAutoAdvance(enabled) { + this.auto_advance = enabled; +} + +function getVideoMetadata(url, callback) { + let youTubeEndpoint; + if (!(_.isString(url))) { + url = this.videos['1.0'] || ''; + } + // Will hit the API URL to get the youtube video metadata. + youTubeEndpoint = this.config.ytMetadataEndpoint; // The new runtime supports anonymous users + // and uses an XBlock handler to get YouTube metadata + if (!youTubeEndpoint) { + // The old runtime has a full/separate LMS API for getting YouTube metadata, but it doesn't + // support anonymous users nor videos that play in a sandboxed iframe. + youTubeEndpoint = [this.config.lmsRootURL, '/courses/yt_video_metadata', '?id=', url].join(''); + } + return $.ajax({ + url: youTubeEndpoint, + success: _.isFunction(callback) ? callback : null, + error: function () { + console.warn( + 'Unable to get youtube video metadata. Some video metadata may be unavailable.' + ); + }, + notifyOnError: false + }); +} + +function youtubeId(speed) { + let currentSpeed = this.isFlashMode() ? this.speed : '1.0'; + + return this.videos[speed] + || this.videos[currentSpeed] + || this.videos['1.0']; +} + +function getDuration() { + try { + return moment.duration(this.metadata[this.youtubeId()].duration, moment.ISO_8601).asSeconds(); + } catch (err) { + return _.result(this.metadata[this.youtubeId('1.0')], 'duration') || 0; + } +} + +/** +* Sets player mode. +* +* @param {string} mode Mode to set for the video player if it is supported. +* Otherwise, `html5` is used by default. +*/ +function setPlayerMode(mode) { + let supportedModes = ['html5', 'flash']; + + mode = _.contains(supportedModes, mode) ? mode : 'html5'; + this.currentPlayerMode = mode; +} + +/** +* Returns current player mode. +* +* @return {string} Returns string that describes player mode +*/ +function getPlayerMode() { + return this.currentPlayerMode; +} + +/** +* Checks if current player mode is Flash. +* +* @return {boolean} Returns `true` if current mode is `flash`, otherwise +* it returns `false` +*/ +function isFlashMode() { + return this.getPlayerMode() === 'flash'; +} + +/** +* Checks if current player mode is Html5. +* +* @return {boolean} Returns `true` if current mode is `html5`, otherwise +* it returns `false` +*/ +function isHtml5Mode() { + return this.getPlayerMode() === 'html5'; +} + +function isYoutubeType() { + return this.videoType === 'youtube'; +} + +function speedToString(speed) { + return parseFloat(speed).toFixed(2).replace(/\.00$/, '.0'); +} + +function getCurrentLanguage() { + let keys = _.keys(this.config.transcriptLanguages); + + if (keys.length) { + if (!_.contains(keys, this.lang)) { + if (_.contains(keys, 'en')) { + this.lang = 'en'; + } else { + this.lang = keys.pop(); + } + } + } else { + return null; + } + + return this.lang; +} + +/* +* The trigger() function will assume that the @objChain is a complete +* chain with a method (function) at the end. It will call this function. +* So for example, when trigger() is called like so: +* +* state.trigger('videoPlayer.pause', {'param1': 10}); +* +* Then trigger() will execute: +* +* state.videoPlayer.pause({'param1': 10}); +*/ +function trigger(objChain) { + let extraParameters = Array.prototype.slice.call(arguments, 1), + i, tmpObj, chain; + + // Remember that 'this' is the 'state' object. + tmpObj = this; + chain = objChain.split('.'); + + // At the end of the loop the letiable 'tmpObj' will either be the + // correct object/function to trigger/invoke. If the 'chain' chain of + // object is incorrect (one of the link is non-existent), then the loop + // will immediately exit. + while (chain.length) { + i = chain.shift(); + + if (tmpObj.hasOwnProperty(i)) { + tmpObj = tmpObj[i]; + } else { + // An incorrect object chain was specified. + + return false; + } + } + + tmpObj.apply(this, extraParameters); + + return true; +} +export { Initialize } \ No newline at end of file diff --git a/xmodule/assets/video/public/js/iterator.js b/xmodule/assets/video/public/js/iterator.js index 121169a4699d..59d2ba2bbfb5 100644 --- a/xmodule/assets/video/public/js/iterator.js +++ b/xmodule/assets/video/public/js/iterator.js @@ -1,54 +1,54 @@ -export class Iterator { - /** - * @param {Array} list - Array to be iterated. - */ - constructor(list) { - this.list = Array.isArray(list) ? list : []; - this.index = 0; - this.size = this.list.length; - this.lastIndex = this.size - 1; - } +'use strict'; +/** + * Provides a convenient way to work with iterable data. + * @constructor + * @param {Array} list Array to be iterated. + */ +function Iterator(list) { + this.list = list; + this.index = 0; + this.size = this.list.length; + this.lastIndex = this.list.length - 1; +} + +Iterator.prototype = { /** - * Checks if the provided index is valid. + * Checks validity of the provided index for the iterator. * @param {number} index * @return {boolean} - * @protected */ _isValid(index) { - return typeof index === 'number' && - Number.isInteger(index) && - index >= 0 && - index < this.size; - } + return _.isNumber(index) && index < this.size && index >= 0; + }, /** - * Returns the next element and updates the current position. - * @param {number} [index] + * Returns the next element. + * @param {number} [index] Updates current position. * @return {any} */ - next(index = this.index) { + next(index) { if (!this._isValid(index)) { index = this.index; } this.index = (index >= this.lastIndex) ? 0 : index + 1; return this.list[this.index]; - } + }, /** - * Returns the previous element and updates the current position. - * @param {number} [index] + * Returns the previous element. + * @param {number} [index] Updates current position. * @return {any} */ - prev(index = this.index) { + prev(index) { if (!this._isValid(index)) { index = this.index; } this.index = (index < 1) ? this.lastIndex : index - 1; return this.list[this.index]; - } + }, /** * Returns the last element in the list. @@ -56,7 +56,7 @@ export class Iterator { */ last() { return this.list[this.lastIndex]; - } + }, /** * Returns the first element in the list. @@ -64,13 +64,15 @@ export class Iterator { */ first() { return this.list[0]; - } + }, /** - * Checks if the iterator is at the end. + * Returns `true` if the current position is the last for the iterator. * @return {boolean} */ isEnd() { return this.index === this.lastIndex; } -} +}; + +export { Iterator }; \ No newline at end of file diff --git a/xmodule/assets/video/public/js/video_block_main.js b/xmodule/assets/video/public/js/video_block_main.js index 902c6e704f21..5c2026d820b8 100644 --- a/xmodule/assets/video/public/js/video_block_main.js +++ b/xmodule/assets/video/public/js/video_block_main.js @@ -4,9 +4,10 @@ import _ from 'underscore'; import {VideoStorage} from './video_storage'; import {VideoPoster} from './poster'; import {VideoTranscriptDownloadHandler} from './video_accessible_menu'; +import {Initialize} from './initialize'; // TODO: Uncomment the imports -// import { initialize } from './initialize'; // Assuming this function is imported +// import { initialize } from './initialize'; // Assuming this function is imported // import { // FocusGrabber, // VideoControl, @@ -116,7 +117,7 @@ console.log('In video_block_main.js file'); return () => { _.extend(innerState.metadata, {autoplay: true, focusFirstControl: true}); // TODO: Uncomment following initialize method calling - // initialize(innerState, element); + Initialize(innerState, element); }; }; @@ -150,7 +151,7 @@ console.log('In video_block_main.js file'); }); } else { // TODO: Uncomment following initialize method calling - // initialize(state, element); + Initialize(state, element); } if (!youtubeXhr) { From 5780f4ddd1746aac62b4be162773fc4920efda5b Mon Sep 17 00:00:00 2001 From: ahmad-arbisoft Date: Mon, 21 Apr 2025 13:33:16 +0500 Subject: [PATCH 04/21] vanila js conversion :: html5_video.js --- xmodule/assets/video/public/js/html5_video.js | 290 ++++++++++++++++++ 1 file changed, 290 insertions(+) create mode 100644 xmodule/assets/video/public/js/html5_video.js diff --git a/xmodule/assets/video/public/js/html5_video.js b/xmodule/assets/video/public/js/html5_video.js new file mode 100644 index 000000000000..75f16181305d --- /dev/null +++ b/xmodule/assets/video/public/js/html5_video.js @@ -0,0 +1,290 @@ +/* eslint-disable no-console, no-param-reassign */ +'use strict'; + +import _ from 'underscore'; + +// HTML5Video object that will be exported +const HTML5Video = {}; + +// Constants mimicking the YouTube API +HTML5Video.PlayerState = { + UNSTARTED: -1, + ENDED: 0, + PLAYING: 1, + PAUSED: 2, + BUFFERING: 3, + CUED: 5 +}; + +// HTML5Video.Player constructor function +class Player { + constructor(el, config) { + this.init(el, config); + + const sourceList = $.map(config.videoSources, source => { + const separator = source.indexOf('?') === -1 ? '?' : '&'; + return ``; + }); + + const errorMessage = [ + gettext('This browser cannot play .mp4, .ogg, or .webm files.'), + gettext('Try using a different browser, such as Google Chrome.') + ].join(''); + + this.video.innerHTML = sourceList.join('') + errorMessage; + + const lastSource = this.videoEl.find('source').last(); + lastSource.on('error', this.showErrorMessage.bind(this)); + lastSource.on('error', this.onError.bind(this)); + this.videoEl.on('error', this.onError.bind(this)); + } + + init(el, config) { + const self = this; + const isTouch = window.onTouchBasedDevice?.() || ''; + const events = [ + 'loadstart', 'progress', 'suspend', 'abort', 'error', + 'emptied', 'stalled', 'play', 'pause', 'loadedmetadata', + 'loadeddata', 'waiting', 'playing', 'canplay', 'canplaythrough', + 'seeking', 'seeked', 'timeupdate', 'ended', 'ratechange', + 'durationchange', 'volumechange' + ]; + + this.config = config; + this.logs = []; + this.el = $(el); + + this.video = document.createElement('video'); + this.videoEl = $(this.video); + this.videoOverlayEl = this.el.find('.video-wrapper .btn-play'); + this.playerState = HTML5Video.PlayerState.UNSTARTED; + + _.bindAll(this, 'onLoadedMetadata', 'onPlay', 'onPlaying', 'onPause', 'onEnded'); + + const togglePlayback = () => { + const { PLAYING, PAUSED } = HTML5Video.PlayerState; + if (self.playerState === PLAYING) { + self.playerState = PAUSED; + self.pauseVideo(); + } else { + self.playerState = PLAYING; + self.playVideo(); + } + }; + + this.videoEl.on('click', togglePlayback); + this.videoOverlayEl.on('click', togglePlayback); + + this.debug = false; + + $.each(events, (index, eventName) => { + self.video.addEventListener(eventName, function (...args) { + self.logs.push({ + 'event name': eventName, + state: self.playerState + }); + + if (self.debug) { + console.log( + 'event name:', eventName, + 'state:', self.playerState, + 'readyState:', self.video.readyState, + 'networkState:', self.video.networkState + ); + } + + self.el.trigger(`html5:${eventName}`, args); + }); + }); + + this.video.addEventListener('loadedmetadata', this.onLoadedMetadata, false); + this.video.addEventListener('play', this.onPlay, false); + this.video.addEventListener('playing', this.onPlaying, false); + this.video.addEventListener('pause', this.onPause, false); + this.video.addEventListener('ended', this.onEnded, false); + + if (/iP(hone|od)/i.test(isTouch[0])) { + this.videoEl.prop('controls', true); + } + + if (config.poster) { + this.videoEl.prop('poster', config.poster); + } + + this.videoEl.appendTo(this.el.find('.video-player > div:first-child')); + } + + showPlayButton() { + this.videoOverlayEl.removeClass('is-hidden'); + } + + hidePlayButton() { + this.videoOverlayEl.addClass('is-hidden'); + } + + showLoading() { + this.el + .removeClass('is-initialized') + .find('.spinner') + .removeAttr('tabindex') + .attr({ 'aria-hidden': 'false' }); + } + + hideLoading() { + this.el + .addClass('is-initialized') + .find('.spinner') + .attr({ 'aria-hidden': 'false', tabindex: -1 }); + } + + updatePlayerLoadingState(state) { + if (state === 'show') { + this.hidePlayButton(); + this.showLoading(); + } else if (state === 'hide') { + this.hideLoading(); + } + } + + callStateChangeCallback() { + const callback = this.config.events.onStateChange; + if ($.isFunction(callback)) { + callback({ data: this.playerState }); + } + } + + pauseVideo() { + this.video.pause(); + } + + seekTo(value) { + if (typeof value === 'number' && value <= this.video.duration && value >= 0) { + this.video.currentTime = value; + } + } + + setVolume(value) { + if (typeof value === 'number' && value <= 100 && value >= 0) { + this.video.volume = value * 0.01; + } + } + + getCurrentTime() { + return this.video.currentTime; + } + + playVideo() { + this.video.play(); + } + + getPlayerState() { + return this.playerState; + } + + getVolume() { + return this.video.volume; + } + + getDuration() { + return isNaN(this.video.duration) ? 0 : this.video.duration; + } + + setPlaybackRate(value) { + const newSpeed = parseFloat(value); + if (isFinite(newSpeed) && this.video.playbackRate !== value) { + this.video.playbackRate = value; + } + } + + getAvailablePlaybackRates() { + return [0.75, 1.0, 1.25, 1.5, 2.0]; + } + + _getLogs() { + return this.logs; + } + + showErrorMessage(_, cssSelector = '.video-player .video-error') { + this.el + .find('.video-player div') + .addClass('hidden') + .end() + .find(cssSelector) + .removeClass('is-hidden') + .end() + .addClass('is-initialized') + .find('.spinner') + .attr({ 'aria-hidden': 'true', tabindex: -1 }); + } + + onError() { + const callback = this.config.events.onError; + if ($.isFunction(callback)) { + callback(); + } + } + + destroy() { + this.video.removeEventListener('loadedmetadata', this.onLoadedMetadata, false); + this.video.removeEventListener('play', this.onPlay, false); + this.video.removeEventListener('playing', this.onPlaying, false); + this.video.removeEventListener('pause', this.onPause, false); + this.video.removeEventListener('ended', this.onEnded, false); + + this.el + .find('.video-player div') + .removeClass('is-hidden') + .end() + .find('.video-player .video-error') + .addClass('is-hidden') + .end() + .removeClass('is-initialized') + .find('.spinner') + .attr({ 'aria-hidden': 'false' }); + + this.videoEl.off('remove'); + this.videoEl.remove(); + } + + onReady() { + if ($.isFunction(this.config.events.onReady)) { + this.config.events.onReady(null); + } + this.showPlayButton(); + } + + onLoadedMetadata() { + this.playerState = HTML5Video.PlayerState.PAUSED; + if ($.isFunction(this.config.events.onReady)) { + this.onReady(); + } + } + + onPlay() { + this.playerState = HTML5Video.PlayerState.BUFFERING; + this.callStateChangeCallback(); + this.videoOverlayEl.addClass('is-hidden'); + } + + onPlaying() { + this.playerState = HTML5Video.PlayerState.PLAYING; + this.callStateChangeCallback(); + this.videoOverlayEl.addClass('is-hidden'); + } + + onPause() { + this.playerState = HTML5Video.PlayerState.PAUSED; + this.callStateChangeCallback(); + this.showPlayButton(); + } + + onEnded() { + this.playerState = HTML5Video.PlayerState.ENDED; + this.callStateChangeCallback(); + } +} + +// Attach to exported object +HTML5Video.Player = Player; + +export { HTML5Video }; \ No newline at end of file From f2a09014218e505b68a3e47db840d50b31a86d4a Mon Sep 17 00:00:00 2001 From: ahmad-arbisoft Date: Mon, 21 Apr 2025 13:39:59 +0500 Subject: [PATCH 05/21] vanila js conversion :: video_player.js --- .../assets/video/public/js/video_player.js | 911 ++++++++++++++++++ 1 file changed, 911 insertions(+) create mode 100644 xmodule/assets/video/public/js/video_player.js diff --git a/xmodule/assets/video/public/js/video_player.js b/xmodule/assets/video/public/js/video_player.js new file mode 100644 index 000000000000..24c8f1510ea6 --- /dev/null +++ b/xmodule/assets/video/public/js/video_player.js @@ -0,0 +1,911 @@ +'use strict'; +import _ from "underscore"; +import $ from "jquery" + +function VideoPlayer(HTML5Video, HTML5HLSVideo, Resizer, HLS, _, Time) { + const dfd = $.Deferred(), + VideoPlayer = function (state) { + state.videoPlayer = {}; + _makeFunctionsPublic(state); + _initialize(state); + // No callbacks to DOM events (click, mousemove, etc.). + + return dfd.promise(); + }, + /* eslint-disable no-use-before-define */ + methodsDict = { + destroy: destroy, + duration: duration, + handlePlaybackQualityChange: handlePlaybackQualityChange, + + // Added for finer graded seeking control. + // Please see: + // https://developers.google.com/youtube/js_api_reference#Events + isBuffering: isBuffering, + // https://developers.google.com/youtube/js_api_reference#cueVideoById + isCued: isCued, + + isEnded: isEnded, + isPlaying: isPlaying, + isUnstarted: isUnstarted, + onCaptionSeek: onSeek, + onEnded: onEnded, + onError: onError, + onPause: onPause, + onPlay: onPlay, + runTimer: runTimer, + stopTimer: stopTimer, + onLoadMetadataHtml5: onLoadMetadataHtml5, + onPlaybackQualityChange: onPlaybackQualityChange, + onReady: onReady, + onSlideSeek: onSeek, + onSpeedChange: onSpeedChange, + onAutoAdvanceChange: onAutoAdvanceChange, + onStateChange: onStateChange, + onUnstarted: onUnstarted, + onVolumeChange: onVolumeChange, + pause: pause, + play: play, + seekTo: seekTo, + setPlaybackRate: setPlaybackRate, + update: update, + figureOutStartEndTime: figureOutStartEndTime, + figureOutStartingTime: figureOutStartingTime, + updatePlayTime: updatePlayTime + }; + /* eslint-enable no-use-before-define */ + + VideoPlayer.prototype = methodsDict; + + // VideoPlayer() function - what this module "exports". + return VideoPlayer; + + // *************************************************************** + // Private functions start here. + // *************************************************************** + + // function _makeFunctionsPublic(state) + // + // Functions which will be accessible via 'state' object. When called, + // these functions will get the 'state' object as a context. + function _makeFunctionsPublic(state) { + const debouncedF = _.debounce( + function (params) { + // Can't cancel a queued debounced function on destroy + if (state.videoPlayer) { + return onSeek.call(this, params); + } + }.bind(state), + 300 + ); + + state.bindTo(methodsDict, state.videoPlayer, state); + + state.videoPlayer.onSlideSeek = debouncedF; + state.videoPlayer.onCaptionSeek = debouncedF; + } + + // Updates players state, once metadata is loaded for html5 player. + function onLoadMetadataHtml5() { + const player = this.videoPlayer.player.videoEl, + videoWidth = player[0].videoWidth || player.width(), + videoHeight = player[0].videoHeight || player.height(); + + _resize(this, videoWidth, videoHeight); + _updateVcrAndRegion(this); + } + + // function _initialize(state) + // + // Create any necessary DOM elements, attach them, and set their + // initial configuration. Also make the created DOM elements available + // via the 'state' object. Much easier to work this way - you don't + // have to do repeated jQuery element selects. + // eslint-disable-next-line no-underscore-dangle + function _initialize(state) { + let youTubeId, + player, + userAgent, + commonPlayerConfig, + eventToBeTriggered = 'loadedmetadata'; + + // The function is called just once to apply pre-defined configurations + // by student before video starts playing. Waits until the video's + // metadata is loaded, which normally happens just after the video + // starts playing. Just after that configurations can be applied. + state.videoPlayer.ready = _.once(function () { + if (!state.isFlashMode() && state.speed != '1.0') { + state.videoPlayer.setPlaybackRate(state.speed); + } + }); + + if (state.isYoutubeType()) { + state.videoPlayer.PlayerState = YT.PlayerState; + state.videoPlayer.PlayerState.UNSTARTED = -1; + } else { + state.videoPlayer.PlayerState = HTML5Video.PlayerState; + } + + state.videoPlayer.currentTime = 0; + + state.videoPlayer.goToStartTime = true; + state.videoPlayer.stopAtEndTime = true; + + state.videoPlayer.playerVars = { + controls: 0, + wmode: 'transparent', + rel: 0, + showinfo: 0, + enablejsapi: 1, + modestbranding: 1, + cc_load_policy: 0 + }; + + if (!state.isFlashMode()) { + state.videoPlayer.playerVars.html5 = 1; + } + + // Detect the current browser for several browser-specific work-arounds. + userAgent = navigator.userAgent.toLowerCase(); + state.browserIsFirefox = userAgent.indexOf('firefox') > -1; + state.browserIsChrome = userAgent.indexOf('chrome') > -1; + // Chrome includes both "Chrome" and "Safari" in the user agent. + state.browserIsSafari = (userAgent.indexOf('safari') > -1 + && !state.browserIsChrome); + + // Browser can play HLS videos if either `Media Source Extensions` + // feature is supported or browser is safari (native HLS support) + state.canPlayHLS = state.HLSVideoSources.length > 0 && (HLS.isSupported() || state.browserIsSafari); + state.HLSOnlySources = state.config.sources.length > 0 + && state.config.sources.length === state.HLSVideoSources.length; + + commonPlayerConfig = { + playerVars: state.videoPlayer.playerVars, + videoSources: state.config.sources, + poster: state.config.poster, + browserIsSafari: state.browserIsSafari, + events: { + onReady: state.videoPlayer.onReady, + onStateChange: state.videoPlayer.onStateChange, + onError: state.videoPlayer.onError + } + }; + + if (state.videoType === 'html5') { + if (state.canPlayHLS || state.HLSOnlySources) { + state.videoPlayer.player = new HTML5HLSVideo.Player( + state.el, + _.extend({}, commonPlayerConfig, { + state: state, + onReadyHLS: function () { dfd.resolve(); }, + videoSources: state.HLSVideoSources, + canPlayHLS: state.canPlayHLS, + HLSOnlySources: state.HLSOnlySources + }) + ); + // `loadedmetadata` event triggered too early on Safari due + // to which correct video dimensions were not calculated + eventToBeTriggered = state.browserIsSafari ? 'loadeddata' : eventToBeTriggered; + } else { + state.videoPlayer.player = new HTML5Video.Player(state.el, commonPlayerConfig); + } + // eslint-disable-next-line no-multi-assign + player = state.videoEl = state.videoPlayer.player.videoEl; + player[0].addEventListener(eventToBeTriggered, state.videoPlayer.onLoadMetadataHtml5, false); + player.on('remove', state.videoPlayer.destroy); + } else { + youTubeId = state.youtubeId(); + + state.videoPlayer.player = new YT.Player(state.id, { + playerVars: state.videoPlayer.playerVars, + videoId: youTubeId, + events: { + onReady: state.videoPlayer.onReady, + onStateChange: state.videoPlayer.onStateChange, + onPlaybackQualityChange: state.videoPlayer.onPlaybackQualityChange, + onError: state.videoPlayer.onError + } + }); + + state.el.on('initialize', function () { + // eslint-disable-next-line no-shadow, no-multi-assign + const player = state.videoEl = state.el.find('iframe'), + videoWidth = player.attr('width') || player.width(), + videoHeight = player.attr('height') || player.height(); + + player.on('remove', state.videoPlayer.destroy); + + _resize(state, videoWidth, videoHeight); + _updateVcrAndRegion(state, true); + }); + } + + if (state.isTouch) { + dfd.resolve(); + } + } + + function _updateVcrAndRegion(state, isYoutube) { + // eslint-disable-next-line no-shadow + let update = function (state) { + // eslint-disable-next-line no-shadow + let duration = state.videoPlayer.duration(), + time; + + time = state.videoPlayer.figureOutStartingTime(duration); + + // Update the VCR. + state.trigger( + 'videoControl.updateVcrVidTime', + { + time: time, + duration: duration + } + ); + + // Update the time slider. + state.trigger( + 'videoProgressSlider.updateStartEndTimeRegion', + { + duration: duration + } + ); + state.trigger( + 'videoProgressSlider.updatePlayTime', + { + time: time, + duration: duration + } + ); + }; + + // After initialization, update the VCR with total time. + // At this point only the metadata duration is available (not + // very precise), but it is better than having 00:00:00 for + // total time. + if (state.youtubeMetadataReceived || !isYoutube) { + // Metadata was already received, and is available. + update(state); + } else { + // We wait for metadata to arrive, before we request the update + // of the VCR video time, and of the start-end time region. + // Metadata contains duration of the video. + state.el.on('metadata_received', function () { + update(state); + }); + } + } + + function _resize(state, videoWidth, videoHeight) { + state.resizer = new Resizer({ + element: state.videoEl, + elementRatio: videoWidth / videoHeight, + container: state.container + }) + .callbacks.once(function () { + state.el.trigger('caption:resize'); + }) + .setMode('width'); + + // Update captions size when controls becomes visible on iPad or Android + if (/iPad|Android/i.test(state.isTouch[0])) { + state.el.on('controls:show', function () { + state.el.trigger('caption:resize'); + }); + } + + $(window).on('resize.video', _.debounce(function () { + state.trigger('videoFullScreen.updateControlsHeight', null); + state.el.trigger('caption:resize'); + state.resizer.align(); + }, 100)); + } + + // function _restartUsingFlash(state) + // + // When we are about to play a YouTube video in HTML5 mode and discover + // that we only have one available playback rate, we will switch to + // Flash mode. In Flash speed switching is done by reloading videos + // recorded at different frame rates. + function _restartUsingFlash(state) { + // Remove from the page current iFrame with HTML5 video. + state.videoPlayer.player.destroy(); + + state.setPlayerMode('flash'); + + console.log('[Video info]: Changing YouTube player mode to "flash".'); + + // Removed configuration option that requests the HTML5 mode. + delete state.videoPlayer.playerVars.html5; + + // Request for the creation of a new Flash player + state.videoPlayer.player = new YT.Player(state.id, { + playerVars: state.videoPlayer.playerVars, + videoId: state.youtubeId(), + events: { + onReady: state.videoPlayer.onReady, + onStateChange: state.videoPlayer.onStateChange, + onPlaybackQualityChange: state.videoPlayer.onPlaybackQualityChange, + onError: state.videoPlayer.onError + } + }); + + _updateVcrAndRegion(state, true); + state.el.trigger('caption:fetch'); + state.resizer.setElement(state.el.find('iframe')).align(); + } + + // *************************************************************** + // Public functions start here. + // These are available via the 'state' object. Their context ('this' + // keyword) is the 'state' object. The magic private function that makes + // them available and sets up their context is makeFunctionsPublic(). + // *************************************************************** + + function destroy() { + const player = this.videoPlayer.player; + this.el.removeClass([ + 'is-unstarted', 'is-playing', 'is-paused', 'is-buffered', + 'is-ended', 'is-cued' + ].join(' ')); + $(window).off('.video'); + this.el.trigger('destroy'); + this.el.off(); + this.videoPlayer.stopTimer(); + if (this.resizer && this.resizer.destroy) { + this.resizer.destroy(); + } + if (player && player.video) { + player.video.removeEventListener('loadedmetadata', this.videoPlayer.onLoadMetadataHtml5, false); + } + if (player && _.isFunction(player.destroy)) { + player.destroy(); + } + if (this.canPlayHLS && player.hls) { + player.hls.destroy(); + } + delete this.videoPlayer; + } + + function pause() { + if (this.videoPlayer.player.pauseVideo) { + this.videoPlayer.player.pauseVideo(); + } + } + + function play() { + if (this.videoPlayer.player.playVideo) { + if (this.videoPlayer.isEnded()) { + // When the video will start playing again from the start, the + // start-time and end-time will come back into effect. + this.videoPlayer.goToStartTime = true; + } + + this.videoPlayer.player.playVideo(); + } + } + + // This function gets the video's current play position in time + // (currentTime) and its duration. + // It is called at a regular interval when the video is playing. + function update(time) { + this.videoPlayer.currentTime = time || this.videoPlayer.player.getCurrentTime(); + + if (isFinite(this.videoPlayer.currentTime)) { + this.videoPlayer.updatePlayTime(this.videoPlayer.currentTime); + + // We need to pause the video if current time is smaller (or equal) + // than end-time. Also, we must make sure that this is only done + // once per video playing from start to end. + if ( + this.videoPlayer.endTime !== null + && this.videoPlayer.endTime <= this.videoPlayer.currentTime + ) { + this.videoPlayer.pause(); + + this.trigger('videoProgressSlider.notifyThroughHandleEnd', { + end: true + }); + + this.el.trigger('stop'); + } + this.el.trigger('timeupdate', [this.videoPlayer.currentTime]); + } + } + + function setPlaybackRate(newSpeed) { + this.videoPlayer.player.setPlaybackRate(newSpeed); + } + + function onSpeedChange(newSpeed) { + const time = this.videoPlayer.currentTime; + + if (this.isFlashMode()) { + this.videoPlayer.currentTime = Time.convert( + time, + parseFloat(this.speed), + newSpeed + ); + } + + newSpeed = parseFloat(newSpeed); + this.setSpeed(newSpeed); + this.videoPlayer.setPlaybackRate(newSpeed); + } + + function onAutoAdvanceChange(enabled) { + this.setAutoAdvance(enabled); + } + + // Every 200 ms, if the video is playing, we call the function update, via + // clearInterval. This interval is called updateInterval. + // It is created on a onPlay event. Cleared on a onPause event. + // Reinitialized on a onSeek event. + function onSeek(params) { + const time = params.time, + type = params.type, + oldTime = this.videoPlayer.currentTime; + // After the user seeks, the video will start playing from + // the sought point, and stop playing at the end. + this.videoPlayer.goToStartTime = false; + + this.videoPlayer.seekTo(time); + + this.el.trigger('seek', [time, oldTime, type]); + } + + function seekTo(time) { + // eslint-disable-next-line no-shadow + const duration = this.videoPlayer.duration(); + + if ((typeof time !== 'number') || (time > duration) || (time < 0)) { + return false; + } + + this.el.off('play.seek'); + + if (this.videoPlayer.isPlaying()) { + this.videoPlayer.stopTimer(); + } + const isUnplayed = this.videoPlayer.isUnstarted() + || this.videoPlayer.isCued(); + + // Use `cueVideoById` method for youtube video that is not played before. + if (isUnplayed && this.isYoutubeType()) { + this.videoPlayer.player.cueVideoById(this.youtubeId(), time); + } else { + // Youtube video cannot be rewinded during bufferization, so wait to + // finish bufferization and then rewind the video. + if (this.isYoutubeType() && this.videoPlayer.isBuffering()) { + this.el.on('play.seek', function () { + this.videoPlayer.player.seekTo(time, true); + }.bind(this)); + } else { + // Otherwise, just seek the video + this.videoPlayer.player.seekTo(time, true); + } + } + + this.videoPlayer.updatePlayTime(time, true); + + // the timer is stopped above; restart it. + if (this.videoPlayer.isPlaying()) { + this.videoPlayer.runTimer(); + } + // Update the the current time when user seek. (YoutubePlayer) + this.videoPlayer.currentTime = time; + } + + function runTimer() { + if (!this.videoPlayer.updateInterval) { + this.videoPlayer.updateInterval = window.setInterval( + this.videoPlayer.update, 200 + ); + + this.videoPlayer.update(); + } + } + + function stopTimer() { + window.clearInterval(this.videoPlayer.updateInterval); + delete this.videoPlayer.updateInterval; + } + + function onEnded() { + const time = this.videoPlayer.duration(); + + this.trigger('videoProgressSlider.notifyThroughHandleEnd', { + end: true + }); + + if (this.videoPlayer.skipOnEndedStartEndReset) { + this.videoPlayer.skipOnEndedStartEndReset = undefined; + } + // Sometimes `onEnded` events fires when `currentTime` not equal + // `duration`. In this case, slider doesn't reach the end point of + // timeline. + this.videoPlayer.updatePlayTime(time); + + // Emit 'pause_video' event when a video ends if Player is of Youtube + if (this.isYoutubeType()) { + this.el.trigger('pause', arguments); + } + this.el.trigger('ended', arguments); + } + + function onPause() { + this.videoPlayer.stopTimer(); + this.el.trigger('pause', arguments); + } + + function onPlay() { + this.videoPlayer.runTimer(); + this.trigger('videoProgressSlider.notifyThroughHandleEnd', { + end: false + }); + this.videoPlayer.ready(); + this.el.trigger('play', arguments); + } + + function onUnstarted() { } + + function handlePlaybackQualityChange(value) { + this.videoPlayer.player.setPlaybackQuality(value); + } + + function onPlaybackQualityChange() { + let quality; + + quality = this.videoPlayer.player.getPlaybackQuality(); + + this.trigger('videoQualityControl.onQualityChange', quality); + this.el.trigger('qualitychange', arguments); + } + + function onReady() { + let _this = this, + availablePlaybackRates, baseSpeedSubs, + player, videoWidth, videoHeight; + + dfd.resolve(); + + this.el.on('speedchange', function (event, speed) { + _this.videoPlayer.onSpeedChange(speed); + }); + + this.el.on('autoadvancechange', function (event, enabled) { + _this.videoPlayer.onAutoAdvanceChange(enabled); + }); + + this.el.on('volumechange volumechange:silent', function (event, volume) { + _this.videoPlayer.onVolumeChange(volume); + }); + + availablePlaybackRates = this.videoPlayer.player + .getAvailablePlaybackRates(); + + // Because of problems with muting sound outside of range 0.25 and + // 5.0, we should filter our available playback rates. + // Issues: + // https://code.google.com/p/chromium/issues/detail?id=264341 + // https://bugzilla.mozilla.org/show_bug.cgi?id=840745 + // https://developer.mozilla.org/en-US/docs/DOM/HTMLMediaElement + + availablePlaybackRates = _.filter( + availablePlaybackRates, + function (item) { + const speed = Number(item); + return speed > 0.25 && speed <= 5; + } + ); + + // Because of a recent change in the YouTube API (not documented), sometimes + // HTML5 mode loads after Flash mode has been loaded. In this case we have + // multiple speeds available but the variable `this.currentPlayerMode` is + // set to "flash". This is impossible because in Flash mode we can have + // only one speed available. Therefore we must execute the following code + // block if we have multiple speeds or if `this.currentPlayerMode` is set to + // "html5". If any of the two conditions are true, we then set the variable + // `this.currentPlayerMode` to "html5". + // + // For more information, please see the PR that introduced this change: + // https://github.com/openedx/edx-platform/pull/2841 + if ( + (this.isHtml5Mode() || availablePlaybackRates.length > 1) + && this.isYoutubeType() + ) { + if (availablePlaybackRates.length === 1 && !this.isTouch) { + // This condition is needed in cases when Firefox version is + // less than 20. In those versions HTML5 playback could only + // happen at 1 speed (no speed changing). Therefore, in this + // case, we need to switch back to Flash. + // + // This might also happen in other browsers, therefore when we + // have 1 speed available, we fall back to Flash. + + _restartUsingFlash(this); + return false; + } else if (availablePlaybackRates.length > 1) { + this.setPlayerMode('html5'); + + // We need to synchronize available frame rates with the ones + // that the user specified. + + baseSpeedSubs = this.videos['1.0']; + // this.videos is a dictionary containing various frame rates + // and their associated subs. + + // First clear the dictionary. + $.each(this.videos, function (index, value) { + delete _this.videos[index]; + }); + this.speeds = []; + // Recreate it with the supplied frame rates. + $.each(availablePlaybackRates, function (index, value) { + const key = value.toFixed(2).replace(/\.00$/, '.0'); + + _this.videos[key] = baseSpeedSubs; + _this.speeds.push(key); + }); + + this.setSpeed(this.speed); + this.el.trigger('speed:render', [this.speeds, this.speed]); + } + } + + if (this.isFlashMode()) { + this.setSpeed(this.speed); + this.el.trigger('speed:set', [this.speed]); + } + + if (this.isHtml5Mode()) { + this.videoPlayer.player.setPlaybackRate(this.speed); + } + + // eslint-disable-next-line no-shadow + const duration = this.videoPlayer.duration(), + time = this.videoPlayer.figureOutStartingTime(duration); + + // this.duration will be set initially only if duration is coming from edx-val + this.duration = this.duration || duration; + + if (time > 0 && this.videoPlayer.goToStartTime) { + this.videoPlayer.seekTo(time); + } + + this.el.trigger('ready', arguments); + + if (this.config.autoplay) { + this.videoPlayer.play(); + } + } + + function onStateChange(event) { + this.el.removeClass([ + 'is-unstarted', 'is-playing', 'is-paused', 'is-buffered', + 'is-ended', 'is-cued' + ].join(' ')); + + // eslint-disable-next-line default-case + switch (event.data) { + case this.videoPlayer.PlayerState.UNSTARTED: + this.el.addClass('is-unstarted'); + this.videoPlayer.onUnstarted(); + break; + case this.videoPlayer.PlayerState.PLAYING: + this.el.addClass('is-playing'); + this.videoPlayer.onPlay(); + break; + case this.videoPlayer.PlayerState.PAUSED: + this.el.addClass('is-paused'); + this.videoPlayer.onPause(); + break; + case this.videoPlayer.PlayerState.BUFFERING: + this.el.addClass('is-buffered'); + this.el.trigger('buffering'); + break; + case this.videoPlayer.PlayerState.ENDED: + this.el.addClass('is-ended'); + this.videoPlayer.onEnded(); + break; + case this.videoPlayer.PlayerState.CUED: + this.el.addClass('is-cued'); + if (this.isFlashMode()) { + this.videoPlayer.play(); + } + break; + } + } + + function onError(code) { + this.el.trigger('error', [code]); + } + + // eslint-disable-next-line no-shadow + function figureOutStartEndTime(duration) { + const videoPlayer = this.videoPlayer; + + videoPlayer.startTime = this.config.startTime; + if (videoPlayer.startTime >= duration) { + videoPlayer.startTime = 0; + } else if (this.isFlashMode()) { + videoPlayer.startTime /= Number(this.speed); + } + + videoPlayer.endTime = this.config.endTime; + if ( + videoPlayer.endTime <= videoPlayer.startTime + || videoPlayer.endTime >= duration + ) { + videoPlayer.endTime = null; + } else if (this.isFlashMode()) { + videoPlayer.endTime /= Number(this.speed); + } + } + + // eslint-disable-next-line no-shadow + function figureOutStartingTime(duration) { + let savedVideoPosition = this.config.savedVideoPosition, + + // Default starting time is 0. This is the case when + // there is not start-time, no previously saved position, + // or one (or both) of those values is incorrect. + time = 0, + + startTime, endTime; + + this.videoPlayer.figureOutStartEndTime(duration); + + startTime = this.videoPlayer.startTime; + endTime = this.videoPlayer.endTime; + + if (startTime > 0) { + if ( + startTime < savedVideoPosition + && (endTime > savedVideoPosition || endTime === null) + + // We do not want to jump to the end of the video. + // We subtract 1 from the duration for a 1 second + // safety net. + && savedVideoPosition < duration - 1 + ) { + time = savedVideoPosition; + } else { + time = startTime; + } + } else if ( + savedVideoPosition > 0 + && (endTime > savedVideoPosition || endTime === null) + + // We do not want to jump to the end of the video. + // We subtract 1 from the duration for a 1 second + // safety net. + && savedVideoPosition < duration - 1 + ) { + time = savedVideoPosition; + } + + return time; + } + + function updatePlayTime(time, skip_seek) { + let videoPlayer = this.videoPlayer, + endTime = this.videoPlayer.duration(), + youTubeId; + + if (this.config.endTime) { + endTime = Math.min(this.config.endTime, endTime); + } + + this.trigger( + 'videoProgressSlider.updatePlayTime', + { + time: time, + duration: endTime + } + ); + + this.trigger( + 'videoControl.updateVcrVidTime', + { + time: time, + duration: endTime + } + ); + + this.el.trigger('caption:update', [time]); + } + + function isEnded() { + let playerState = this.videoPlayer.player.getPlayerState(), + ENDED = this.videoPlayer.PlayerState.ENDED; + + return playerState === ENDED; + } + + function isPlaying() { + let playerState = this.videoPlayer.player.getPlayerState(); + + return playerState === this.videoPlayer.PlayerState.PLAYING; + } + + function isBuffering() { + let playerState = this.videoPlayer.player.getPlayerState(); + + return playerState === this.videoPlayer.PlayerState.BUFFERING; + } + + function isCued() { + let playerState = this.videoPlayer.player.getPlayerState(); + + return playerState === this.videoPlayer.PlayerState.CUED; + } + + function isUnstarted() { + let playerState = this.videoPlayer.player.getPlayerState(); + + return playerState === this.videoPlayer.PlayerState.UNSTARTED; + } + + /* +* Return the duration of the video in seconds. +* +* First, try to use the native player API call to get the duration. +* If the value returned by the native function is not valid, resort to +* the value stored in the metadata for the video. Note that the metadata +* is available only for YouTube videos. +* +* IMPORTANT! It has been observed that sometimes, after initial playback +* of the video, when operations "pause" and "play" are performed (in that +* sequence), the function will start returning a slightly different value. +* +* For example: While playing for the first time, the function returns 31. +* After pausing the video and then resuming once more, the function will +* start returning 31.950656. +* +* This instability is internal to the player API (or browser internals). +*/ + function duration() { + let dur; + + // Sometimes the YouTube API doesn't finish instantiating all of it's + // methods, but the execution point arrives here. + // + // This happens when you have start-time and end-time set, and click "Edit" + // in Studio, and then "Save". The Video editor dialog closes, the + // video reloads, but the start-end range is not visible. + if (this.videoPlayer.player.getDuration) { + dur = this.videoPlayer.player.getDuration(); + } + + // For YouTube videos, before the video starts playing, the API + // function player.getDuration() will return 0. This means that the VCR + // will show total time as 0 when the page just loads (before the user + // clicks the Play button). + // + // We can do betterin a case when dur is 0 (or less than 0). We can ask + // the getDuration() function for total time, which will query the + // metadata for a duration. + // + // Be careful! Often the metadata duration is not very precise. It + // might differ by one or two seconds against the actual time as will + // be reported later on by the player.getDuration() API function. + if (!isFinite(dur) || dur <= 0) { + if (this.isYoutubeType()) { + dur = this.getDuration(); + } + } + + // Just in case the metadata is garbled, or something went wrong, we + // have a final check. + if (!isFinite(dur) || dur <= 0) { + dur = 0; + } + + return Math.floor(dur); + } + + function onVolumeChange(volume) { + this.videoPlayer.player.setVolume(volume); + } +}; \ No newline at end of file From 8512d08dd80e3ca8bd50029758ca97518ff581e0 Mon Sep 17 00:00:00 2001 From: ahmad-arbisoft Date: Mon, 21 Apr 2025 13:46:29 +0500 Subject: [PATCH 06/21] vanila js conversion :: video_full_screen.js --- .../video/public/js/video_full_screen.js | 251 ++++++++++++++++++ .../video/public/js/video_progress_slider.js | 236 ++++++++++++++++ 2 files changed, 487 insertions(+) create mode 100644 xmodule/assets/video/public/js/video_full_screen.js create mode 100644 xmodule/assets/video/public/js/video_progress_slider.js diff --git a/xmodule/assets/video/public/js/video_full_screen.js b/xmodule/assets/video/public/js/video_full_screen.js new file mode 100644 index 000000000000..fdf081f496a2 --- /dev/null +++ b/xmodule/assets/video/public/js/video_full_screen.js @@ -0,0 +1,251 @@ +'use strict'; + +import $ from 'jquery'; +import HtmlUtils from 'edx-ui-toolkit/js/utils/html-utils'; +import { gettext } from '@edx/frontend-platform/i18n'; + +const fullscreenTemplate = ` + +`; + +const prefixedFullscreenProperties = (() => { + if ('fullscreenEnabled' in document) { + return { + fullscreenElement: 'fullscreenElement', + fullscreenEnabled: 'fullscreenEnabled', + requestFullscreen: 'requestFullscreen', + exitFullscreen: 'exitFullscreen', + fullscreenchange: 'fullscreenchange', + fullscreenerror: 'fullscreenerror' + }; + } + if ('webkitFullscreenEnabled' in document) { + return { + fullscreenElement: 'webkitFullscreenElement', + fullscreenEnabled: 'webkitFullscreenEnabled', + requestFullscreen: 'webkitRequestFullscreen', + exitFullscreen: 'webkitExitFullscreen', + fullscreenchange: 'webkitfullscreenchange', + fullscreenerror: 'webkitfullscreenerror' + }; + } + if ('mozFullScreenEnabled' in document) { + return { + fullscreenElement: 'mozFullScreenElement', + fullscreenEnabled: 'mozFullScreenEnabled', + requestFullscreen: 'mozRequestFullScreen', + exitFullscreen: 'mozCancelFullScreen', + fullscreenchange: 'mozfullscreenchange', + fullscreenerror: 'mozfullscreenerror' + }; + } + if ('msFullscreenEnabled' in document) { + return { + fullscreenElement: 'msFullscreenElement', + fullscreenEnabled: 'msFullscreenEnabled', + requestFullscreen: 'msRequestFullscreen', + exitFullscreen: 'msExitFullscreen', + fullscreenchange: 'MSFullscreenChange', + fullscreenerror: 'MSFullscreenError' + }; + } + return {}; +})(); + +function getVendorPrefixed(property) { + return prefixedFullscreenProperties[property]; +} + +function getFullscreenElement() { + return document[getVendorPrefixed('fullscreenElement')]; +} + +function exitFullscreen() { + return document[getVendorPrefixed('exitFullscreen')]?.() || null; +} + +function requestFullscreen(element, options) { + return element[getVendorPrefixed('requestFullscreen')]?.(options) || null; +} + +function renderElements(state) { + const { videoFullScreen } = state; + videoFullScreen.fullScreenEl = $(fullscreenTemplate); + videoFullScreen.sliderEl = state.el.find('.slider'); + videoFullScreen.fullScreenState = false; + HtmlUtils.append(state.el.find('.secondary-controls'), HtmlUtils.HTML(videoFullScreen.fullScreenEl)); + videoFullScreen.updateControlsHeight(); +} + +function bindHandlers(state) { + const { videoFullScreen } = state; + + videoFullScreen.fullScreenEl.on('click', videoFullScreen.toggleHandler); + state.el.on({ destroy: videoFullScreen.destroy }); + $(document).on('keyup', videoFullScreen.exitHandler); + document.addEventListener(getVendorPrefixed('fullscreenchange'), videoFullScreen.handleFullscreenChange); +} + +function getControlsHeight(controls, slider) { + return controls.height() + 0.5 * slider.height(); +} + +function destroy() { + $(document).off('keyup', this.videoFullScreen.exitHandler); + this.videoFullScreen.fullScreenEl.remove(); + this.el.off({ destroy: this.videoFullScreen.destroy }); + + document.removeEventListener( + getVendorPrefixed('fullscreenchange'), + this.videoFullScreen.handleFullscreenChange + ); + + if (this.isFullScreen) { + this.videoFullScreen.exit(); + } + + delete this.videoFullScreen; +} + +function handleFullscreenChange() { + if (getFullscreenElement() !== this.el[0] && this.isFullScreen) { + this.videoFullScreen.handleExit(); + } +} + +function updateControlsHeight() { + const controls = this.el.find('.video-controls'); + const slider = this.videoFullScreen.sliderEl; + + this.videoFullScreen.height = getControlsHeight(controls, slider); + return this.videoFullScreen.height; +} + +function notifyParent(fullscreenOpen) { + if (window !== window.parent) { + window.parent.postMessage({ + type: 'plugin.videoFullScreen', + payload: { open: fullscreenOpen } + }, document.referrer); + } +} + +function toggleHandler(event) { + event.preventDefault(); + this.videoCommands.execute('toggleFullScreen'); +} + +function handleExit() { + if (!this.isFullScreen) return; + + const fullScreenClassNameEl = this.el.add(document.documentElement); + const closedCaptionsEl = this.el.find('.closed-captions'); + + this.isFullScreen = this.videoFullScreen.fullScreenState = false; + fullScreenClassNameEl.removeClass('video-fullscreen'); + $(window).scrollTop(this.scrollPos); + + this.videoFullScreen.fullScreenEl + .attr({ title: gettext('Fill browser'), 'aria-label': gettext('Fill browser') }) + .find('.icon') + .removeClass('fa-compress') + .addClass('fa-arrows-alt'); + + $(closedCaptionsEl).css({ top: '70%', left: '5%' }); + + if (this.resizer) { + this.resizer.delta.reset().setMode('width'); + } + + this.el.trigger('fullscreen', [this.isFullScreen]); + this.videoFullScreen.notifyParent(false); +} + +function handleEnter() { + if (this.isFullScreen) return; + + const fullScreenClassNameEl = this.el.add(document.documentElement); + const closedCaptionsEl = this.el.find('.closed-captions'); + + this.videoFullScreen.notifyParent(true); + + this.isFullScreen = this.videoFullScreen.fullScreenState = true; + fullScreenClassNameEl.addClass('video-fullscreen'); + + this.videoFullScreen.fullScreenEl + .attr({ title: gettext('Exit full browser'), 'aria-label': gettext('Exit full browser') }) + .find('.icon') + .removeClass('fa-arrows-alt') + .addClass('fa-compress'); + + $(closedCaptionsEl).css({ top: '70%', left: '5%' }); + + if (this.resizer) { + this.resizer.delta.substract(this.videoFullScreen.updateControlsHeight(), 'height').setMode('both'); + } + + this.el.trigger('fullscreen', [this.isFullScreen]); +} + +function enter() { + this.scrollPos = $(window).scrollTop(); + this.videoFullScreen.handleEnter(); + requestFullscreen(this.el[0]); +} + +function exit() { + if (getFullscreenElement() === this.el[0]) { + exitFullscreen(); + } else { + this.videoFullScreen.handleExit(); + } +} + +function toggle() { + if (this.videoFullScreen.fullScreenState) { + this.videoFullScreen.exit(); + } else { + this.videoFullScreen.enter(); + } +} + +function exitHandler(event) { + if (this.isFullScreen && event.keyCode === 27) { + event.preventDefault(); + this.videoCommands.execute('toggleFullScreen'); + } +} + +function makeFunctionsPublic(state) { + const methods = { + destroy, + enter, + exit, + exitHandler, + handleExit, + handleEnter, + handleFullscreenChange, + toggle, + toggleHandler, + updateControlsHeight, + notifyParent + }; + + state.bindTo(methods, state.videoFullScreen, state); +} + +function initializeVideoFullScreen(state) { + const dfd = $.Deferred(); + + state.videoFullScreen = {}; + makeFunctionsPublic(state); + renderElements(state); + bindHandlers(state); + + dfd.resolve(); + return dfd.promise(); +} + +export { initializeVideoFullScreen } \ No newline at end of file diff --git a/xmodule/assets/video/public/js/video_progress_slider.js b/xmodule/assets/video/public/js/video_progress_slider.js new file mode 100644 index 000000000000..a1c75f471fe2 --- /dev/null +++ b/xmodule/assets/video/public/js/video_progress_slider.js @@ -0,0 +1,236 @@ +'use strict'; +import $ from 'jquery'; +import edx from 'edx-ui-toolkit/js/utils/html-utils'; +import { gettext, ngettext, interpolate } from '@edx/frontend-platform/i18n'; + +/** + * "This is as true in everyday life as it is in battle: we are given one life + * and the decision is ours whether to wait for circumstances to make up our + * mind, or whether to act, and in acting, to live." + * — Omar N. Bradley + */ + +const sliderTemplate = ` +
+`; + +export default function initializeVideoProgressSlider(state) { + const dfd = $.Deferred(); + state.videoProgressSlider = {}; + makeFunctionsPublic(state); + renderElements(state); + dfd.resolve(); + return dfd.promise(); +} + +function makeFunctionsPublic(state) { + const methods = { + destroy, + buildSlider, + getRangeParams, + onSlide, + onStop, + updatePlayTime, + updateStartEndTimeRegion, + notifyThroughHandleEnd, + getTimeDescription, + focusSlider + }; + + state.bindTo(methods, state.videoProgressSlider, state); +} + +function renderElements(state) { + state.videoProgressSlider.el = $(sliderTemplate); + state.el.find('.video-controls').prepend(state.videoProgressSlider.el); + state.videoProgressSlider.buildSlider(); + buildHandle(state); + bindHandlers(state); +} + +function bindHandlers(state) { + state.videoProgressSlider.el.on('keypress', sliderToggle.bind(state)); + state.el.on('destroy', state.videoProgressSlider.destroy); +} + +function destroy() { + this.videoProgressSlider.el.removeAttr('tabindex').slider('destroy'); + this.el.off('destroy', this.videoProgressSlider.destroy); + delete this.videoProgressSlider; +} + +function buildHandle(state) { + const handle = state.videoProgressSlider.el.find('.ui-slider-handle'); + state.videoProgressSlider.handle = handle; + + state.videoProgressSlider.el.attr({ tabindex: -1 }); + + handle.attr({ + role: 'slider', + 'aria-disabled': false, + 'aria-valuetext': getTimeDescription(state.videoProgressSlider.slider.slider('option', 'value')), + 'aria-valuemax': state.videoPlayer.duration(), + 'aria-valuemin': '0', + 'aria-valuenow': state.videoPlayer.currentTime, + tabindex: '0', + 'aria-label': gettext('Video position. Press space to toggle playback') + }); +} + +function buildSlider() { + const sliderContents = edx.HtmlUtils.joinHtml( + edx.HtmlUtils.HTML('
') + ); + + this.videoProgressSlider.el.append(sliderContents.text); + + this.videoProgressSlider.slider = this.videoProgressSlider.el.slider({ + range: 'min', + min: this.config.startTime, + max: this.config.endTime, + slide: this.videoProgressSlider.onSlide, + stop: this.videoProgressSlider.onStop, + step: 5 + }); + + this.videoProgressSlider.sliderProgress = this.videoProgressSlider.slider.find( + '.ui-slider-range.ui-widget-header.ui-slider-range-min' + ); +} + +function updateStartEndTimeRegion(params) { + if (!params.duration) return; + + let start = this.config.startTime; + let end = this.config.endTime; + const duration = params.duration; + + if (start > duration) start = 0; + else if (this.isFlashMode()) start /= Number(this.speed); + + if (end === null || end > duration) end = duration; + else if (this.isFlashMode()) end /= Number(this.speed); + + if (start === 0 && end === duration) return; + + return getRangeParams(start, end, duration); +} + +function getRangeParams(startTime, endTime, duration) { + const step = 100 / duration; + const left = startTime * step; + const width = endTime * step - left; + + return { + left: `${left}%`, + width: `${width}%` + }; +} + +function onSlide(event, ui) { + const time = ui.value; + let endTime = this.videoPlayer.duration(); + + if (this.config.endTime) { + endTime = Math.min(this.config.endTime, endTime); + } + + this.videoProgressSlider.frozen = true; + this.videoProgressSlider.lastSeekValue = time; + + this.trigger('videoControl.updateVcrVidTime', { time, duration: endTime }); + this.trigger('videoPlayer.onSlideSeek', { type: 'onSlideSeek', time }); + + this.videoProgressSlider.handle.attr('aria-valuetext', getTimeDescription(this.videoPlayer.currentTime)); +} + +function onStop(event, ui) { + const _this = this; + this.videoProgressSlider.frozen = true; + + if (this.videoProgressSlider.lastSeekValue !== ui.value) { + this.trigger('videoPlayer.onSlideSeek', { type: 'onSlideSeek', time: ui.value }); + } + + this.videoProgressSlider.handle.attr('aria-valuetext', getTimeDescription(this.videoPlayer.currentTime)); + + setTimeout(() => { + _this.videoProgressSlider.frozen = false; + }, 200); +} + +function updatePlayTime(params) { + const time = Math.floor(params.time); + let endTime = Math.floor(params.duration); + + if (this.config.endTime !== null) { + endTime = Math.min(this.config.endTime, endTime); + } + + if (this.videoProgressSlider.slider && !this.videoProgressSlider.frozen) { + this.videoProgressSlider.slider + .slider('option', 'max', endTime) + .slider('option', 'value', time); + } + + this.videoProgressSlider.handle.attr({ + 'aria-valuemax': endTime, + 'aria-valuenow': time + }); +} + +function notifyThroughHandleEnd(params) { + const handle = this.videoProgressSlider.handle; + if (params.end) { + handle.attr('title', gettext('Video ended')).focus(); + } else { + handle.attr('title', gettext('Video position')); + } +} + +function getTimeDescription(time) { + let seconds = Math.floor(time); + let minutes = Math.floor(seconds / 60); + let hours = Math.floor(minutes / 60); + + seconds %= 60; + minutes %= 60; + + const i18n = (value, word) => { + let msg; + switch (word) { + case 'hour': + msg = ngettext('%(value)s hour', '%(value)s hours', value); + break; + case 'minute': + msg = ngettext('%(value)s minute', '%(value)s minutes', value); + break; + case 'second': + msg = ngettext('%(value)s second', '%(value)s seconds', value); + break; + } + return interpolate(msg, { value }, true); + }; + + if (hours) { + return `${i18n(hours, 'hour')} ${i18n(minutes, 'minute')} ${i18n(seconds, 'second')}`; + } else if (minutes) { + return `${i18n(minutes, 'minute')} ${i18n(seconds, 'second')}`; + } + + return i18n(seconds, 'second'); +} + +function focusSlider() { + this.videoProgressSlider.handle.attr( + 'aria-valuetext', getTimeDescription(this.videoPlayer.currentTime) + ); + this.videoProgressSlider.el.trigger('focus'); +} + +function sliderToggle(e) { + if (e.which === 32) { + e.preventDefault(); + this.videoCommands.execute('togglePlayback'); + } +} From d161d81f878c9d6a7478116e83e3d6848eda8271 Mon Sep 17 00:00:00 2001 From: ahmad-arbisoft Date: Mon, 21 Apr 2025 13:47:12 +0500 Subject: [PATCH 07/21] vanila js conversion :: remove video_progress_slider.js --- .../video/public/js/video_progress_slider.js | 236 ------------------ 1 file changed, 236 deletions(-) diff --git a/xmodule/assets/video/public/js/video_progress_slider.js b/xmodule/assets/video/public/js/video_progress_slider.js index a1c75f471fe2..e69de29bb2d1 100644 --- a/xmodule/assets/video/public/js/video_progress_slider.js +++ b/xmodule/assets/video/public/js/video_progress_slider.js @@ -1,236 +0,0 @@ -'use strict'; -import $ from 'jquery'; -import edx from 'edx-ui-toolkit/js/utils/html-utils'; -import { gettext, ngettext, interpolate } from '@edx/frontend-platform/i18n'; - -/** - * "This is as true in everyday life as it is in battle: we are given one life - * and the decision is ours whether to wait for circumstances to make up our - * mind, or whether to act, and in acting, to live." - * — Omar N. Bradley - */ - -const sliderTemplate = ` -
-`; - -export default function initializeVideoProgressSlider(state) { - const dfd = $.Deferred(); - state.videoProgressSlider = {}; - makeFunctionsPublic(state); - renderElements(state); - dfd.resolve(); - return dfd.promise(); -} - -function makeFunctionsPublic(state) { - const methods = { - destroy, - buildSlider, - getRangeParams, - onSlide, - onStop, - updatePlayTime, - updateStartEndTimeRegion, - notifyThroughHandleEnd, - getTimeDescription, - focusSlider - }; - - state.bindTo(methods, state.videoProgressSlider, state); -} - -function renderElements(state) { - state.videoProgressSlider.el = $(sliderTemplate); - state.el.find('.video-controls').prepend(state.videoProgressSlider.el); - state.videoProgressSlider.buildSlider(); - buildHandle(state); - bindHandlers(state); -} - -function bindHandlers(state) { - state.videoProgressSlider.el.on('keypress', sliderToggle.bind(state)); - state.el.on('destroy', state.videoProgressSlider.destroy); -} - -function destroy() { - this.videoProgressSlider.el.removeAttr('tabindex').slider('destroy'); - this.el.off('destroy', this.videoProgressSlider.destroy); - delete this.videoProgressSlider; -} - -function buildHandle(state) { - const handle = state.videoProgressSlider.el.find('.ui-slider-handle'); - state.videoProgressSlider.handle = handle; - - state.videoProgressSlider.el.attr({ tabindex: -1 }); - - handle.attr({ - role: 'slider', - 'aria-disabled': false, - 'aria-valuetext': getTimeDescription(state.videoProgressSlider.slider.slider('option', 'value')), - 'aria-valuemax': state.videoPlayer.duration(), - 'aria-valuemin': '0', - 'aria-valuenow': state.videoPlayer.currentTime, - tabindex: '0', - 'aria-label': gettext('Video position. Press space to toggle playback') - }); -} - -function buildSlider() { - const sliderContents = edx.HtmlUtils.joinHtml( - edx.HtmlUtils.HTML('
') - ); - - this.videoProgressSlider.el.append(sliderContents.text); - - this.videoProgressSlider.slider = this.videoProgressSlider.el.slider({ - range: 'min', - min: this.config.startTime, - max: this.config.endTime, - slide: this.videoProgressSlider.onSlide, - stop: this.videoProgressSlider.onStop, - step: 5 - }); - - this.videoProgressSlider.sliderProgress = this.videoProgressSlider.slider.find( - '.ui-slider-range.ui-widget-header.ui-slider-range-min' - ); -} - -function updateStartEndTimeRegion(params) { - if (!params.duration) return; - - let start = this.config.startTime; - let end = this.config.endTime; - const duration = params.duration; - - if (start > duration) start = 0; - else if (this.isFlashMode()) start /= Number(this.speed); - - if (end === null || end > duration) end = duration; - else if (this.isFlashMode()) end /= Number(this.speed); - - if (start === 0 && end === duration) return; - - return getRangeParams(start, end, duration); -} - -function getRangeParams(startTime, endTime, duration) { - const step = 100 / duration; - const left = startTime * step; - const width = endTime * step - left; - - return { - left: `${left}%`, - width: `${width}%` - }; -} - -function onSlide(event, ui) { - const time = ui.value; - let endTime = this.videoPlayer.duration(); - - if (this.config.endTime) { - endTime = Math.min(this.config.endTime, endTime); - } - - this.videoProgressSlider.frozen = true; - this.videoProgressSlider.lastSeekValue = time; - - this.trigger('videoControl.updateVcrVidTime', { time, duration: endTime }); - this.trigger('videoPlayer.onSlideSeek', { type: 'onSlideSeek', time }); - - this.videoProgressSlider.handle.attr('aria-valuetext', getTimeDescription(this.videoPlayer.currentTime)); -} - -function onStop(event, ui) { - const _this = this; - this.videoProgressSlider.frozen = true; - - if (this.videoProgressSlider.lastSeekValue !== ui.value) { - this.trigger('videoPlayer.onSlideSeek', { type: 'onSlideSeek', time: ui.value }); - } - - this.videoProgressSlider.handle.attr('aria-valuetext', getTimeDescription(this.videoPlayer.currentTime)); - - setTimeout(() => { - _this.videoProgressSlider.frozen = false; - }, 200); -} - -function updatePlayTime(params) { - const time = Math.floor(params.time); - let endTime = Math.floor(params.duration); - - if (this.config.endTime !== null) { - endTime = Math.min(this.config.endTime, endTime); - } - - if (this.videoProgressSlider.slider && !this.videoProgressSlider.frozen) { - this.videoProgressSlider.slider - .slider('option', 'max', endTime) - .slider('option', 'value', time); - } - - this.videoProgressSlider.handle.attr({ - 'aria-valuemax': endTime, - 'aria-valuenow': time - }); -} - -function notifyThroughHandleEnd(params) { - const handle = this.videoProgressSlider.handle; - if (params.end) { - handle.attr('title', gettext('Video ended')).focus(); - } else { - handle.attr('title', gettext('Video position')); - } -} - -function getTimeDescription(time) { - let seconds = Math.floor(time); - let minutes = Math.floor(seconds / 60); - let hours = Math.floor(minutes / 60); - - seconds %= 60; - minutes %= 60; - - const i18n = (value, word) => { - let msg; - switch (word) { - case 'hour': - msg = ngettext('%(value)s hour', '%(value)s hours', value); - break; - case 'minute': - msg = ngettext('%(value)s minute', '%(value)s minutes', value); - break; - case 'second': - msg = ngettext('%(value)s second', '%(value)s seconds', value); - break; - } - return interpolate(msg, { value }, true); - }; - - if (hours) { - return `${i18n(hours, 'hour')} ${i18n(minutes, 'minute')} ${i18n(seconds, 'second')}`; - } else if (minutes) { - return `${i18n(minutes, 'minute')} ${i18n(seconds, 'second')}`; - } - - return i18n(seconds, 'second'); -} - -function focusSlider() { - this.videoProgressSlider.handle.attr( - 'aria-valuetext', getTimeDescription(this.videoPlayer.currentTime) - ); - this.videoProgressSlider.el.trigger('focus'); -} - -function sliderToggle(e) { - if (e.which === 32) { - e.preventDefault(); - this.videoCommands.execute('togglePlayback'); - } -} From 7e823e991302ec6e199a61c3f626a316ddaf8f44 Mon Sep 17 00:00:00 2001 From: ahmad-arbisoft Date: Mon, 21 Apr 2025 13:47:27 +0500 Subject: [PATCH 08/21] vanila js conversion :: video_progress_slider.js --- .../video/public/js/video_progress_slider.js | 236 ++++++++++++++++++ 1 file changed, 236 insertions(+) diff --git a/xmodule/assets/video/public/js/video_progress_slider.js b/xmodule/assets/video/public/js/video_progress_slider.js index e69de29bb2d1..a1c75f471fe2 100644 --- a/xmodule/assets/video/public/js/video_progress_slider.js +++ b/xmodule/assets/video/public/js/video_progress_slider.js @@ -0,0 +1,236 @@ +'use strict'; +import $ from 'jquery'; +import edx from 'edx-ui-toolkit/js/utils/html-utils'; +import { gettext, ngettext, interpolate } from '@edx/frontend-platform/i18n'; + +/** + * "This is as true in everyday life as it is in battle: we are given one life + * and the decision is ours whether to wait for circumstances to make up our + * mind, or whether to act, and in acting, to live." + * — Omar N. Bradley + */ + +const sliderTemplate = ` +
+`; + +export default function initializeVideoProgressSlider(state) { + const dfd = $.Deferred(); + state.videoProgressSlider = {}; + makeFunctionsPublic(state); + renderElements(state); + dfd.resolve(); + return dfd.promise(); +} + +function makeFunctionsPublic(state) { + const methods = { + destroy, + buildSlider, + getRangeParams, + onSlide, + onStop, + updatePlayTime, + updateStartEndTimeRegion, + notifyThroughHandleEnd, + getTimeDescription, + focusSlider + }; + + state.bindTo(methods, state.videoProgressSlider, state); +} + +function renderElements(state) { + state.videoProgressSlider.el = $(sliderTemplate); + state.el.find('.video-controls').prepend(state.videoProgressSlider.el); + state.videoProgressSlider.buildSlider(); + buildHandle(state); + bindHandlers(state); +} + +function bindHandlers(state) { + state.videoProgressSlider.el.on('keypress', sliderToggle.bind(state)); + state.el.on('destroy', state.videoProgressSlider.destroy); +} + +function destroy() { + this.videoProgressSlider.el.removeAttr('tabindex').slider('destroy'); + this.el.off('destroy', this.videoProgressSlider.destroy); + delete this.videoProgressSlider; +} + +function buildHandle(state) { + const handle = state.videoProgressSlider.el.find('.ui-slider-handle'); + state.videoProgressSlider.handle = handle; + + state.videoProgressSlider.el.attr({ tabindex: -1 }); + + handle.attr({ + role: 'slider', + 'aria-disabled': false, + 'aria-valuetext': getTimeDescription(state.videoProgressSlider.slider.slider('option', 'value')), + 'aria-valuemax': state.videoPlayer.duration(), + 'aria-valuemin': '0', + 'aria-valuenow': state.videoPlayer.currentTime, + tabindex: '0', + 'aria-label': gettext('Video position. Press space to toggle playback') + }); +} + +function buildSlider() { + const sliderContents = edx.HtmlUtils.joinHtml( + edx.HtmlUtils.HTML('
') + ); + + this.videoProgressSlider.el.append(sliderContents.text); + + this.videoProgressSlider.slider = this.videoProgressSlider.el.slider({ + range: 'min', + min: this.config.startTime, + max: this.config.endTime, + slide: this.videoProgressSlider.onSlide, + stop: this.videoProgressSlider.onStop, + step: 5 + }); + + this.videoProgressSlider.sliderProgress = this.videoProgressSlider.slider.find( + '.ui-slider-range.ui-widget-header.ui-slider-range-min' + ); +} + +function updateStartEndTimeRegion(params) { + if (!params.duration) return; + + let start = this.config.startTime; + let end = this.config.endTime; + const duration = params.duration; + + if (start > duration) start = 0; + else if (this.isFlashMode()) start /= Number(this.speed); + + if (end === null || end > duration) end = duration; + else if (this.isFlashMode()) end /= Number(this.speed); + + if (start === 0 && end === duration) return; + + return getRangeParams(start, end, duration); +} + +function getRangeParams(startTime, endTime, duration) { + const step = 100 / duration; + const left = startTime * step; + const width = endTime * step - left; + + return { + left: `${left}%`, + width: `${width}%` + }; +} + +function onSlide(event, ui) { + const time = ui.value; + let endTime = this.videoPlayer.duration(); + + if (this.config.endTime) { + endTime = Math.min(this.config.endTime, endTime); + } + + this.videoProgressSlider.frozen = true; + this.videoProgressSlider.lastSeekValue = time; + + this.trigger('videoControl.updateVcrVidTime', { time, duration: endTime }); + this.trigger('videoPlayer.onSlideSeek', { type: 'onSlideSeek', time }); + + this.videoProgressSlider.handle.attr('aria-valuetext', getTimeDescription(this.videoPlayer.currentTime)); +} + +function onStop(event, ui) { + const _this = this; + this.videoProgressSlider.frozen = true; + + if (this.videoProgressSlider.lastSeekValue !== ui.value) { + this.trigger('videoPlayer.onSlideSeek', { type: 'onSlideSeek', time: ui.value }); + } + + this.videoProgressSlider.handle.attr('aria-valuetext', getTimeDescription(this.videoPlayer.currentTime)); + + setTimeout(() => { + _this.videoProgressSlider.frozen = false; + }, 200); +} + +function updatePlayTime(params) { + const time = Math.floor(params.time); + let endTime = Math.floor(params.duration); + + if (this.config.endTime !== null) { + endTime = Math.min(this.config.endTime, endTime); + } + + if (this.videoProgressSlider.slider && !this.videoProgressSlider.frozen) { + this.videoProgressSlider.slider + .slider('option', 'max', endTime) + .slider('option', 'value', time); + } + + this.videoProgressSlider.handle.attr({ + 'aria-valuemax': endTime, + 'aria-valuenow': time + }); +} + +function notifyThroughHandleEnd(params) { + const handle = this.videoProgressSlider.handle; + if (params.end) { + handle.attr('title', gettext('Video ended')).focus(); + } else { + handle.attr('title', gettext('Video position')); + } +} + +function getTimeDescription(time) { + let seconds = Math.floor(time); + let minutes = Math.floor(seconds / 60); + let hours = Math.floor(minutes / 60); + + seconds %= 60; + minutes %= 60; + + const i18n = (value, word) => { + let msg; + switch (word) { + case 'hour': + msg = ngettext('%(value)s hour', '%(value)s hours', value); + break; + case 'minute': + msg = ngettext('%(value)s minute', '%(value)s minutes', value); + break; + case 'second': + msg = ngettext('%(value)s second', '%(value)s seconds', value); + break; + } + return interpolate(msg, { value }, true); + }; + + if (hours) { + return `${i18n(hours, 'hour')} ${i18n(minutes, 'minute')} ${i18n(seconds, 'second')}`; + } else if (minutes) { + return `${i18n(minutes, 'minute')} ${i18n(seconds, 'second')}`; + } + + return i18n(seconds, 'second'); +} + +function focusSlider() { + this.videoProgressSlider.handle.attr( + 'aria-valuetext', getTimeDescription(this.videoPlayer.currentTime) + ); + this.videoProgressSlider.el.trigger('focus'); +} + +function sliderToggle(e) { + if (e.which === 32) { + e.preventDefault(); + this.videoCommands.execute('togglePlayback'); + } +} From 741e98d7ba78e630ce15d49cc90f6c6926165737 Mon Sep 17 00:00:00 2001 From: ahmad-arbisoft Date: Mon, 21 Apr 2025 13:55:03 +0500 Subject: [PATCH 09/21] vanila js conversion :: video_bumper.js --- .../video/public/js/video_block_main.js | 1 + .../assets/video/public/js/video_bumper.js | 111 ++++++++++++++++++ 2 files changed, 112 insertions(+) create mode 100644 xmodule/assets/video/public/js/video_bumper.js diff --git a/xmodule/assets/video/public/js/video_block_main.js b/xmodule/assets/video/public/js/video_block_main.js index 5c2026d820b8..1638d35e387e 100644 --- a/xmodule/assets/video/public/js/video_block_main.js +++ b/xmodule/assets/video/public/js/video_block_main.js @@ -5,6 +5,7 @@ import {VideoStorage} from './video_storage'; import {VideoPoster} from './poster'; import {VideoTranscriptDownloadHandler} from './video_accessible_menu'; import {Initialize} from './initialize'; +import {VideoBumper} from './video_bumper'; // TODO: Uncomment the imports // import { initialize } from './initialize'; // Assuming this function is imported diff --git a/xmodule/assets/video/public/js/video_bumper.js b/xmodule/assets/video/public/js/video_bumper.js new file mode 100644 index 000000000000..4ae6f19bcc24 --- /dev/null +++ b/xmodule/assets/video/public/js/video_bumper.js @@ -0,0 +1,111 @@ +'use strict'; +import $ from 'jquery'; + +/** + * VideoBumper module. + * + * @param {Function} player - The player factory function. + * @param {Object} state - Object containing the video state and DOM refs. + * @returns {Promise} A jQuery promise that resolves when bumper ends. + */ +export default class VideoBumper { + constructor(player, state) { + this.dfd = $.Deferred(); + this.element = state.el; + this.player = player; + this.state = state; + this.doNotShowAgain = false; + this.maxBumperDuration = 35; // seconds + + // Attach bumper instance to state for external reference + this.state.videoBumper = this; + + // Style and initialize + this.element.addClass('is-bumper'); + + // Bind class methods to `this` + this.showMainVideoHandler = this.showMainVideoHandler.bind(this); + this.destroy = this.destroy.bind(this); + this.skipByDuration = this.skipByDuration.bind(this); + this.destroyAndResolve = this.destroyAndResolve.bind(this); + + this.bindHandlers(); + this.initialize(); + } + + initialize() { + this.player(); + } + + getPromise() { + return this.dfd.promise(); + } + + showMainVideoHandler() { + this.state.storage.setItem('isBumperShown', true); + setTimeout(() => { + this.saveState(); + this.showMainVideo(); + }, 20); + } + + destroyAndResolve() { + this.destroy(); + this.dfd.resolve(); + } + + showMainVideo() { + if (this.state.videoPlayer) { + this.destroyAndResolve(); + } else { + this.element.on('initialize', this.destroyAndResolve); + } + } + + skip() { + this.element.trigger('skip', [this.doNotShowAgain]); + this.showMainVideoHandler(); + } + + skipAndDoNotShowAgain() { + this.doNotShowAgain = true; + this.skip(); + } + + skipByDuration(event, time) { + if (time > this.maxBumperDuration) { + this.element.trigger('ended'); + } + } + + bindHandlers() { + this.element.on('ended error', this.showMainVideoHandler); + this.element.on('timeupdate', this.skipByDuration); + } + + saveState() { + const info = { bumper_last_view_date: true }; + if (this.doNotShowAgain) { + Object.assign(info, { bumper_do_not_show_again: true }); + } + + if (this.state.videoSaveStatePlugin) { + this.state.videoSaveStatePlugin.saveState(true, info); + } + } + + destroy() { + this.element.off('ended error', this.showMainVideoHandler); + this.element.off({ + timeupdate: this.skipByDuration, + initialize: this.destroyAndResolve + }); + this.element.removeClass('is-bumper'); + + if (typeof this.state.videoPlayer?.destroy === 'function') { + this.state.videoPlayer.destroy(); + } + + delete this.state.videoBumper; + } +} From 97bc4479302579715164ce9d7dc9abe40118414e Mon Sep 17 00:00:00 2001 From: ahmad-arbisoft Date: Mon, 21 Apr 2025 15:22:56 +0500 Subject: [PATCH 10/21] vanila js conversion :: sjson.js --- xmodule/assets/video/public/js/sjson.js | 137 ++++++++++++++++++++++++ 1 file changed, 137 insertions(+) create mode 100644 xmodule/assets/video/public/js/sjson.js diff --git a/xmodule/assets/video/public/js/sjson.js b/xmodule/assets/video/public/js/sjson.js new file mode 100644 index 000000000000..d1cbf6c90ade --- /dev/null +++ b/xmodule/assets/video/public/js/sjson.js @@ -0,0 +1,137 @@ +import _ from 'underscore'; + +/** + * Creates an object to manage subtitle data. + * + * @param {object} data An object containing subtitle information with `start` (array of start times) and `text` (array of captions). + * @returns {object} An object with methods to access and manipulate subtitle data. + */ +const Sjson = function (data) { + 'use strict'; + + const sjson = { + start: Array.isArray(data.start) ? [...data.start] : [], + text: Array.isArray(data.text) ? [...data.text] : [] + }; + + /** + * Creates a getter function for a specified property of the `sjson` object. + * + * @param {string} propertyName The name of the property to access ('start' or 'text'). + * @returns {function(): Array} A function that returns a copy of the specified array. + */ + const getter = function (propertyName) { + return function () { + return [...sjson[propertyName]]; + }; + }; + + /** + * Returns a copy of the array of start times. + * + * @returns {Array} An array of subtitle start times in seconds. + */ + const getStartTimes = getter('start'); + + /** + * Returns a copy of the array of captions. + * + * @returns {Array} An array of subtitle text. + */ + const getCaptions = getter('text'); + + /** + * Returns the number of captions available. + * + * @returns {number} The total number of captions. + */ + const size = function () { + return sjson.text.length; + }; + + /** + * Searches for the index of the caption that should be displayed at a given time. + * + * @param {number} time The time (in seconds) to search for. + * @param {number} [startTime] An optional start time (in seconds) to filter the search within. + * @param {number} [endTime] An optional end time (in seconds) to filter the search within. + * @returns {number} The index of the caption to display at the given time, or the index of the last caption if the time is beyond the last start time. Returns 0 if no captions are available. + */ + function search(time, startTime, endTime) { + let start = getStartTimes(); + let max = size() - 1; + let min = 0; + let searchResults; + let index; + + if (typeof startTime !== 'undefined' && typeof endTime !== 'undefined') { + searchResults = filter(startTime, endTime); + start = searchResults.start; + max = searchResults.captions.length - 1; + } + + if (start.length === 0) { + return 0; + } + + while (min < max) { + index = Math.ceil((max + min) / 2); + + if (time < start[index]) { + max = index - 1; + } + + if (time >= start[index]) { + min = index; + } + } + + return min; + } + + /** + * Filters captions that occur between a given start and end time. + * + * @param {number} start The start time (in seconds) for filtering. + * @param {number} end The end time (in seconds) for filtering. + * @returns {object} An object with `start` (array of filtered start times) and `captions` (array of corresponding filtered captions). + */ + function filter(start, end) { + const filteredTimes = []; + const filteredCaptions = []; + const startTimes = getStartTimes(); + const captions = getCaptions(); + + if (startTimes.length !== captions.length) { + console.warn('video caption and start time arrays do not match in length'); + } + + let effectiveEnd = end; + if (effectiveEnd === null && startTimes.length) { + effectiveEnd = startTimes[startTimes.length - 1]; + } + + for (let i = 0; i < startTimes.length; i++) { + const currentStartTime = startTimes[i]; + if (currentStartTime >= start && currentStartTime <= effectiveEnd) { + filteredTimes.push(currentStartTime); + filteredCaptions.push(captions[i]); + } + } + + return { + start: filteredTimes, + captions: filteredCaptions + }; + } + + return { + getCaptions, + getStartTimes, + getSize: size, + filter, + search + }; +}; + +export { Sjson }; \ No newline at end of file From 1e11647e2c37e5117b20bc03ed42f1937c487ab8 Mon Sep 17 00:00:00 2001 From: ahmad-arbisoft Date: Mon, 21 Apr 2025 15:39:34 +0500 Subject: [PATCH 11/21] vanila js conversion :: update video_player.js --- xmodule/assets/video/public/js/video_player.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/xmodule/assets/video/public/js/video_player.js b/xmodule/assets/video/public/js/video_player.js index 24c8f1510ea6..67b29cb8db11 100644 --- a/xmodule/assets/video/public/js/video_player.js +++ b/xmodule/assets/video/public/js/video_player.js @@ -908,4 +908,5 @@ function VideoPlayer(HTML5Video, HTML5HLSVideo, Resizer, HLS, _, Time) { function onVolumeChange(volume) { this.videoPlayer.player.setVolume(volume); } -}; \ No newline at end of file +}; +export {VideoPlayer} \ No newline at end of file From 54da71bc6dc432f6d51e16c23e46821ca0edd8e4 Mon Sep 17 00:00:00 2001 From: ahmad-arbisoft Date: Tue, 22 Apr 2025 10:18:27 +0500 Subject: [PATCH 12/21] vanila js conversion :: update video_player.js --- xmodule/assets/video/public/js/video_player.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/xmodule/assets/video/public/js/video_player.js b/xmodule/assets/video/public/js/video_player.js index 67b29cb8db11..6e0920951c89 100644 --- a/xmodule/assets/video/public/js/video_player.js +++ b/xmodule/assets/video/public/js/video_player.js @@ -909,4 +909,4 @@ function VideoPlayer(HTML5Video, HTML5HLSVideo, Resizer, HLS, _, Time) { this.videoPlayer.player.setVolume(volume); } }; -export {VideoPlayer} \ No newline at end of file +export { VideoPlayer } \ No newline at end of file From bcd6ddec520834fafe059f207b1c3f408440b9d5 Mon Sep 17 00:00:00 2001 From: ahmad-arbisoft Date: Tue, 22 Apr 2025 10:21:39 +0500 Subject: [PATCH 13/21] vanila js conversion :: video_auto_advance_control.js --- .../public/js/video_auto_advance_control.js | 148 ++++++++++++++++++ 1 file changed, 148 insertions(+) create mode 100644 xmodule/assets/video/public/js/video_auto_advance_control.js diff --git a/xmodule/assets/video/public/js/video_auto_advance_control.js b/xmodule/assets/video/public/js/video_auto_advance_control.js new file mode 100644 index 000000000000..9f8c27a46a14 --- /dev/null +++ b/xmodule/assets/video/public/js/video_auto_advance_control.js @@ -0,0 +1,148 @@ +import { interpolateHtml, HTML } from 'edx-ui-toolkit/js/utils/html-utils'; +import _ from 'underscore'; +import * as gettext from 'gettext'; + +/** + * Auto advance control module. + * @exports video/08_video_auto_advance_control.js + * @constructor + * @param {object} state The object containing the state of the video player. + * @return {Promise} + */ +class AutoAdvanceControl { + constructor(state) { + if (!(this instanceof AutoAdvanceControl)) { + return new AutoAdvanceControl(state); + } + + _.bindAll(this, 'onClick', 'destroy', 'autoPlay', 'autoAdvance'); + this.state = state; + this.state.videoAutoAdvanceControl = this; + this.el = null; + this.initialize(); + + return Promise.resolve(); + } + + template = interpolateHtml( + HTML([ + ''].join('')), + { + autoAdvanceText: gettext('Auto-advance') // Assuming gettext is globally available or imported + } + ).toString(); + + destroy() { + if (this.el) { + this.el.removeEventListener('click', this.onClick); + this.el.remove(); + } + if (this.state && this.state.el) { + this.state.el.removeEventListener('ready', this.autoPlay); + this.state.el.removeEventListener('ended', this.autoAdvance); + this.state.el.removeEventListener('destroy', this.destroy); + } + if (this.state) { + delete this.state.videoAutoAdvanceControl; + } + } + + /** Initializes the module. */ + initialize() { + const state = this.state; + + this.el = this.createDOMElement(this.template); + this.render(); + this.setAutoAdvance(state.auto_advance); + this.bindHandlers(); + + return true; + } + + /** + * Creates a DOM element from an HTML string. + * @param {string} htmlString The HTML string to create the element from. + * @returns {HTMLElement} The created DOM element. + */ + createDOMElement(htmlString) { + const tempDiv = document.createElement('div'); + tempDiv.innerHTML = htmlString.trim(); + return tempDiv.firstChild; + } + + /** + * Creates any necessary DOM elements, attach them, and set their, + * initial configuration. + * @param {boolean} enabled Whether auto advance is enabled + */ + render() { + const secondaryControls = this.state.el.querySelector('.secondary-controls'); + if (secondaryControls && this.el) { + secondaryControls.prepend(this.el); + } + } + + /** + * Bind any necessary function callbacks to DOM events (click, + * mousemove, etc.). + */ + bindHandlers() { + if (this.el) { + this.el.addEventListener('click', this.onClick); + } + if (this.state && this.state.el) { + this.state.el.addEventListener('ready', this.autoPlay); + this.state.el.addEventListener('ended', this.autoAdvance); + this.state.el.addEventListener('destroy', this.destroy); + } + } + + onClick(event) { + const enabled = !this.state.auto_advance; + event.preventDefault(); + this.setAutoAdvance(enabled); + if (this.el) { + this.el.dispatchEvent(new CustomEvent('autoadvancechange', { detail: [enabled] })); + } + } + + /** + * Sets or unsets auto advance. + * @param {boolean} enabled Sets auto advance. + */ + setAutoAdvance(enabled) { + if (this.el) { + if (enabled) { + this.el.classList.add('active'); + } else { + this.el.classList.remove('active'); + } + } + } + + autoPlay() { + // Only autoplay the video if it's the first component of the unit. + // If a unit has more than one video, no more than one will autoplay. + const isFirstComponent = this.state.el.closest('.vert-0'); + if (this.state.auto_advance && isFirstComponent) { + this.state.videoCommands.execute('play'); // Assuming videoCommands is on the state + } + } + + autoAdvance() { + if (this.state.auto_advance) { + const nextButton = document.querySelector('.sequence-nav-button.button-next'); + if (nextButton) { + nextButton.click(); + } + } + } +} + +export { AutoAdvanceControl }; \ No newline at end of file From 1a0fc22d4fcde3384be9a758ae438f81a7087cc1 Mon Sep 17 00:00:00 2001 From: ahmad-arbisoft Date: Tue, 22 Apr 2025 10:24:53 +0500 Subject: [PATCH 14/21] vanila js conversion :: video_auto_advance_control.js --- .../assets/video/public/js/video_auto_advance_control.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/xmodule/assets/video/public/js/video_auto_advance_control.js b/xmodule/assets/video/public/js/video_auto_advance_control.js index 9f8c27a46a14..d6cba1b5d79d 100644 --- a/xmodule/assets/video/public/js/video_auto_advance_control.js +++ b/xmodule/assets/video/public/js/video_auto_advance_control.js @@ -9,10 +9,10 @@ import * as gettext from 'gettext'; * @param {object} state The object containing the state of the video player. * @return {Promise} */ -class AutoAdvanceControl { +class VideoAutoAdvanceControl { constructor(state) { - if (!(this instanceof AutoAdvanceControl)) { - return new AutoAdvanceControl(state); + if (!(this instanceof VideoAutoAdvanceControl)) { + return new VideoAutoAdvanceControl(state); } _.bindAll(this, 'onClick', 'destroy', 'autoPlay', 'autoAdvance'); @@ -145,4 +145,4 @@ class AutoAdvanceControl { } } -export { AutoAdvanceControl }; \ No newline at end of file +export { VideoAutoAdvanceControl }; \ No newline at end of file From 010c6cf100d2ac5223913f11a856aa4b7c95c4a9 Mon Sep 17 00:00:00 2001 From: ahmad-arbisoft Date: Tue, 22 Apr 2025 10:32:53 +0500 Subject: [PATCH 15/21] vanila js conversion :: html5_hls_video.js --- .../assets/video/public/js/html5_hls_video.js | 99 +++++++++++++++++++ 1 file changed, 99 insertions(+) create mode 100644 xmodule/assets/video/public/js/html5_hls_video.js diff --git a/xmodule/assets/video/public/js/html5_hls_video.js b/xmodule/assets/video/public/js/html5_hls_video.js new file mode 100644 index 000000000000..fad512248bcb --- /dev/null +++ b/xmodule/assets/video/public/js/html5_hls_video.js @@ -0,0 +1,99 @@ +import { bindAll, once } from 'underscore'; +import { Player as HTML5Player } from './02_html5_video.js'; +import Hls from 'hls.js'; + +export class HLSVideoPlayer extends HTML5Player { + constructor(el, config) { + super(); + + this.config = config; + this.init(el, config); + + bindAll(this, 'playVideo', 'pauseVideo', 'onReady'); + + // Handle unsupported HLS + if (config.HLSOnlySources && !config.canPlayHLS) { + this.showErrorMessage(null, '.video-hls-error'); + return; + } + + // Setup on initialize + const onInitialize = once(() => { + console.log('[HLS Video]: HLS Player initialized'); + this.showPlayButton(); + }); + config.state.el.addEventListener('initialize', onInitialize); + + // Handle native Safari HLS + if (config.browserIsSafari) { + this.videoEl.setAttribute('src', config.videoSources[0]); + } else { + this.hls = new Hls({ + autoStartLoad: config.state.auto_advance ?? false + }); + + this.hls.loadSource(config.videoSources[0]); + this.hls.attachMedia(this.video); + + this.hls.on(Hls.Events.ERROR, this.onError.bind(this)); + + this.hls.on(Hls.Events.MANIFEST_PARSED, (event, data) => { + console.log('[HLS Video]: MANIFEST_PARSED, qualityLevelsInfo:', + data.levels.map(level => ({ + bitrate: level.bitrate, + resolution: `${level.width}x${level.height}` + })) + ); + this.config.onReadyHLS?.(); + }); + + this.hls.on(Hls.Events.LEVEL_SWITCHED, (event, data) => { + const level = this.hls.levels[data.level]; + console.log('[HLS Video]: LEVEL_SWITCHED, qualityLevelInfo:', { + bitrate: level.bitrate, + resolution: `${level.width}x${level.height}` + }); + }); + } + } + + playVideo() { + super.updatePlayerLoadingState('show'); + if (!this.config.browserIsSafari) { + this.hls.startLoad(); + } + super.playVideo(); + } + + pauseVideo() { + super.pauseVideo(); + super.updatePlayerLoadingState('hide'); + } + + onPlaying() { + super.onPlaying(); + super.updatePlayerLoadingState('hide'); + } + + onReady() { + this.config.events.onReady?.(null); + } + + onError(event, data) { + if (!data.fatal) return; + + switch (data.type) { + case Hls.ErrorTypes.NETWORK_ERROR: + console.error('[HLS Video]: Fatal network error. Details:', data.details); + this.hls.startLoad(); + break; + case Hls.ErrorTypes.MEDIA_ERROR: + console.error('[HLS Video]: Fatal media error. Details:', data.details); + this.hls.recoverMediaError(); + break; + default: + console.error('[HLS Video]: Unrecoverable error. Details:', data.details); + break; + } + } +} From 5aa6360dd440df9e4395892c9276a189b637935d Mon Sep 17 00:00:00 2001 From: ahmad-arbisoft Date: Tue, 22 Apr 2025 10:40:33 +0500 Subject: [PATCH 16/21] vanila js conversion :: video_control.js --- .../assets/video/public/js/video_control.js | 167 ++++++++++++++++++ 1 file changed, 167 insertions(+) create mode 100644 xmodule/assets/video/public/js/video_control.js diff --git a/xmodule/assets/video/public/js/video_control.js b/xmodule/assets/video/public/js/video_control.js new file mode 100644 index 000000000000..f422d5ba6fc5 --- /dev/null +++ b/xmodule/assets/video/public/js/video_control.js @@ -0,0 +1,167 @@ +// VideoControl.js + +import * as Time from 'time.js'; + +/** + * Initializes video control logic. + * @param {Object} state - The shared state object for the video player. + * @returns {Promise} + */ +export default function VideoControl(state) { + return new Promise((resolve) => { + state.videoControl = {}; + + _makeFunctionsPublic(state); + _renderElements(state); + _bindHandlers(state); + + resolve(); + }); +} + +// *************************************************************** +// Private helper functions +// *************************************************************** + +function _makeFunctionsPublic(state) { + const methodsDict = { + destroy, + hideControls, + show, + showControls, + focusFirst, + updateVcrVidTime + }; + + // Equivalent of `state.bindTo(methodsDict, state.videoControl, state)` + for (const [key, fn] of Object.entries(methodsDict)) { + state.videoControl[key] = fn.bind(state); + } +} + +function destroy() { + this.el.removeEventListener('mousemove', this.videoControl.showControls); + this.el.removeEventListener('keydown', this.videoControl.showControls); + this.el.removeEventListener('destroy', this.videoControl.destroy); + this.el.removeEventListener('initialize', this.videoControl.focusFirst); + + if (this.controlHideTimeout) { + clearTimeout(this.controlHideTimeout); + } + + delete this.videoControl; +} + +function _renderElements(state) { + state.videoControl.el = state.el.querySelector('.video-controls'); + state.videoControl.vidTimeEl = state.videoControl.el.querySelector('.vidtime'); + + if (state.videoType === 'html5' && state.config.autohideHtml5) { + state.videoControl.fadeOutTimeout = state.config.fadeOutTimeout; + + state.videoControl.el.classList.add('html5'); + + state.controlHideTimeout = setTimeout( + state.videoControl.hideControls, + state.videoControl.fadeOutTimeout + ); + } +} + +function _bindHandlers(state) { + if (state.videoType === 'html5' && state.config.autohideHtml5) { + state.el.addEventListener('mousemove', state.videoControl.showControls); + state.el.addEventListener('keydown', state.videoControl.showControls); + } + + if (state.config.focusFirstControl) { + state.el.addEventListener('initialize', state.videoControl.focusFirst); + } + + state.el.addEventListener('destroy', state.videoControl.destroy); +} + +// *************************************************************** +// Public methods — bound to state.videoControl +// *************************************************************** + +function focusFirst() { + const firstControl = this.videoControl.el.querySelector('.vcr a, .vcr button'); + if (firstControl) firstControl.focus(); +} + +function show() { + this.videoControl.el.classList.remove('is-hidden'); + const event = new CustomEvent('controls:show', { detail: arguments }); + this.el.dispatchEvent(event); +} + +function showControls() { + if (this.controlShowLock || !this.captionsHidden) return; + + this.controlShowLock = true; + + const el = this.videoControl.el; + + switch (this.controlState) { + case 'invisible': + el.style.display = 'block'; + this.controlState = 'visible'; + break; + + case 'hiding': + el.style.opacity = 1; + el.style.display = 'block'; + this.controlState = 'visible'; + break; + + case 'visible': + clearTimeout(this.controlHideTimeout); + break; + } + + this.controlHideTimeout = setTimeout( + this.videoControl.hideControls, + this.videoControl.fadeOutTimeout + ); + + this.controlShowLock = false; +} + +function hideControls() { + if (!this.captionsHidden) return; + + this.controlHideTimeout = null; + this.controlState = 'hiding'; + + const el = this.videoControl.el; + el.style.transition = `opacity ${this.videoControl.fadeOutTimeout}ms`; + el.style.opacity = 0; + + setTimeout(() => { + el.style.display = 'none'; + this.controlState = 'invisible'; + + this.videoVolumeControl?.el.classList.remove('open'); + this.videoSpeedControl?.el.classList.remove('open'); + + this.focusGrabber?.enableFocusGrabber?.(); + }, this.videoControl.fadeOutTimeout); +} + +function updateVcrVidTime(params) { + let endTime = this.config.endTime ?? params.duration; + endTime = Math.min(endTime, params.duration); + + const startTime = this.config.startTime > 0 ? this.config.startTime : 0; + if (startTime && this.config.endTime) { + endTime = this.config.endTime - startTime; + } + + const currentTime = startTime ? params.time - startTime : params.time; + const formattedTime = `${Time.format(currentTime)} / ${Time.format(endTime)}`; + + if (this.videoControl.vidTimeEl) { + this.videoControl.vidTimeEl.textContent = formattedTime; + } +} From 3212ffdfeeba0ee5c106913f6f17eef99196d7b2 Mon Sep 17 00:00:00 2001 From: ahmad-arbisoft Date: Tue, 22 Apr 2025 10:43:07 +0500 Subject: [PATCH 17/21] vanila js conversion :: video_quality_control.js --- .../video/public/js/video_quality_control.js | 186 ++++++++++++++++++ 1 file changed, 186 insertions(+) create mode 100644 xmodule/assets/video/public/js/video_quality_control.js diff --git a/xmodule/assets/video/public/js/video_quality_control.js b/xmodule/assets/video/public/js/video_quality_control.js new file mode 100644 index 000000000000..ecb856e971ca --- /dev/null +++ b/xmodule/assets/video/public/js/video_quality_control.js @@ -0,0 +1,186 @@ + +import { interpolateHtml, HTML } from 'edx-ui-toolkit/js/utils/html-utils'; +import _ from 'underscore'; +import * as gettext from 'gettext'; + +/** + * Creates the VideoQualityControl module. + * @param {object} state The object containing the state of the video player. + * @returns {Promise|undefined} A promise that resolves when the module is initialized, or undefined if not applicable. + */ +const VideoQualityControl = function (state) { + // Changing quality for now only works for YouTube videos. + if (state.videoType !== 'youtube') { + return; + } + + this.state = state; + this.videoQualityControl = { + el: null, + quality: 'large' + }; + + this._makeFunctionsPublic(state); + this._renderElements(state); + this._bindHandlers(state); + + return Promise.resolve(); +}; + +/** + * Makes the VideoQualityControl functions accessible via the 'state' object. + * @param {object} state The object containing the state of the video player. + */ +VideoQualityControl.prototype._makeFunctionsPublic = function (state) { + const methodsDict = { + destroy: this.destroy.bind(this), + fetchAvailableQualities: this.fetchAvailableQualities.bind(this), + onQualityChange: this.onQualityChange.bind(this), + showQualityControl: this.showQualityControl.bind(this), + toggleQuality: this.toggleQuality.bind(this) + }; + + state.bindTo(methodsDict, state.videoQualityControl, state); +}; + +VideoQualityControl.prototype.template = interpolateHtml( + HTML([ + '' + ].join('')), + { + highDefinition: gettext('High Definition'), // Assuming gettext is globally available or imported + off: gettext('off') // Assuming gettext is globally available or imported + } +); + +VideoQualityControl.prototype.destroy = function () { + if (this.videoQualityControl.el) { + this.videoQualityControl.el.removeEventListener('click', this.videoQualityControl.toggleQuality); + this.videoQualityControl.el.remove(); + } + if (this.state && this.state.el) { + this.state.el.removeEventListener('play.quality', this.fetchAvailableQualities); + this.state.el.removeEventListener('destroy.quality', this.destroy); + } + delete this.state.videoQualityControl; +}; + +/** + * Creates and appends the DOM elements for the quality control. + * @param {object} state The object containing the state of the video player. + */ +VideoQualityControl.prototype._renderElements = function (state) { + this.videoQualityControl.el = this.createDOMElement(this.template.toString()); + const secondaryControls = state.el.querySelector('.secondary-controls'); + if (secondaryControls && this.videoQualityControl.el) { + secondaryControls.appendChild(this.videoQualityControl.el); + } +}; + +/** + * Creates a DOM element from an HTML string. + * @param {string} htmlString The HTML string to create the element from. + * @returns {HTMLElement} The created DOM element. + */ +VideoQualityControl.prototype.createDOMElement = function (htmlString) { + const tempDiv = document.createElement('div'); + tempDiv.innerHTML = htmlString.trim(); + return tempDiv.firstChild; +}; + +/** + * Binds event handlers for the quality control. + * @param {object} state The object containing the state of the video player. + */ +VideoQualityControl.prototype._bindHandlers = function (state) { + if (this.videoQualityControl.el) { + this.videoQualityControl.el.addEventListener('click', this.toggleQuality); + } + if (state && state.el) { + state.el.addEventListener('play', this.fetchAvailableQualities.bind(this), { once: true }); + state.el.addEventListener('destroy', this.destroy.bind(this)); + } +}; + +/** + * Shows the quality control button. This function will only be called if HD qualities are available. + */ +VideoQualityControl.prototype.showQualityControl = function () { + if (this.videoQualityControl.el) { + this.videoQualityControl.el.classList.remove('is-hidden'); + } +}; + +/** + * Gets the available qualities from the YouTube API. Possible values are: + * ['highres', 'hd1080', 'hd720', 'large', 'medium', 'small']. + * HD are: ['highres', 'hd1080', 'hd720']. + */ +VideoQualityControl.prototype.fetchAvailableQualities = function () { + if (this.state && this.state.videoPlayer && this.state.videoPlayer.player && this.state.videoPlayer.player.getAvailableQualityLevels) { + const qualities = this.state.videoPlayer.player.getAvailableQualityLevels(); + this.state.config.availableHDQualities = _.intersection( + qualities, ['highres', 'hd1080', 'hd720'] + ); + + // HD qualities are available, show video quality control. + if (this.state.config.availableHDQualities.length > 0) { + this.showQualityControl(); + this.onQualityChange(this.videoQualityControl.quality); + } + // On initialization, force the video quality to be 'large' instead of + // 'default'. Otherwise, the player will sometimes switch to HD + // automatically, for example when the iframe resizes itself. + this.state.trigger('videoPlayer.handlePlaybackQualityChange', + this.videoQualityControl.quality + ); + } +}; + +/** + * Updates the visual state of the quality control button based on the current quality. + * @param {string} value The current quality level. + */ +VideoQualityControl.prototype.onQualityChange = function (value) { + this.videoQualityControl.quality = value; + if (this.videoQualityControl.el) { + const controlTextSpan = this.videoQualityControl.el.querySelector('.control-text'); + if (_.contains(this.state.config.availableHDQualities, value)) { + this.videoQualityControl.el.classList.add('active'); + if (controlTextSpan) { + controlTextSpan.textContent = gettext('on'); // Assuming gettext is globally available or imported + } + } else { + this.videoQualityControl.el.classList.remove('active'); + if (controlTextSpan) { + controlTextSpan.textContent = gettext('off'); // Assuming gettext is globally available or imported + } + } + } +}; + +/** + * Toggles the quality of the video if HD qualities are available. + * @param {Event} event The click event. + */ +VideoQualityControl.prototype.toggleQuality = function (event) { + event.preventDefault(); + if (this.state && this.state.config && this.state.config.availableHDQualities) { + const currentValue = this.videoQualityControl.quality; + const isHD = _.contains(this.state.config.availableHDQualities, currentValue); + const newQuality = isHD ? 'large' : 'highres'; + this.state.trigger('videoPlayer.handlePlaybackQualityChange', newQuality); + } +}; + +export {VideoQualityControl}; \ No newline at end of file From c427b6b03251907dfe55849375bfd74e999f23ff Mon Sep 17 00:00:00 2001 From: ahmad-arbisoft Date: Tue, 22 Apr 2025 10:44:58 +0500 Subject: [PATCH 18/21] vanila js conversion :: resizer.js --- xmodule/assets/video/public/js/resizer.js | 191 ++++++++++++++++++++++ 1 file changed, 191 insertions(+) create mode 100644 xmodule/assets/video/public/js/resizer.js diff --git a/xmodule/assets/video/public/js/resizer.js b/xmodule/assets/video/public/js/resizer.js new file mode 100644 index 000000000000..bd1382be16e1 --- /dev/null +++ b/xmodule/assets/video/public/js/resizer.js @@ -0,0 +1,191 @@ +import _ from 'underscore'; + +class Resizer { + constructor(params) { + const defaults = { + container: window, + element: null, + containerRatio: null, + elementRatio: null + }; + this.callbacksList = []; + this.delta = { + height: 0, + width: 0 + }; + this.mode = null; + this.config = { ...defaults, ...params }; + + if (!this.config.element) { + console.log('Required parameter `element` is not passed.'); + } + + this.callbacks = { + add: this.addCallback.bind(this), + once: this.addOnceCallback.bind(this), + remove: this.removeCallback.bind(this), + removeAll: this.removeCallbacks.bind(this) + }; + this.deltaApi = { + add: this.addDelta.bind(this), + substract: this.substractDelta.bind(this), + reset: this.resetDelta.bind(this) + }; + } + + getData() { + const $container = this.config.container instanceof Window ? this.config.container : this.config.container; + const containerWidth = ($container === window ? $container.innerWidth : $container.offsetWidth) + this.delta.width; + const containerHeight = ($container === window ? $container.innerHeight : $container.offsetHeight) + this.delta.height; + let containerRatio = this.config.containerRatio; + + const $element = this.config.element; + let elementRatio = this.config.elementRatio; + + if (!containerRatio) { + containerRatio = containerWidth / containerHeight; + } + + if (!elementRatio && $element) { + elementRatio = $element.offsetWidth / $element.offsetHeight; + } + + return { + containerWidth, + containerHeight, + containerRatio, + element: $element, + elementRatio + }; + } + + align() { + const data = this.getData(); + + switch (this.mode) { + case 'height': + this.alignByHeightOnly(data); + break; + case 'width': + this.alignByWidthOnly(data); + break; + default: + if (data.containerRatio >= data.elementRatio) { + this.alignByHeightOnly(data); + } else { + this.alignByWidthOnly(data); + } + break; + } + + this.fireCallbacks(); + return this; + } + + alignByWidthOnly(data = this.getData()) { + if (!data.element) return this; + const height = data.containerWidth / data.elementRatio; + data.element.style.height = `${height}px`; + data.element.style.width = `${data.containerWidth}px`; + data.element.style.top = `${0.5 * (data.containerHeight - height)}px`; + data.element.style.left = '0px'; + return this; + } + + alignByHeightOnly(data = this.getData()) { + if (!data.element) return this; + const width = data.containerHeight * data.elementRatio; + data.element.style.height = `${data.containerHeight}px`; + data.element.style.width = `${width}px`; + data.element.style.top = '0px'; + data.element.style.left = `${0.5 * (data.containerWidth - width)}px`; + return this; + } + + setMode(param) { + if (_.isString(param)) { + this.mode = param; + this.align(); + } + return this; + } + + setElement(element) { + this.config.element = element; + return this; + } + + addCallback(func) { + if (_.isFunction(func)) { + this.callbacksList.push(func); + } else { + console.error('[Video info]: TypeError: Argument is not a function.'); + } + return this; + } + + addOnceCallback(func) { + if (_.isFunction(func)) { + const decorator = () => { + func(); + this.removeCallback(func); + }; + this.addCallback(decorator); + } else { + console.error('TypeError: Argument is not a function.'); + } + return this; + } + + fireCallbacks() { + this.callbacksList.forEach(callback => callback()); + } + + removeCallbacks() { + this.callbacksList.length = 0; + return this; + } + + removeCallback(func) { + const index = this.callbacksList.indexOf(func); + if (index !== -1) { + return this.callbacksList.splice(index, 1); + } + return undefined; + } + + resetDelta() { + this.delta.height = 0; + this.delta.width = 0; + return this; + } + + addDelta(value, side) { + if (_.isNumber(value) && _.isNumber(this.delta[side])) { + this.delta[side] += value; + } + return this; + } + + substractDelta(value, side) { + if (_.isNumber(value) && _.isNumber(this.delta[side])) { + this.delta[side] -= value; + } + return this; + } + + destroy() { + const data = this.getData(); + if (data.element) { + data.element.style.height = ''; + data.element.style.width = ''; + data.element.style.top = ''; + data.element.style.left = ''; + } + this.removeCallbacks(); + this.resetDelta(); + this.mode = null; + } +} + +export { Resizer }; \ No newline at end of file From 18ed4d18bfb2d8a2c3206098c6ef7ed69d1f0300 Mon Sep 17 00:00:00 2001 From: ahmad-arbisoft Date: Tue, 22 Apr 2025 10:47:57 +0500 Subject: [PATCH 19/21] vanila js conversion :: video_completion_handler.js --- .../public/js/video_completion_handler.js | 133 ++++++++++++++++++ 1 file changed, 133 insertions(+) create mode 100644 xmodule/assets/video/public/js/video_completion_handler.js diff --git a/xmodule/assets/video/public/js/video_completion_handler.js b/xmodule/assets/video/public/js/video_completion_handler.js new file mode 100644 index 000000000000..ddcbe0dbad16 --- /dev/null +++ b/xmodule/assets/video/public/js/video_completion_handler.js @@ -0,0 +1,133 @@ +// VideoCompletionHandler.js + +/** + * Handles video completion logic. + * @param {Object} state - The shared video state object. + * @returns {Promise} + */ +function VideoCompletionHandler(state) { + const handler = new CompletionHandler(state); + state.completionHandler = handler; + return Promise.resolve(); +} + +class CompletionHandler { + constructor(state) { + this.state = state; + this.el = state.el; // Should be a DOM element + this.lastSentTime = undefined; + this.isComplete = false; + this.completionPercentage = state.config.completionPercentage; + this.startTime = state.config.startTime; + this.endTime = state.config.endTime; + this.isEnabled = state.config.completionEnabled; + + if (this.endTime) { + this.completeAfterTime = this.calculateCompleteAfterTime(this.startTime, this.endTime); + } + + if (this.isEnabled) { + this.bindHandlers(); + } + } + + destroy() { + this.el.removeEventListener('timeupdate', this._onTimeUpdate); + this.el.removeEventListener('ended', this._onEnded); + this.el.removeEventListener('metadata_received', this._onMetadataReceived); + delete this.state.completionHandler; + } + + bindHandlers() { + this._onEnded = this.handleEnded.bind(this); + this._onTimeUpdate = (e) => this.handleTimeUpdate(e.detail); + this._onMetadataReceived = this.checkMetadata.bind(this); + + this.el.addEventListener('ended', this._onEnded); + this.el.addEventListener('timeupdate', this._onTimeUpdate); + this.el.addEventListener('metadata_received', this._onMetadataReceived); + } + + handleEnded() { + if (!this.isComplete) { + this.markCompletion(); + } + } + + handleTimeUpdate(currentTime) { + if (this.isComplete) return; + + const now = Date.now() / 1000; + + if ( + this.lastSentTime !== undefined && + currentTime - this.lastSentTime < this.repostDelaySeconds() + ) { + return; + } + + if (this.completeAfterTime === undefined) { + const duration = this.state.videoPlayer.duration?.(); + if (!duration) return; + this.completeAfterTime = this.calculateCompleteAfterTime(this.startTime, duration); + } + + if (currentTime > this.completeAfterTime) { + this.markCompletion(currentTime); + } + } + + checkMetadata() { + const metadata = this.state.metadata?.[this.state.youtubeId()]; + const ytRating = metadata?.contentRating?.ytRating; + + if (ytRating === 'ytAgeRestricted' && !this.isComplete) { + this.markCompletion(); + } + } + + async markCompletion(currentTime) { + this.isComplete = true; + this.lastSentTime = currentTime; + + this.el.dispatchEvent(new CustomEvent('complete')); + + const url = this.state.config.publishCompletionUrl; + + if (!url) { + console.warn('publishCompletionUrl not defined'); + return; + } + + try { + const res = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ completion: 1.0 }) + }); + + if (!res.ok) { + const error = await res.json(); + throw new Error(error?.error || 'Unknown error'); + } + + this.el.removeEventListener('timeupdate', this._onTimeUpdate); + this.el.removeEventListener('ended', this._onEnded); + } catch (err) { + console.warn('Failed to submit completion:', err.message); + this.isComplete = false; + } + } + + calculateCompleteAfterTime(startTime, endTime) { + return startTime + (endTime - startTime) * this.completionPercentage; + } + + repostDelaySeconds() { + return 3.0; + } +} + +export { VideoCompletionHandler } \ No newline at end of file From fcb32ba47977ae4768c20c76a41f754d02f88f1f Mon Sep 17 00:00:00 2001 From: ahmad-arbisoft Date: Tue, 22 Apr 2025 11:38:56 +0500 Subject: [PATCH 20/21] vanila js conversion :: update video_completion_handler.js --- xmodule/assets/video/public/js/video_completion_handler.js | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/xmodule/assets/video/public/js/video_completion_handler.js b/xmodule/assets/video/public/js/video_completion_handler.js index ddcbe0dbad16..2bb153bab728 100644 --- a/xmodule/assets/video/public/js/video_completion_handler.js +++ b/xmodule/assets/video/public/js/video_completion_handler.js @@ -1,11 +1,10 @@ -// VideoCompletionHandler.js - /** * Handles video completion logic. * @param {Object} state - The shared video state object. * @returns {Promise} */ -function VideoCompletionHandler(state) { + +const VideoCompletionHandler = (state) => { const handler = new CompletionHandler(state); state.completionHandler = handler; return Promise.resolve(); From bd7f01f16537fad2b511458841e4811f1045fd69 Mon Sep 17 00:00:00 2001 From: ahmad-arbisoft Date: Tue, 22 Apr 2025 14:51:53 +0500 Subject: [PATCH 21/21] vanila js conversion :: revert video_block_main --- xmodule/assets/video/public/js/video_block_main.js | 1 - 1 file changed, 1 deletion(-) diff --git a/xmodule/assets/video/public/js/video_block_main.js b/xmodule/assets/video/public/js/video_block_main.js index 1638d35e387e..5c2026d820b8 100644 --- a/xmodule/assets/video/public/js/video_block_main.js +++ b/xmodule/assets/video/public/js/video_block_main.js @@ -5,7 +5,6 @@ import {VideoStorage} from './video_storage'; import {VideoPoster} from './poster'; import {VideoTranscriptDownloadHandler} from './video_accessible_menu'; import {Initialize} from './initialize'; -import {VideoBumper} from './video_bumper'; // TODO: Uncomment the imports // import { initialize } from './initialize'; // Assuming this function is imported