From 9419db02a16a55e675f36a3843db58c558e8d19f Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Fri, 27 Jan 2023 12:33:40 +0100 Subject: [PATCH] MOBILE-4166 core: Use VideoJS in iOS for unsupported media --- scripts/copy-assets.js | 2 + .../mediaplugin/classes/videojs-ogvjs.ts | 762 ++++++++++++++++++ .../filter/mediaplugin/mediaplugin.module.ts | 7 +- .../services/handlers/mediaplugin.ts | 62 +- src/core/directives/external-content.ts | 31 +- src/core/services/app.ts | 18 + src/core/services/utils/mimetype.ts | 10 +- src/core/services/utils/url.ts | 4 +- src/core/singletons/dom.ts | 78 ++ src/index.html | 1 + src/theme/theme.base.scss | 60 ++ 11 files changed, 1012 insertions(+), 23 deletions(-) create mode 100644 src/addons/filter/mediaplugin/classes/videojs-ogvjs.ts diff --git a/scripts/copy-assets.js b/scripts/copy-assets.js index 3b3f03ea4..03fec6038 100644 --- a/scripts/copy-assets.js +++ b/scripts/copy-assets.js @@ -28,6 +28,8 @@ const ASSETS = { '/node_modules/mathjax/jax/output/PreviewHTML': '/lib/mathjax/jax/output/PreviewHTML', '/node_modules/mathjax/localization': '/lib/mathjax/localization', '/src/core/features/h5p/assets': '/lib/h5p', + '/node_modules/ogv/dist': '/lib/ogv', + '/node_modules/video.js/dist/video-js.min.css': '/lib/video.js/video-js.min.css', }; module.exports = function(ctx) { diff --git a/src/addons/filter/mediaplugin/classes/videojs-ogvjs.ts b/src/addons/filter/mediaplugin/classes/videojs-ogvjs.ts new file mode 100644 index 000000000..19707463a --- /dev/null +++ b/src/addons/filter/mediaplugin/classes/videojs-ogvjs.ts @@ -0,0 +1,762 @@ +// (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 { CorePlatform } from '@services/platform'; +import { OGVPlayer, OGVCompat, OGVLoader } from 'ogv'; +import videojs from 'video.js'; + +export const Tech = videojs.getComponent('Tech'); + +/** + * Object.defineProperty but "lazy", which means that the value is only set after + * it retrieved the first time, rather than being set right away. + * + * @param obj The object to set the property on. + * @param key The key for the property to set. + * @param getValue The function used to get the value when it is needed. + * @param setter Whether a setter should be allowed or not. + * @returns Object. + */ +const defineLazyProperty = (obj: T, key: string, getValue: () => unknown, setter = true): T => { + const set = (value: unknown): void => { + Object.defineProperty(obj, key, { value, enumerable: true, writable: true }); + }; + + const options: PropertyDescriptor = { + configurable: true, + enumerable: true, + get() { + const value = getValue(); + + set(value); + + return value; + }, + }; + + if (setter) { + options.set = set; + } + + return Object.defineProperty(obj, key, options); +}; + +/** + * OgvJS Media Controller for VideoJS - Wrapper for ogv.js Media API. + * + * Code adapted from https://github.com/HuongNV13/videojs-ogvjs/blob/f9b12bd53018d967bb305f02725834a98f20f61f/src/plugin.js + * Modified in the following ways: + * - Adapted to Typescript. + * - Use our own functions to detect the platform instead of using getDeviceOS. + * - Add an initialize static function. + * - In the play function, reset the media if it already ended to fix problems with replaying media. + * - Allow full screen in iOS devices, and implement enterFullScreen and exitFullScreen to use a fake full screen. + */ +export class VideoJSOgvJS extends Tech { + + /** + * List of available events of the media player. + */ + // eslint-disable-next-line @typescript-eslint/naming-convention + static readonly Events = [ + 'loadstart', + 'suspend', + 'abort', + 'error', + 'emptied', + 'stalled', + 'loadedmetadata', + 'loadeddata', + 'canplay', + 'canplaythrough', + 'playing', + 'waiting', + 'seeking', + 'seeked', + 'ended', + 'durationchange', + 'timeupdate', + 'progress', + 'play', + 'pause', + 'ratechange', + 'resize', + 'volumechange', + ]; + + // Variables/functions defined in parent classes. + protected el_!: OGVPlayerEl; // eslint-disable-line @typescript-eslint/naming-convention + protected options_!: VideoJSOptions; // eslint-disable-line @typescript-eslint/naming-convention + protected currentSource_?: TechSourceObject; // eslint-disable-line @typescript-eslint/naming-convention + protected triggerReady!: () => void; + protected on!: (name: string, callback: (e?: Event) => void) => void; + + /** + * Create an instance of this Tech. + * + * @param options The key/value store of player options. + * @param ready Callback function to call when the `OgvJS` Tech is ready. + */ + constructor(options: VideoJSOptions, ready: () => void) { + super(options, ready); + + this.el_.src = options.src || options.source?.src || options.sources?.[0]?.src || this.el_.src; + VideoJSOgvJS.setIfAvailable(this.el_, 'autoplay', options.autoplay); + VideoJSOgvJS.setIfAvailable(this.el_, 'loop', options.loop); + VideoJSOgvJS.setIfAvailable(this.el_, 'poster', options.poster); + VideoJSOgvJS.setIfAvailable(this.el_, 'preload', options.preload); + + this.on('loadedmetadata', () => { + if (CoreApp.isIPhone()) { + // iPhoneOS add some inline styles to the canvas, we need to remove it. + const canvas = this.el_.getElementsByTagName('canvas')[0]; + + canvas.style.removeProperty('width'); + canvas.style.removeProperty('margin'); + } + + this.triggerReady(); + }); + } + + /** + * Set the value for the player is it has that property. + * + * @param el HTML player. + * @param name Name of the property. + * @param value Value to set. + */ + static setIfAvailable(el: HTMLElement, name: string, value: unknown): void { + // eslint-disable-next-line no-prototype-builtins + if (el.hasOwnProperty(name)) { + el[name] = value; + } + }; + + /** + * Check if browser/device is supported by Ogv.JS. + * + * @returns Whether it's supported. + */ + static isSupported(): boolean { + return OGVCompat.supported('OGVPlayer'); + }; + + /** + * Check if the tech can support the given type. + * + * @param type The mimetype to check. + * @returns 'probably', 'maybe', or '' (empty string). + */ + static canPlayType(type: string): string { + return (type.indexOf('/ogg') !== -1 || type.indexOf('/webm')) ? 'maybe' : ''; + }; + + /** + * Check if the tech can support the given source. + * + * @param srcObj The source object. + * @returns The options passed to the tech. + */ + static canPlaySource(srcObj: TechSourceObject): string { + return VideoJSOgvJS.canPlayType(srcObj.type); + }; + + /** + * Check if the volume can be changed in this browser/device. + * Volume cannot be changed in a lot of mobile devices. + * Specifically, it can't be changed from 1 on iOS. + * + * @returns True if volume can be controlled. + */ + static canControlVolume(): boolean { + if (CoreApp.isIPhone()) { + return false; + } + + const player = new OGVPlayer(); + + // eslint-disable-next-line no-prototype-builtins + return player.hasOwnProperty('volume'); + }; + + /** + * Check if the volume can be muted in this browser/device. + * + * @returns True if volume can be muted. + */ + static canMuteVolume(): boolean { + return true; + }; + + /** + * Check if the playback rate can be changed in this browser/device. + * + * @returns True if playback rate can be controlled. + */ + static canControlPlaybackRate(): boolean { + return true; + }; + + /** + * Check to see if native 'TextTracks' are supported by this browser/device. + * + * @returns True if native 'TextTracks' are supported. + */ + static supportsNativeTextTracks(): boolean { + return false; + }; + + /** + * Check if the fullscreen resize is supported by this browser/device. + * + * @returns True if the fullscreen resize is supported. + */ + static supportsFullscreenResize(): boolean { + return true; + }; + + /** + * Check if the progress events is supported by this browser/device. + * + * @returns True if the progress events is supported. + */ + static supportsProgressEvents(): boolean { + return true; + }; + + /** + * Check if the time update events is supported by this browser/device. + * + * @returns True if the time update events is supported. + */ + static supportsTimeupdateEvents(): boolean { + return true; + }; + + /** + * Create the 'OgvJS' Tech's DOM element. + * + * @returns The element that gets created. + */ + createEl(): OGVPlayerEl { + const options = this.options_; + + if (options.base) { + OGVLoader.base = options.base; + } else if (!OGVLoader.base) { + throw new Error('Please specify the base for the ogv.js library'); + } + + const el = new OGVPlayer(options); + + el.className += ' vjs-tech'; + options.tag = el; + + return el; + } + + /** + * Start playback. + */ + play(): void { + if (this.ended()) { + // Reset the player, otherwise the Replay button doesn't work. + this.el_.stop(); + } + + this.el_.play(); + } + + /** + * Get the current playback speed. + * + * @returns Playback speed. + */ + playbackRate(): number { + return this.el_.playbackRate || 1; + } + + /** + * Set the playback speed. + * + * @param val Speed for the player to play. + */ + setPlaybackRate(val: number): void { + // eslint-disable-next-line no-prototype-builtins + if (this.el_.hasOwnProperty('playbackRate')) { + this.el_.playbackRate = val; + } + } + + /** + * Returns a TimeRanges object that represents the ranges of the media resource that the user agent has played. + * + * @returns The range of points on the media timeline that has been reached through normal playback. + */ + played(): TimeRanges { + return this.el_.played; + } + + /** + * Pause playback. + */ + pause(): void { + this.el_.pause(); + } + + /** + * Is the player paused or not. + * + * @returns Whether is paused. + */ + paused(): boolean { + return this.el_.paused; + } + + /** + * Get current playing time. + * + * @returns Current time. + */ + currentTime(): number { + return this.el_.currentTime; + } + + /** + * Set current playing time. + * + * @param seconds Current time of audio/video. + */ + setCurrentTime(seconds: number): void { + try { + this.el_.currentTime = seconds; + } catch (e) { + videojs.log(e, 'Media is not ready. (Video.JS)'); + } + } + + /** + * Get media's duration. + * + * @returns Duration. + */ + duration(): number { + if (this.el_.duration && this.el_.duration !== Infinity) { + return this.el_.duration; + } + + return 0; + } + + /** + * Get a TimeRange object that represents the intersection + * of the time ranges for which the user agent has all + * relevant media. + * + * @returns Time ranges. + */ + buffered(): TimeRanges { + return this.el_.buffered; + } + + /** + * Get current volume level. + * + * @returns Volume. + */ + volume(): number { + // eslint-disable-next-line no-prototype-builtins + return this.el_.hasOwnProperty('volume') ? this.el_.volume : 1; + } + + /** + * Set current playing volume level. + * + * @param percentAsDecimal Volume percent as a decimal. + */ + setVolume(percentAsDecimal: number): void { + // eslint-disable-next-line no-prototype-builtins + if (!CoreApp.isIPhone() && this.el_.hasOwnProperty('volume')) { + this.el_.volume = percentAsDecimal; + } + } + + /** + * Is the player muted or not. + * + * @returns Whether it's muted. + */ + muted(): boolean { + return this.el_.muted; + } + + /** + * Mute the player. + * + * @param muted True to mute the player. + */ + setMuted(muted: boolean): void { + this.el_.muted = !!muted; + } + + /** + * Is the player muted by default or not. + * + * @returns Whether it's muted by default. + */ + defaultMuted(): boolean { + return this.el_.defaultMuted || false; + } + + /** + * Get the player width. + * + * @returns Width. + */ + width(): number { + return this.el_.offsetWidth; + } + + /** + * Get the player height. + * + * @returns Height. + */ + height(): number { + return this.el_.offsetHeight; + } + + /** + * Get the video width. + * + * @returns Video width. + */ + videoWidth(): number { + return ( this.el_).videoWidth ?? 0; + } + + /** + * Get the video height. + * + * @returns Video heigth. + */ + videoHeight(): number { + return ( this.el_).videoHeight ?? 0; + } + + /** + * Get/set media source. + * + * @param src Source. + * @returns Source when getting it, undefined when setting it. + */ + src(src?: string): string | undefined { + if (typeof src === 'undefined') { + return this.el_.src; + } + + this.el_.src = src; + } + + /** + * Load the media into the player. + */ + load(): void { + this.el_.load(); + } + + /** + * Get current media source. + * + * @returns Current source. + */ + currentSrc(): string { + if (this.currentSource_) { + return this.currentSource_.src; + } + + return this.el_.currentSrc; + } + + /** + * Get media poster URL. + * + * @returns Poster. + */ + poster(): string { + return 'poster' in this.el_ ? this.el_.poster : ''; + } + + /** + * Set media poster URL. + * + * @param url The poster image's url. + */ + setPoster(url: string): void { + ( this.el_).poster = url; + } + + /** + * Is the media preloaded or not. + * + * @returns Whether it's preloaded. + */ + preload(): PreloadOption { + return this.el_.preload || 'none'; + } + + /** + * Set the media preload method. + * + * @param val Value for preload attribute. + */ + setPreload(val: PreloadOption): void { + // eslint-disable-next-line no-prototype-builtins + if (this.el_.hasOwnProperty('preload')) { + this.el_.preload = val; + } + } + + /** + * Is the media auto-played or not. + * + * @returns Whether it's auto-played. + */ + autoplay(): boolean { + return this.el_.autoplay || false; + } + + /** + * Set media autoplay method. + * + * @param val Value for autoplay attribute. + */ + setAutoplay(val: boolean): void { + // eslint-disable-next-line no-prototype-builtins + if (this.el_.hasOwnProperty('autoplay')) { + this.el_.autoplay = !!val; + } + } + + /** + * Does the media has controls or not. + * + * @returns Whether it has controls. + */ + controls(): boolean { + return this.el_.controls || false; + } + + /** + * Set the media controls method. + * + * @param val Value for controls attribute. + */ + setControls(val: boolean): void { + // eslint-disable-next-line no-prototype-builtins + if (this.el_.hasOwnProperty('controls')) { + this.el_.controls = !!val; + } + } + + /** + * Is the media looped or not. + * + * @returns Whether it's looped. + */ + loop(): boolean { + return this.el_.loop || false; + } + + /** + * Set the media loop method. + * + * @param val Value for loop attribute. + */ + setLoop(val: boolean): void { + // eslint-disable-next-line no-prototype-builtins + if (this.el_.hasOwnProperty('loop')) { + this.el_.loop = !!val; + } + } + + /** + * Get a TimeRanges object that represents the + * ranges of the media resource to which it is possible + * for the user agent to seek. + * + * @returns Time ranges. + */ + seekable(): TimeRanges { + return this.el_.seekable; + } + + /** + * Is player in the "seeking" state or not. + * + * @returns Whether is in the seeking state. + */ + seeking(): boolean { + return this.el_.seeking; + } + + /** + * Is the media ended or not. + * + * @returns Whether it's ended. + */ + ended(): boolean { + return this.el_.ended; + } + + /** + * Get the current state of network activity + * NETWORK_EMPTY (numeric value 0) + * NETWORK_IDLE (numeric value 1) + * NETWORK_LOADING (numeric value 2) + * NETWORK_NO_SOURCE (numeric value 3) + * + * @returns Network state. + */ + networkState(): number { + return this.el_.networkState; + } + + /** + * Get the current state of the player. + * HAVE_NOTHING (numeric value 0) + * HAVE_METADATA (numeric value 1) + * HAVE_CURRENT_DATA (numeric value 2) + * HAVE_FUTURE_DATA (numeric value 3) + * HAVE_ENOUGH_DATA (numeric value 4) + * + * @returns Ready state. + */ + readyState(): number { + return this.el_.readyState; + } + + /** + * Does the player support native fullscreen mode or not. (Mobile devices) + * + * @returns Whether it supports full screen. + */ + supportsFullScreen(): boolean { + // iOS devices have some problem with HTML5 fullscreen api so we need to fallback to fullWindow mode. + return !CoreApp.isIOS(); + } + + /** + * Get media player error. + * + * @returns Error. + */ + error(): MediaError | null { + return this.el_.error; + } + +} + +[ + ['featuresVolumeControl', 'canControlVolume'], + ['featuresMuteControl', 'canMuteVolume'], + ['featuresPlaybackRate', 'canControlPlaybackRate'], + ['featuresNativeTextTracks', 'supportsNativeTextTracks'], + ['featuresFullscreenResize', 'supportsFullscreenResize'], + ['featuresProgressEvents', 'supportsProgressEvents'], + ['featuresTimeupdateEvents', 'supportsTimeupdateEvents'], +].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); +}; + +export type VideoJSOptions = { + aspectRatio?: string; + audioOnlyMode?: boolean; + audioPosterMode?: boolean; + autoplay?: boolean | string; + autoSetup?: boolean; + base?: string; + breakpoints?: Record; + children?: string[] | Record>; + controlBar?: { + remainingTimeDisplay?: { + displayNegative?: boolean; + }; + }; + controls?: boolean; + fluid?: boolean; + fullscreen?: { + options?: Record; + }; + height?: string | number; + id?: string; + inactivityTimeout?: number; + language?: string; + languages?: Record>; + liveui?: boolean; + liveTracker?: { + trackingThreshold?: number; + liveTolerance?: number; + }; + loop?: boolean; + muted?: boolean; + nativeControlsForTouch?: boolean; + normalizeAutoplay?: boolean; + notSupportedMessage?: string; + noUITitleAttributes?: boolean; + playbackRates?: number[]; + plugins?: Record>; + poster?: string; + preferFullWindow?: boolean; + preload?: PreloadOption; + responsive?: boolean; + restoreEl?: boolean | HTMLElement; + source?: TechSourceObject; + sources?: TechSourceObject[]; + src?: string; + suppressNotSupportedError?: boolean; + tag?: HTMLElement; + techCanOverridePoster?: boolean; + techOrder?: string[]; + userActions?: { + click?: boolean | ((ev: MouseEvent) => void); + doubleClick?: boolean | ((ev: MouseEvent) => void); + hotkeys?: boolean | ((ev: KeyboardEvent) => void) | { + fullscreenKey?: (ev: KeyboardEvent) => void; + muteKey?: (ev: KeyboardEvent) => void; + playPauseKey?: (ev: KeyboardEvent) => void; + }; + }; + 'vtt.js'?: string; + width?: string | number; +}; + +type TechSourceObject = { + src: string; // Source URL. + type: string; // Mimetype. +}; + +type PreloadOption = '' | 'none' | 'metadata' | 'auto'; + +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 1977bd08d..821b4db73 100644 --- a/src/addons/filter/mediaplugin/mediaplugin.module.ts +++ b/src/addons/filter/mediaplugin/mediaplugin.module.ts @@ -15,6 +15,7 @@ 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({ @@ -26,7 +27,11 @@ import { AddonFilterMediaPluginHandler } from './services/handlers/mediaplugin'; { provide: APP_INITIALIZER, multi: true, - useValue: () => CoreFilterDelegate.registerHandler(AddonFilterMediaPluginHandler.instance), + 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 509451927..a54442278 100644 --- a/src/addons/filter/mediaplugin/services/handlers/mediaplugin.ts +++ b/src/addons/filter/mediaplugin/services/handlers/mediaplugin.ts @@ -13,11 +13,17 @@ // limitations under the License. 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 { CoreDom } from '@singletons/dom'; +import videojs from 'video.js'; +import { VideoJSOptions } from '../../classes/videojs-ogvjs'; /** * Handler to support the Multimedia filter. @@ -47,6 +53,53 @@ export class AddonFilterMediaPluginHandlerService extends CoreFilterDefaultHandl return this.template.innerHTML; } + /** + * @inheritdoc + */ + handleHtml(container: HTMLElement): void { + const mediaElements = Array.from(container.querySelectorAll('video, audio')); + + mediaElements.forEach((mediaElement) => { + if (CoreDom.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'); + } + }); + } + + /** + * 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, {}); + + videojs(mediaElement, { + controls: true, + techOrder: ['OgvJS'], + language: lang, + fluid: true, + controlBar: { + fullscreenToggle: false, + }, + aspectRatio: data.aspectRatio, + }); + } + /** * Treat video filters. Currently only treating youtube video using video JS. * @@ -59,7 +112,7 @@ export class AddonFilterMediaPluginHandlerService extends CoreFilterDefaultHandl } const dataSetupString = video.getAttribute('data-setup') || video.getAttribute('data-setup-lazy') || '{}'; - const data = CoreTextUtils.parseJSON(dataSetupString, {}); + const data = CoreTextUtils.parseJSON(dataSetupString, {}); const youtubeUrl = data.techOrder?.[0] == 'youtube' && CoreUrlUtils.getYoutubeEmbedUrl(data.sources?.[0]?.src); if (!youtubeUrl) { @@ -81,10 +134,3 @@ export class AddonFilterMediaPluginHandlerService extends CoreFilterDefaultHandl } export const AddonFilterMediaPluginHandler = makeSingleton(AddonFilterMediaPluginHandlerService); - -type VideoDataSetup = { - techOrder?: string[]; - sources?: { - src?: string; - }[]; -}; diff --git a/src/core/directives/external-content.ts b/src/core/directives/external-content.ts index 68f0323e4..dcf6977c1 100644 --- a/src/core/directives/external-content.ts +++ b/src/core/directives/external-content.ts @@ -36,6 +36,9 @@ import { CoreEventObserver, CoreEvents } from '@singletons/events'; import { CoreConstants } from '../constants'; import { CoreNetwork } from '@services/network'; import { Translate } from '@singletons'; +import { AsyncDirective } from '@classes/async-directive'; +import { CoreDirectivesRegistry } from '@singletons/directives-registry'; +import { CorePromisedValue } from '@classes/promised-value'; /** * Directive to handle external content. @@ -50,7 +53,7 @@ import { Translate } from '@singletons'; @Directive({ selector: '[core-external-content]', }) -export class CoreExternalContentDirective implements AfterViewInit, OnChanges, OnDestroy { +export class CoreExternalContentDirective implements AfterViewInit, OnChanges, OnDestroy, AsyncDirective { @Input() siteId?: string; // Site ID to use. @Input() component?: string; // Component to link the file to. @@ -67,11 +70,14 @@ export class CoreExternalContentDirective implements AfterViewInit, OnChanges, O protected logger: CoreLogger; protected initialized = false; protected fileEventObserver?: CoreEventObserver; + protected onReadyPromise = new CorePromisedValue(); constructor(element: ElementRef) { this.element = element.nativeElement; this.logger = CoreLogger.getInstance('CoreExternalContentDirective'); + + CoreDirectivesRegistry.register(this.element, this); } /** @@ -157,15 +163,21 @@ export class CoreExternalContentDirective implements AfterViewInit, OnChanges, O } else { this.invalid = true; + this.onReadyPromise.resolve(); return; } // Avoid handling data url's. if (url && url.indexOf('data:') === 0) { - this.invalid = true; + if (tagName === 'SOURCE') { + // Restoring original src. + this.addSource(url); + } + this.onLoad.emit(); this.loaded = true; + this.onReadyPromise.resolve(); return; } @@ -182,6 +194,8 @@ export class CoreExternalContentDirective implements AfterViewInit, OnChanges, O this.loaded = true; } } + } finally { + this.onReadyPromise.resolve(); } } @@ -266,13 +280,11 @@ export class CoreExternalContentDirective implements AfterViewInit, OnChanges, O return; } - let urls = inlineStyles.match(/https?:\/\/[^"') ;]*/g); - if (!urls || !urls.length) { + const urls = CoreUtils.uniqueArray(Array.from(inlineStyles.match(/https?:\/\/[^"') ;]*/g) ?? [])); + if (!urls.length) { return; } - urls = CoreUtils.uniqueArray(urls); // Remove duplicates. - const promises = urls.map(async (url) => { const finalUrl = await CoreFilepool.getSrcByUrl(siteId, url, this.component, this.componentId, 0, true, true); @@ -462,4 +474,11 @@ export class CoreExternalContentDirective implements AfterViewInit, OnChanges, O this.fileEventObserver?.off(); } + /** + * @inheritdoc + */ + async ready(): Promise { + return this.onReadyPromise; + } + } diff --git a/src/core/services/app.ts b/src/core/services/app.ts index fa6315372..31bc790fc 100644 --- a/src/core/services/app.ts +++ b/src/core/services/app.ts @@ -266,6 +266,24 @@ export class CoreAppProvider { return CorePlatform.isMobile() && !CorePlatform.is('android'); } + /** + * Checks if the app is running in an iPad device. + * + * @returns Whether the app is running in an iPad device. + */ + isIPad(): boolean { + return CoreApp.isIOS() && CorePlatform.is('ipad'); + } + + /** + * Checks if the app is running in an iPhone device. + * + * @returns Whether the app is running in an iPhone device. + */ + isIPhone(): boolean { + return CoreApp.isIOS() && CorePlatform.is('iphone'); + } + /** * Check if the keyboard is closing. * diff --git a/src/core/services/utils/mimetype.ts b/src/core/services/utils/mimetype.ts index bf9105362..a22e98750 100644 --- a/src/core/services/utils/mimetype.ts +++ b/src/core/services/utils/mimetype.ts @@ -25,6 +25,7 @@ import { CoreUtils } from '@services/utils/utils'; import extToMime from '@/assets/exttomime.json'; import mimeToExt from '@/assets/mimetoext.json'; import { CoreFileEntry, CoreFileHelper } from '@services/file-helper'; +import { CoreUrl } from '@singletons/url'; interface MimeTypeInfo { type: string; @@ -302,18 +303,13 @@ export class CoreMimetypeUtilsProvider { * @returns The lowercased extension without the dot, or undefined. */ guessExtensionFromUrl(fileUrl: string): string | undefined { - const split = fileUrl.split('.'); + const split = CoreUrl.removeUrlAnchor(fileUrl).split('.'); let extension: string | undefined; if (split.length > 1) { let candidate = split[split.length - 1].toLowerCase(); // Remove params if any. - let position = candidate.indexOf('?'); - if (position > -1) { - candidate = candidate.substring(0, position); - } - // Remove anchor if any. - position = candidate.indexOf('#'); + const position = candidate.indexOf('?'); if (position > -1) { candidate = candidate.substring(0, position); } diff --git a/src/core/services/utils/url.ts b/src/core/services/utils/url.ts index 71ab26903..aa8a0c9e4 100644 --- a/src/core/services/utils/url.ts +++ b/src/core/services/utils/url.ts @@ -22,6 +22,7 @@ import { CoreUrl } from '@singletons/url'; import { CoreSites } from '@services/sites'; import { CorePath } from '@singletons/path'; import { CorePlatform } from '@services/platform'; +import { CoreDom } from '@singletons/dom'; /* * "Utils" service with helper functions for URLs. @@ -120,7 +121,8 @@ export class CoreUrlUtilsProvider { // Also, only use it for "core" pluginfile endpoints. Some plugins can implement their own endpoint (like customcert). return !CoreConstants.CONFIG.disableTokenFile && !!accessKey && !url.match(/[&?]file=/) && ( url.indexOf(CorePath.concatenatePaths(siteUrl, 'pluginfile.php')) === 0 || - url.indexOf(CorePath.concatenatePaths(siteUrl, 'webservice/pluginfile.php')) === 0); + url.indexOf(CorePath.concatenatePaths(siteUrl, 'webservice/pluginfile.php')) === 0) && + !CoreDom.sourceUsesJavascriptPlayer({ src: url }); } /** diff --git a/src/core/singletons/dom.ts b/src/core/singletons/dom.ts index 6e6b3d8d8..08c408d32 100644 --- a/src/core/singletons/dom.ts +++ b/src/core/singletons/dom.ts @@ -13,7 +13,9 @@ // limitations under the License. import { CoreCancellablePromise } from '@classes/cancellable-promise'; +import { CoreApp } from '@services/app'; import { CoreDomUtils } from '@services/utils/dom'; +import { CoreMimetypeUtils } from '@services/utils/mimetype'; import { CoreUtils } from '@services/utils/utils'; import { CoreEventObserver } from '@singletons/events'; @@ -567,6 +569,74 @@ export class CoreDom { } } + /** + * Get all source URLs and types for a video or audio. + * + * @param mediaElement Audio or video element. + * @returns List of sources. + */ + static getMediaSources(mediaElement: HTMLVideoElement | HTMLAudioElement): CoreMediaSource[] { + const sources = Array.from(mediaElement.querySelectorAll('source')).map(source => ({ + src: source.src || source.getAttribute('target-src') || '', + type: source.type, + })); + + if (mediaElement.src) { + sources.push({ + src: mediaElement.src, + type: '', + }); + } + + return sources; + } + + /** + * Check if a source needs to be converted to be able to reproduce it. + * + * @param source Source. + * @returns Whether needs conversion. + */ + static sourceNeedsConversion(source: CoreMediaSource): boolean { + if (!CoreApp.isIOS()) { + return false; + } + + let extension = source.type ? CoreMimetypeUtils.getExtension(source.type) : undefined; + if (!extension) { + extension = CoreMimetypeUtils.guessExtensionFromUrl(source.src); + } + + return !!extension && ['ogv', 'webm', 'oga', 'ogg'].includes(extension); + } + + /** + * Check if JS player should be used for a certain source. + * + * @param source Source. + * @returns Whether JS player should be used. + */ + static sourceUsesJavascriptPlayer(source: CoreMediaSource): boolean { + // For now, only use JS player if the source needs to be converted. + return CoreDom.sourceNeedsConversion(source); + } + + /** + * Check if JS player should be used for a certain audio or video. + * + * @param mediaElement Media element. + * @returns Whether JS player should be used. + */ + static mediaUsesJavascriptPlayer(mediaElement: HTMLVideoElement | HTMLAudioElement): boolean { + if (!CoreApp.isIOS()) { + return false; + } + + const sources = CoreDom.getMediaSources(mediaElement); + + return sources.some(source => CoreDom.sourceUsesJavascriptPlayer(source)); + } + } /** @@ -585,3 +655,11 @@ export type CoreScrollOptions = { addYAxis?: number; addXAxis?: number; }; + +/** + * Source of a media element. + */ +export type CoreMediaSource = { + src: string; + type?: string; +}; diff --git a/src/index.html b/src/index.html index 864f4b89a..817533811 100644 --- a/src/index.html +++ b/src/index.html @@ -16,6 +16,7 @@ + diff --git a/src/theme/theme.base.scss b/src/theme/theme.base.scss index 26c7fbdd6..1283e6499 100644 --- a/src/theme/theme.base.scss +++ b/src/theme/theme.base.scss @@ -1809,3 +1809,63 @@ ion-modal.core-modal-no-background { display: none; } } + +/** + * VideoJS modifications. + * Styles extracted from the end of https://github.com/moodle/moodle/blob/master/media/player/videojs/styles.css + **/ + +/* Audio: Remove big play button (leave only the button in controls). */ +.video-js.vjs-audio .vjs-big-play-button { + display: none; +} +/* Audio: Make the controlbar visible by default */ +.video-js.vjs-audio .vjs-control-bar { + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; +} +/* Make player height minimum to the controls height so when we hide video/poster area the controls are displayed correctly. */ +.video-js.vjs-audio { + min-height: 3em; +} +/* In case of error reset height to the default (otherwise no aspect ratio is available and height becomes 0). */ +.video-js.vjs-error { + height: 150px !important; +} +/* Minimum height for videos should not be less than the size of play button. */ +.mediaplugin_videojs video { + min-height: 32px; +} + +/* MDL-61020: VideoJS timeline progress bar should not be flipped in RTL mode. */ + +/* Prevent the progress bar from being flipped in RTL. */ +/*rtl:ignore*/ +.video-js .vjs-progress-holder .vjs-play-progress, +.video-js .vjs-progress-holder .vjs-load-progress, +.video-js .vjs-progress-holder .vjs-load-progress div { + left: 0; + right: auto; +} +/* Keep the video scrubber button at the end of the progress bar in RTL. */ +/*rtl:ignore*/ +.video-js .vjs-play-progress:before { + left: auto; + right: -0.5em; +} +/* Prevent the volume slider from being flipped in RTL. */ +/*rtl:ignore*/ +.video-js .vjs-volume-level { + left: 0; + right: auto; +} +/* Keep the volume slider handle at the end of the volume slider in RTL. */ +/*rtl:ignore*/ +.vjs-slider-horizontal .vjs-volume-level:before { + left: auto; + right: -0.5em; +} + +/** Finish VideoJS modifications. **/