From 8ee614a60adc54405af1a3b610ef6b46c34a04f4 Mon Sep 17 00:00:00 2001 From: Noel De Martin Date: Wed, 22 Feb 2023 15:04:59 +0100 Subject: [PATCH] MOBILE-4239 mediaplugin: Lazy load videojs --- .../mediaplugin/classes/videojs-ogvjs.ts | 9 +- .../filter/mediaplugin/mediaplugin.module.ts | 3 - .../services/handlers/mediaplugin.ts | 116 +---------- .../filter/mediaplugin/services/videojs.ts | 188 ++++++++++++++++++ .../filter/mediaplugin/utils/videojs.ts | 28 +++ .../MediaElementController.ts | 54 +++-- src/core/singletons/events.ts | 12 -- src/index.html | 7 - src/polyfills.ts | 1 + 9 files changed, 265 insertions(+), 153 deletions(-) create mode 100644 src/addons/filter/mediaplugin/services/videojs.ts create mode 100644 src/addons/filter/mediaplugin/utils/videojs.ts diff --git a/src/addons/filter/mediaplugin/classes/videojs-ogvjs.ts b/src/addons/filter/mediaplugin/classes/videojs-ogvjs.ts index 16fc96e19..3301aecb6 100644 --- a/src/addons/filter/mediaplugin/classes/videojs-ogvjs.ts +++ b/src/addons/filter/mediaplugin/classes/videojs-ogvjs.ts @@ -16,7 +16,7 @@ import { CorePlatform } from '@services/platform'; import { OGVPlayer, OGVCompat, OGVLoader } from 'ogv'; import videojs, { PreloadOption, TechSourceObject, VideoJSOptions } from 'video.js'; -export const Tech = videojs.getComponent('Tech'); +const Tech = videojs.getComponent('Tech'); /** * Object.defineProperty but "lazy", which means that the value is only set after @@ -728,13 +728,6 @@ export class VideoJSOgvJS extends Tech { ].forEach(([key, fn]) => { defineLazyProperty(VideoJSOgvJS.prototype, key, () => VideoJSOgvJS[fn](), true); }); -/** - * Initialize the controller. - */ -export const initializeVideoJSOgvJS = (): void => { - OGVLoader.base = 'assets/lib/ogv'; - Tech.registerTech('OgvJS', VideoJSOgvJS); -}; type OGVPlayerEl = (HTMLAudioElement | HTMLVideoElement) & { stop: () => void; diff --git a/src/addons/filter/mediaplugin/mediaplugin.module.ts b/src/addons/filter/mediaplugin/mediaplugin.module.ts index 821b4db73..69451421c 100644 --- a/src/addons/filter/mediaplugin/mediaplugin.module.ts +++ b/src/addons/filter/mediaplugin/mediaplugin.module.ts @@ -15,7 +15,6 @@ import { APP_INITIALIZER, NgModule } from '@angular/core'; import { CoreFilterDelegate } from '@features/filter/services/filter-delegate'; -import { initializeVideoJSOgvJS } from './classes/videojs-ogvjs'; import { AddonFilterMediaPluginHandler } from './services/handlers/mediaplugin'; @NgModule({ @@ -29,8 +28,6 @@ import { AddonFilterMediaPluginHandler } from './services/handlers/mediaplugin'; multi: true, useValue: () => { CoreFilterDelegate.registerHandler(AddonFilterMediaPluginHandler.instance); - - initializeVideoJSOgvJS(); }, }, ], diff --git a/src/addons/filter/mediaplugin/services/handlers/mediaplugin.ts b/src/addons/filter/mediaplugin/services/handlers/mediaplugin.ts index 503233620..5c5cea576 100644 --- a/src/addons/filter/mediaplugin/services/handlers/mediaplugin.ts +++ b/src/addons/filter/mediaplugin/services/handlers/mediaplugin.ts @@ -12,18 +12,12 @@ // See the License for the specific language governing permissions and // limitations under the License. +import { AddonFilterMediaPluginVideoJS } from '@addons/filter/mediaplugin/services/videojs'; import { Injectable } from '@angular/core'; -import { CoreExternalContentDirective } from '@directives/external-content'; import { CoreFilterDefaultHandler } from '@features/filter/services/handlers/default-filter'; -import { CoreLang } from '@services/lang'; -import { CoreTextUtils } from '@services/utils/text'; -import { CoreUrlUtils } from '@services/utils/url'; import { makeSingleton } from '@singletons'; -import { CoreDirectivesRegistry } from '@singletons/directives-registry'; -import { CoreEvents } from '@singletons/events'; import { CoreMedia } from '@singletons/media'; -import videojs, { VideoJSOptions, VideoJSPlayer } from 'video.js'; /** * Handler to support the Multimedia filter. @@ -39,15 +33,13 @@ export class AddonFilterMediaPluginHandlerService extends CoreFilterDefaultHandl /** * @inheritdoc */ - filter( - text: string, - ): string | Promise { + filter(text: string): string | Promise { this.template.innerHTML = text; const videos = Array.from(this.template.content.querySelectorAll('video')); videos.forEach((video) => { - this.treatYoutubeVideos(video); + AddonFilterMediaPluginVideoJS.treatYoutubeVideos(video); }); return this.template.innerHTML; @@ -61,104 +53,18 @@ export class AddonFilterMediaPluginHandlerService extends CoreFilterDefaultHandl mediaElements.forEach((mediaElement) => { if (CoreMedia.mediaUsesJavascriptPlayer(mediaElement)) { - this.useVideoJS(mediaElement); - } else { - // Remove the VideoJS classes and data if present. - mediaElement.classList.remove('video-js'); - mediaElement.removeAttribute('data-setup'); - mediaElement.removeAttribute('data-setup-lazy'); + AddonFilterMediaPluginVideoJS.createPlayer(mediaElement); + + return; } + + // Remove the VideoJS classes and data if present. + mediaElement.classList.remove('video-js'); + mediaElement.removeAttribute('data-setup'); + mediaElement.removeAttribute('data-setup-lazy'); }); } - /** - * Use video JS in a certain video or audio. - * - * @param mediaElement Media element. - */ - protected async useVideoJS(mediaElement: HTMLVideoElement | HTMLAudioElement): Promise { - const lang = await CoreLang.getCurrentLanguage(); - - // Wait for external-content to finish in the element and its sources. - await Promise.all([ - CoreDirectivesRegistry.waitDirectivesReady(mediaElement, undefined, CoreExternalContentDirective), - CoreDirectivesRegistry.waitDirectivesReady(mediaElement, 'source', CoreExternalContentDirective), - ]); - - const dataSetupString = mediaElement.getAttribute('data-setup') || mediaElement.getAttribute('data-setup-lazy') || '{}'; - const data = CoreTextUtils.parseJSON(dataSetupString, {}); - - const player = videojs(mediaElement, { - controls: true, - techOrder: ['OgvJS'], - language: lang, - controlBar: { - pictureInPictureToggle: false, - }, - aspectRatio: data.aspectRatio, - }, () => { - if (mediaElement.tagName === 'VIDEO') { - this.fixVideoJSPlayerSize(player); - } - }); - - CoreEvents.trigger(CoreEvents.JS_PLAYER_CREATED, { - id: mediaElement.id, - element: mediaElement, - player, - }); - } - - /** - * Fix VideoJS player size. - * If video width is wider than available width, video is cut off. Fix the dimensions in this case. - * - * @param player Player instance. - */ - protected fixVideoJSPlayerSize(player: VideoJSPlayer): void { - const videoWidth = player.videoWidth(); - const videoHeight = player.videoHeight(); - const playerDimensions = player.currentDimensions(); - if (!videoWidth || !videoHeight || !playerDimensions.width || videoWidth === playerDimensions.width) { - return; - } - - const candidateHeight = playerDimensions.width * videoHeight / videoWidth; - if (!playerDimensions.height || Math.abs(candidateHeight - playerDimensions.height) > 1) { - player.dimension('height', candidateHeight); - } - } - - /** - * Treat Video JS Youtube video links and translate them to iframes. - * - * @param video Video element. - */ - protected treatYoutubeVideos(video: HTMLElement): void { - if (!video.classList.contains('video-js')) { - return; - } - - const dataSetupString = video.getAttribute('data-setup') || video.getAttribute('data-setup-lazy') || '{}'; - const data = CoreTextUtils.parseJSON(dataSetupString, {}); - const youtubeUrl = data.techOrder?.[0] == 'youtube' && CoreUrlUtils.getYoutubeEmbedUrl(data.sources?.[0]?.src); - - if (!youtubeUrl) { - return; - } - - const iframe = document.createElement('iframe'); - iframe.id = video.id; - iframe.src = youtubeUrl; - iframe.setAttribute('frameborder', '0'); - iframe.setAttribute('allowfullscreen', '1'); - iframe.width = '100%'; - iframe.height = '300'; - - // Replace video tag by the iframe. - video.parentNode?.replaceChild(iframe, video); - } - } export const AddonFilterMediaPluginHandler = makeSingleton(AddonFilterMediaPluginHandlerService); diff --git a/src/addons/filter/mediaplugin/services/videojs.ts b/src/addons/filter/mediaplugin/services/videojs.ts new file mode 100644 index 000000000..2bfb87f8f --- /dev/null +++ b/src/addons/filter/mediaplugin/services/videojs.ts @@ -0,0 +1,188 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Injectable } from '@angular/core'; +import { CorePromisedValue } from '@classes/promised-value'; +import { CoreExternalContentDirective } from '@directives/external-content'; +import { CoreLang } from '@services/lang'; +import { CoreTextUtils } from '@services/utils/text'; +import { CoreUrlUtils } from '@services/utils/url'; +import { makeSingleton } from '@singletons'; +import { CoreDirectivesRegistry } from '@singletons/directives-registry'; +import { CoreEvents } from '@singletons/events'; +import type videojs from 'video.js'; + +// eslint-disable-next-line no-duplicate-imports +import type { VideoJSOptions, VideoJSPlayer } from 'video.js'; + +declare module '@singletons/events' { + + /** + * Augment CoreEventsData interface with events specific to this service. + * + * @see https://www.typescriptlang.org/docs/handbook/declaration-merging.html#module-augmentation + */ + export interface CoreEventsData { + [VIDEO_JS_PLAYER_CREATED]: CoreEventJSVideoPlayerCreated; + } + +} + +export const VIDEO_JS_PLAYER_CREATED = 'video_js_player_created'; + +/** + * Wrapper encapsulating videojs functionality. + */ +@Injectable({ providedIn: 'root' }) +export class AddonFilterMediaPluginVideoJSService { + + protected videojs?: CorePromisedValue; + + /** + * Create a VideoJS player. + * + * @param element Media element. + */ + async createPlayer(element: HTMLVideoElement | HTMLAudioElement): Promise { + // Wait for external-content to finish in the element and its sources. + await Promise.all([ + CoreDirectivesRegistry.waitDirectivesReady(element, undefined, CoreExternalContentDirective), + CoreDirectivesRegistry.waitDirectivesReady(element, 'source', CoreExternalContentDirective), + ]); + + // Create player. + const videojs = await this.getVideoJS(); + const dataSetupString = element.getAttribute('data-setup') || element.getAttribute('data-setup-lazy') || '{}'; + const data = CoreTextUtils.parseJSON(dataSetupString, {}); + const player = videojs( + element, + { + controls: true, + techOrder: ['OgvJS'], + language: await CoreLang.getCurrentLanguage(), + controlBar: { pictureInPictureToggle: false }, + aspectRatio: data.aspectRatio, + }, + () => element.tagName === 'VIDEO' && this.fixVideoJSPlayerSize(player), + ); + + CoreEvents.trigger(VIDEO_JS_PLAYER_CREATED, { + element, + player, + }); + } + + /** + * Find a VideoJS player by id. + * + * @param id Element id. + * @returns VideoJS player. + */ + async findPlayer(id: string): Promise { + const videojs = await this.getVideoJS(); + + return videojs.getPlayer(id); + } + + /** + * Treat Video JS Youtube video links and translate them to iframes. + * + * @param video Video element. + */ + treatYoutubeVideos(video: HTMLElement): void { + if (!video.classList.contains('video-js')) { + return; + } + + const dataSetupString = video.getAttribute('data-setup') || video.getAttribute('data-setup-lazy') || '{}'; + const data = CoreTextUtils.parseJSON(dataSetupString, {}); + const youtubeUrl = data.techOrder?.[0] == 'youtube' && CoreUrlUtils.getYoutubeEmbedUrl(data.sources?.[0]?.src); + + if (!youtubeUrl) { + return; + } + + const iframe = document.createElement('iframe'); + iframe.id = video.id; + iframe.src = youtubeUrl; + iframe.setAttribute('frameborder', '0'); + iframe.setAttribute('allowfullscreen', '1'); + iframe.width = '100%'; + iframe.height = '300'; + + // Replace video tag by the iframe. + video.parentNode?.replaceChild(iframe, video); + } + + /** + * Gets videojs instance. + * + * @returns VideoJS. + */ + protected async getVideoJS(): Promise { + if (!this.videojs) { + this.videojs = new CorePromisedValue(); + + // Inject CSS. + const link = document.createElement('link'); + + link.rel = 'stylesheet'; + link.href = 'assets/lib/video.js/video-js.min.css'; + + document.head.appendChild(link); + + // Load library. + return import('@addons/filter/mediaplugin/utils/videojs').then(({ initializeVideoJSOgvJS, videojs }) => { + initializeVideoJSOgvJS(); + + this.videojs?.resolve(videojs); + + return videojs; + }); + } + + return this.videojs; + } + + /** + * Fix VideoJS player size. + * If video width is wider than available width, video is cut off. Fix the dimensions in this case. + * + * @param player Player instance. + */ + protected fixVideoJSPlayerSize(player: VideoJSPlayer): void { + const videoWidth = player.videoWidth(); + const videoHeight = player.videoHeight(); + const playerDimensions = player.currentDimensions(); + if (!videoWidth || !videoHeight || !playerDimensions.width || videoWidth === playerDimensions.width) { + return; + } + + const candidateHeight = playerDimensions.width * videoHeight / videoWidth; + if (!playerDimensions.height || Math.abs(candidateHeight - playerDimensions.height) > 1) { + player.dimension('height', candidateHeight); + } + } + +} + +export const AddonFilterMediaPluginVideoJS = makeSingleton(AddonFilterMediaPluginVideoJSService); + +/** + * Data passed to VIDEO_JS_PLAYER_CREATED event. + */ +export type CoreEventJSVideoPlayerCreated = { + element: HTMLAudioElement | HTMLVideoElement; + player: VideoJSPlayer; +}; diff --git a/src/addons/filter/mediaplugin/utils/videojs.ts b/src/addons/filter/mediaplugin/utils/videojs.ts new file mode 100644 index 000000000..6d074d473 --- /dev/null +++ b/src/addons/filter/mediaplugin/utils/videojs.ts @@ -0,0 +1,28 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { VideoJSOgvJS } from '@addons/filter/mediaplugin/classes/videojs-ogvjs'; +import { OGVLoader } from 'ogv'; +import videojs from 'video.js'; + +export { videojs }; + +/** + * Initialize the controller. + */ +export function initializeVideoJSOgvJS(): void { + OGVLoader.base = 'assets/lib/ogv'; + + videojs.getComponent('Tech').registerTech('OgvJS', VideoJSOgvJS); +} diff --git a/src/core/classes/element-controllers/MediaElementController.ts b/src/core/classes/element-controllers/MediaElementController.ts index 247c3985e..3a67a6ceb 100644 --- a/src/core/classes/element-controllers/MediaElementController.ts +++ b/src/core/classes/element-controllers/MediaElementController.ts @@ -14,10 +14,11 @@ import { CoreUtils } from '@services/utils/utils'; import { ElementController } from './ElementController'; -import videojs, { VideoJSPlayer } from 'video.js'; import { CorePromisedValue } from '@classes/promised-value'; import { CoreEventObserver, CoreEvents } from '@singletons/events'; import { CoreMedia } from '@singletons/media'; +import { AddonFilterMediaPluginVideoJS, VIDEO_JS_PLAYER_CREATED } from '@addons/filter/mediaplugin/services/videojs'; +import type { VideoJSPlayer } from 'video.js'; /** * Wrapper class to control the interactivity of a media element. @@ -42,21 +43,7 @@ export class MediaElementController extends ElementController { media.autoplay = false; - if (CoreMedia.mediaUsesJavascriptPlayer(media)) { - const player = this.searchJSPlayer(); - if (player) { - this.jsPlayer.resolve(player); - } else { - this.jsPlayerListener = CoreEvents.on(CoreEvents.JS_PLAYER_CREATED, data => { - if (data.element === media) { - this.jsPlayerListener?.off(); - this.jsPlayer.resolve(data.player); - } - }); - } - } else { - this.jsPlayer.resolve(null); - } + this.initJSPlayer(media); enabled && this.onEnabled(); } @@ -115,6 +102,36 @@ export class MediaElementController extends ElementController { jsPlayer?.dispose(); } + /** + * Init JS Player instance. + * + * @param media Media element. + */ + private async initJSPlayer(media: HTMLMediaElement): Promise { + if (!CoreMedia.mediaUsesJavascriptPlayer(media)) { + this.jsPlayer.resolve(null); + + return; + } + + const player = await this.searchJSPlayer(); + + if (!player) { + this.jsPlayerListener = CoreEvents.on(VIDEO_JS_PLAYER_CREATED, ({ element, player }) => { + if (element !== media) { + return; + } + + this.jsPlayerListener?.off(); + this.jsPlayer.resolve(player); + }); + + return; + } + + this.jsPlayer.resolve(player); + } + /** * Start listening playback events. * @@ -153,8 +170,9 @@ export class MediaElementController extends ElementController { * * @returns Player instance if found. */ - private searchJSPlayer(): VideoJSPlayer | null { - return videojs.getPlayer(this.media.id) || videojs.getPlayer(this.media.id.replace('_html5_api', '')); + private async searchJSPlayer(): Promise { + return AddonFilterMediaPluginVideoJS.findPlayer(this.media.id) + ?? AddonFilterMediaPluginVideoJS.findPlayer(this.media.id.replace('_html5_api', '')); } } diff --git a/src/core/singletons/events.ts b/src/core/singletons/events.ts index 10be74a3e..4f33ad1fa 100644 --- a/src/core/singletons/events.ts +++ b/src/core/singletons/events.ts @@ -20,7 +20,6 @@ import { CoreFilepoolComponentFileEventData } from '@services/filepool'; import { CoreRedirectPayload } from '@services/navigator'; import { CoreCourseModuleCompletionData } from '@features/course/services/course-helper'; import { CoreScreenOrientation } from '@services/screen'; -import { VideoJSPlayer } from 'video.js'; /** * Observer instance to stop listening to an event. @@ -65,7 +64,6 @@ export interface CoreEventsData { [CoreEvents.ORIENTATION_CHANGE]: CoreEventOrientationData; [CoreEvents.COURSE_MODULE_VIEWED]: CoreEventCourseModuleViewed; [CoreEvents.COMPLETE_REQUIRED_PROFILE_DATA_FINISHED]: CoreEventCompleteRequiredProfileDataFinished; - [CoreEvents.JS_PLAYER_CREATED]: CoreEventJSVideoPlayerCreated; } /* @@ -125,7 +123,6 @@ export class CoreEvents { static readonly COMPLETE_REQUIRED_PROFILE_DATA_FINISHED = 'complete_required_profile_data_finished'; static readonly MAIN_HOME_LOADED = 'main_home_loaded'; static readonly FULL_SCREEN_CHANGED = 'full_screen_changed'; - static readonly JS_PLAYER_CREATED = 'js_player_created'; protected static logger = CoreLogger.getInstance('CoreEvents'); protected static observables: { [eventName: string]: Subject } = {}; @@ -493,12 +490,3 @@ export type CoreEventCourseModuleViewed = { export type CoreEventCompleteRequiredProfileDataFinished = { path: string; }; - -/** - * Data passed to JS_PLAYER_CREATED event. - */ -export type CoreEventJSVideoPlayerCreated = { - id: string; - element: HTMLAudioElement | HTMLVideoElement; - player: VideoJSPlayer; -}; diff --git a/src/index.html b/src/index.html index b031f9255..864f4b89a 100644 --- a/src/index.html +++ b/src/index.html @@ -16,19 +16,12 @@ - - diff --git a/src/polyfills.ts b/src/polyfills.ts index cf90936b0..06cf4f719 100644 --- a/src/polyfills.ts +++ b/src/polyfills.ts @@ -20,6 +20,7 @@ import 'zone.js/dist/zone'; // Platform polyfills import 'core-js/es/array/includes'; +import 'core-js/es/global-this'; import 'core-js/es/promise/finally'; import 'core-js/es/string/match-all'; import 'core-js/es/string/trim-right';