MOBILE-4239 mediaplugin: Lazy load videojs

main
Noel De Martin 2023-02-22 15:04:59 +01:00
parent 4cb9a6640c
commit 8ee614a60a
9 changed files with 265 additions and 153 deletions

View File

@ -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;

View File

@ -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();
},
},
],

View File

@ -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<string> {
filter(text: string): string | Promise<string> {
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<void> {
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<VideoJSOptions>(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<VideoJSOptions>(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);

View File

@ -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<typeof videojs>;
/**
* Create a VideoJS player.
*
* @param element Media element.
*/
async createPlayer(element: HTMLVideoElement | HTMLAudioElement): Promise<void> {
// 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<VideoJSOptions>(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<VideoJSPlayer | null> {
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<VideoJSOptions>(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<typeof videojs> {
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;
};

View File

@ -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);
}

View File

@ -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<void> {
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<VideoJSPlayer | null> {
return AddonFilterMediaPluginVideoJS.findPlayer(this.media.id)
?? AddonFilterMediaPluginVideoJS.findPlayer(this.media.id.replace('_html5_api', ''));
}
}

View File

@ -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<unknown> } = {};
@ -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;
};

View File

@ -16,19 +16,12 @@
<meta name="msapplication-tap-highlight" content="no" />
<link rel="icon" type="image/png" href="assets/icon/favicon.png" />
<link rel="stylesheet" href="assets/lib/video.js/video-js.min.css">
<!-- add to homescreen for ios -->
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black" />
<script src="assets/lib/mathjax/MathJax.js?delayStartupUntil=configured"></script>
<script type="text/javascript">
if (globalThis === undefined) {
// Define globalThis in environments where it's not supported to avoid errors with ogv.js.
var globalThis = window;
}
</script>
</head>
<body>

View File

@ -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';