diff --git a/src/addons/filter/mediaplugin/services/handlers/mediaplugin.ts b/src/addons/filter/mediaplugin/services/handlers/mediaplugin.ts index a54442278..0de318e04 100644 --- a/src/addons/filter/mediaplugin/services/handlers/mediaplugin.ts +++ b/src/addons/filter/mediaplugin/services/handlers/mediaplugin.ts @@ -22,6 +22,7 @@ import { CoreUrlUtils } from '@services/utils/url'; import { makeSingleton } from '@singletons'; import { CoreDirectivesRegistry } from '@singletons/directives-registry'; import { CoreDom } from '@singletons/dom'; +import { CoreEvents } from '@singletons/events'; import videojs from 'video.js'; import { VideoJSOptions } from '../../classes/videojs-ogvjs'; @@ -88,7 +89,7 @@ export class AddonFilterMediaPluginHandlerService extends CoreFilterDefaultHandl const dataSetupString = mediaElement.getAttribute('data-setup') || mediaElement.getAttribute('data-setup-lazy') || '{}'; const data = CoreTextUtils.parseJSON(dataSetupString, {}); - videojs(mediaElement, { + const player = videojs(mediaElement, { controls: true, techOrder: ['OgvJS'], language: lang, @@ -98,6 +99,13 @@ export class AddonFilterMediaPluginHandlerService extends CoreFilterDefaultHandl }, aspectRatio: data.aspectRatio, }); + + CoreEvents.trigger(CoreEvents.JS_PLAYER_CREATED, { + id: mediaElement.id, + element: mediaElement, + player, + }); + } /** diff --git a/src/core/classes/element-controllers/ElementController.ts b/src/core/classes/element-controllers/ElementController.ts index e4ef1b000..d7e889e58 100644 --- a/src/core/classes/element-controllers/ElementController.ts +++ b/src/core/classes/element-controllers/ElementController.ts @@ -18,6 +18,7 @@ export abstract class ElementController { protected enabled: boolean; + protected destroyed = false; constructor(enabled: boolean) { this.enabled = enabled; @@ -49,6 +50,19 @@ export abstract class ElementController { this.onDisabled(); } + /** + * Destroy the element. + */ + destroy(): void { + if (this.destroyed) { + return; + } + + this.destroyed = true; + + this.onDestroy(); + } + /** * Update underlying element to enable interactivity. */ @@ -59,4 +73,11 @@ export abstract class ElementController { */ abstract onDisabled(): void; + /** + * Destroy/dispose pertinent data. + */ + onDestroy(): void { + // By default, nothing to destroy. + } + } diff --git a/src/core/classes/element-controllers/MediaElementController.ts b/src/core/classes/element-controllers/MediaElementController.ts index 0ae911fc0..1f8b31648 100644 --- a/src/core/classes/element-controllers/MediaElementController.ts +++ b/src/core/classes/element-controllers/MediaElementController.ts @@ -14,6 +14,10 @@ import { CoreUtils } from '@services/utils/utils'; import { ElementController } from './ElementController'; +import videojs from 'video.js'; +import { CoreDom } from '@singletons/dom'; +import { CorePromisedValue } from '@classes/promised-value'; +import { CoreEventObserver, CoreEvents } from '@singletons/events'; /** * Wrapper class to control the interactivity of a media element. @@ -25,6 +29,10 @@ export class MediaElementController extends ElementController { private playing?: boolean; private playListener?: () => void; private pauseListener?: () => void; + private jsPlayer = new CorePromisedValue(); + private jsPlayerListener?: CoreEventObserver; + private shouldEnable = false; + private shouldDisable = false; constructor(media: HTMLMediaElement, enabled: boolean) { super(enabled); @@ -34,48 +42,127 @@ export class MediaElementController extends ElementController { media.autoplay = false; + if (CoreDom.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 as VideoJSPlayer); + } + }); + } + } else { + this.jsPlayer.resolve(null); + } + enabled && this.onEnabled(); } /** * @inheritdoc */ - onEnabled(): void { + async onEnabled(): Promise { + this.shouldEnable = true; + this.shouldDisable = false; + + const jsPlayer = await this.jsPlayer; + + if (!this.shouldEnable || this.destroyed) { + return; + } + const ready = this.playing ?? this.autoplay - ? this.media.play() + ? (jsPlayer ?? this.media).play() : Promise.resolve(); - ready - .then(() => this.addPlaybackEventListeners()) - .catch(error => CoreUtils.logUnhandledError('Error enabling media element', error)); + try { + await ready; + + this.addPlaybackEventListeners(jsPlayer); + } catch (error) { + CoreUtils.logUnhandledError('Error enabling media element', error); + } } /** * @inheritdoc */ async onDisabled(): Promise { - this.removePlaybackEventListeners(); + this.shouldDisable = true; + this.shouldEnable = false; - this.media.pause(); + const jsPlayer = await this.jsPlayer; + + if (!this.shouldDisable || this.destroyed) { + return; + } + + this.removePlaybackEventListeners(jsPlayer); + + (jsPlayer ?? this.media).pause(); + } + + /** + * @inheritdoc + */ + async onDestroy(): Promise { + const jsPlayer = await this.jsPlayer; + + this.removePlaybackEventListeners(jsPlayer); + jsPlayer?.dispose(); } /** * Start listening playback events. + * + * @param jsPlayer Javascript player instance (if any). */ - private addPlaybackEventListeners(): void { - this.media.addEventListener('play', this.playListener = () => this.playing = true); - this.media.addEventListener('pause', this.pauseListener = () => this.playing = false); + private addPlaybackEventListeners(jsPlayer: VideoJSPlayer | null): void { + if (jsPlayer) { + jsPlayer.on('play', this.playListener = () => this.playing = true); + jsPlayer.on('pause', this.pauseListener = () => this.playing = false); + } else { + this.media.addEventListener('play', this.playListener = () => this.playing = true); + this.media.addEventListener('pause', this.pauseListener = () => this.playing = false); + } } /** * Stop listening playback events. + * + * @param jsPlayer Javascript player instance (if any). */ - private removePlaybackEventListeners(): void { - this.playListener && this.media.removeEventListener('play', this.playListener); - this.pauseListener && this.media.removeEventListener('pause', this.pauseListener); + private removePlaybackEventListeners(jsPlayer: VideoJSPlayer | null): void { + if (jsPlayer) { + this.playListener && jsPlayer.off('play', this.playListener); + this.pauseListener && jsPlayer.off('pause', this.pauseListener); + } else { + this.playListener && this.media.removeEventListener('play', this.playListener); + this.pauseListener && this.media.removeEventListener('pause', this.pauseListener); + } delete this.playListener; delete this.pauseListener; } + /** + * Search JS player instance. + * + * @returns Player instance if found. + */ + private searchJSPlayer(): VideoJSPlayer | undefined { + return videojs.getPlayer(this.media.id) || videojs.getPlayer(this.media.id.replace('_html5_api', '')); + } + } + +type VideoJSPlayer = { + play: () => Promise; + pause: () => Promise; + on: (name: string, callback: (ev: Event) => void) => void; + off: (name: string, callback: (ev: Event) => void) => void; + dispose: () => void; +}; diff --git a/src/core/directives/format-text.ts b/src/core/directives/format-text.ts index 4b6be6171..b747497e6 100644 --- a/src/core/directives/format-text.ts +++ b/src/core/directives/format-text.ts @@ -149,6 +149,7 @@ export class CoreFormatTextDirective implements OnChanges, OnDestroy, AsyncDirec ngOnDestroy(): void { this.domElementPromise?.cancel(); this.domPromises.forEach((promise) => { promise.cancel();}); + this.elementControllers.forEach(controller => controller.destroy()); } /** @@ -365,6 +366,7 @@ export class CoreFormatTextDirective implements OnChanges, OnDestroy, AsyncDirec // Move the children to the current element to be able to calculate the height. CoreDomUtils.moveChildren(result.div, this.element); + this.elementControllers.forEach(controller => controller.destroy()); this.elementControllers = result.elementControllers; await CoreUtils.nextTick(); diff --git a/src/core/singletons/events.ts b/src/core/singletons/events.ts index 4f33ad1fa..34d949097 100644 --- a/src/core/singletons/events.ts +++ b/src/core/singletons/events.ts @@ -64,6 +64,7 @@ export interface CoreEventsData { [CoreEvents.ORIENTATION_CHANGE]: CoreEventOrientationData; [CoreEvents.COURSE_MODULE_VIEWED]: CoreEventCourseModuleViewed; [CoreEvents.COMPLETE_REQUIRED_PROFILE_DATA_FINISHED]: CoreEventCompleteRequiredProfileDataFinished; + [CoreEvents.JS_PLAYER_CREATED]: CoreEventJSVideoPlayerCreated; } /* @@ -123,6 +124,7 @@ 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 } = {}; @@ -490,3 +492,12 @@ export type CoreEventCourseModuleViewed = { export type CoreEventCompleteRequiredProfileDataFinished = { path: string; }; + +/** + * Data passed to JS_PLAYER_CREATED event. + */ +export type CoreEventJSVideoPlayerCreated = { + id: string; + element: HTMLAudioElement | HTMLVideoElement; + player: unknown; +};