Vmeda.Online/src/core/directives/external-content.ts

483 lines
17 KiB
TypeScript

// (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 {
Directive,
Input,
AfterViewInit,
ElementRef,
OnChanges,
SimpleChange,
Output,
EventEmitter,
OnDestroy,
} from '@angular/core';
import { CoreFile } from '@services/file';
import { CoreFilepool, CoreFilepoolFileActions, CoreFilepoolFileEventData } from '@services/filepool';
import { CoreSites } from '@services/sites';
import { CoreUrlUtils } from '@services/utils/url';
import { CoreUtils } from '@services/utils/utils';
import { CoreLogger } from '@singletons/logger';
import { CoreError } from '@classes/errors/error';
import { CoreSite } from '@classes/sites/site';
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';
import { CorePlatform } from '@services/platform';
/**
* Directive to handle external content.
*
* This directive should be used with any element that links to external content
* which we want to have available when the app is offline. Typically media and links.
*
* If a file is downloaded, its URL will be replaced by the local file URL.
*
* This directive also downloads inline styles, so it can be used in any element as long as it has inline styles.
*/
@Directive({
selector: '[core-external-content]',
})
export class CoreExternalContentDirective implements AfterViewInit, OnChanges, OnDestroy, AsyncDirective {
@Input() siteId?: string; // Site ID to use.
@Input() component?: string; // Component to link the file to.
@Input() componentId?: string | number; // Component ID to use in conjunction with the component.
@Input() src?: string;
@Input() href?: string;
@Input('target-src') targetSrc?: string; // eslint-disable-line @angular-eslint/no-input-rename
@Input() poster?: string;
@Output() onLoad = new EventEmitter(); // Emitted when content is loaded. Only for images.
loaded = false;
invalid = false;
protected element: Element;
protected logger: CoreLogger;
protected initialized = false;
protected fileEventObserver?: CoreEventObserver;
protected onReadyPromise = new CorePromisedValue<void>();
constructor(element: ElementRef) {
this.element = element.nativeElement;
this.logger = CoreLogger.getInstance('CoreExternalContentDirective');
CoreDirectivesRegistry.register(this.element, this);
}
/**
* @inheritdoc
*/
ngAfterViewInit(): void {
this.checkAndHandleExternalContent();
this.initialized = true;
}
/**
* @inheritdoc
*/
ngOnChanges(changes: { [name: string]: SimpleChange }): void {
if (changes && this.initialized) {
// If any of the inputs changes, handle the content again.
this.checkAndHandleExternalContent();
}
}
/**
* Add a new source with a certain URL as a sibling of the current element.
*
* @param url URL to use in the source.
*/
protected addSource(url: string): void {
if (this.element.tagName !== 'SOURCE') {
return;
}
const newSource = document.createElement('source');
const type = this.element.getAttribute('type');
newSource.setAttribute('src', url);
if (type) {
if (CorePlatform.isAndroid() && type == 'video/quicktime') {
// Fix for VideoJS/Chrome bug https://github.com/videojs/video.js/issues/423 .
newSource.setAttribute('type', 'video/mp4');
} else {
newSource.setAttribute('type', type);
}
}
this.element.parentNode?.insertBefore(newSource, this.element);
}
/**
* Get the URL that should be handled and, if valid, handle it.
*/
protected async checkAndHandleExternalContent(): Promise<void> {
const siteId = this.siteId || CoreSites.getRequiredCurrentSite().getId();
const tagName = this.element.tagName.toUpperCase();
let targetAttr: string;
let url: string;
// Always handle inline styles (if any).
this.handleInlineStyles(siteId);
if (tagName === 'A' || tagName == 'IMAGE') {
targetAttr = 'href';
url = this.href ?? '';
} else if (tagName === 'IMG') {
targetAttr = 'src';
url = this.src ?? '';
} else if (tagName === 'AUDIO' || tagName === 'VIDEO' || tagName === 'SOURCE' || tagName === 'TRACK') {
targetAttr = 'src';
url = (this.targetSrc || this.src) ?? '';
if (tagName === 'VIDEO') {
if (this.poster) {
// Handle poster.
this.handleExternalContent('poster', this.poster, siteId).catch(() => {
// Ignore errors.
});
}
}
} else {
this.invalid = true;
this.onReadyPromise.resolve();
return;
}
// Avoid handling data url's.
if (url && url.indexOf('data:') === 0) {
if (tagName === 'SOURCE') {
// Restoring original src.
this.addSource(url);
}
this.onLoad.emit();
this.loaded = true;
this.onReadyPromise.resolve();
return;
}
try {
await this.handleExternalContent(targetAttr, url, siteId);
} catch (error) {
// Error handling content. Make sure the loaded event is triggered for images.
if (tagName === 'IMG') {
if (url) {
this.waitForLoad();
} else {
this.onLoad.emit();
this.loaded = true;
}
}
} finally {
this.onReadyPromise.resolve();
}
}
/**
* Handle external content, setting the right URL.
*
* @param targetAttr Attribute to modify.
* @param url Original URL to treat.
* @param siteId Site ID.
* @returns Promise resolved if the element is successfully treated.
*/
protected async handleExternalContent(targetAttr: string, url: string, siteId?: string): Promise<void> {
const tagName = this.element.tagName;
if (tagName == 'VIDEO' && targetAttr != 'poster') {
this.handleVideoSubtitles(<HTMLVideoElement> this.element);
}
const site = await CoreSites.getSite(siteId);
const isSiteFile = site.isSitePluginFileUrl(url);
if (!url || !url.match(/^https?:\/\//i) || CoreUrlUtils.isLocalFileUrl(url) ||
(tagName === 'A' && !(isSiteFile || site.isSiteThemeImageUrl(url) || CoreUrlUtils.isGravatarUrl(url)))) {
this.logger.debug('Ignoring non-downloadable URL: ' + url);
if (tagName === 'SOURCE') {
// Restoring original src.
this.addSource(url);
} else if (url && !this.element.getAttribute(targetAttr)) {
// By default, Angular inputs aren't added as DOM attributes. Add it now.
this.element.setAttribute(targetAttr, url);
}
throw new CoreError('Non-downloadable URL');
}
if (!site.canDownloadFiles() && isSiteFile) {
this.element.parentElement?.removeChild(this.element); // Remove element since it'll be broken.
throw new CoreError(Translate.instant('core.cannotdownloadfiles'));
}
const finalUrl = await this.getUrlToUse(targetAttr, url, site);
this.logger.debug('Using URL ' + finalUrl + ' for ' + url);
if (tagName === 'SOURCE') {
// The browser does not catch changes in SRC, we need to add a new source.
this.addSource(finalUrl);
} else {
if (tagName === 'IMG') {
this.loaded = false;
this.waitForLoad();
}
if (targetAttr == 'poster') {
// Setting the poster immediately doesn't display it in some cases. Set it to empty and then set the right one.
this.element.setAttribute(targetAttr, '');
await CoreUtils.nextTick();
}
this.element.setAttribute(targetAttr, finalUrl);
this.element.setAttribute('data-original-' + targetAttr, url);
}
this.setListeners(targetAttr, url, site);
}
/**
* Handle inline styles, trying to download referenced files.
*
* @param siteId Site ID.
* @returns Promise resolved when done.
*/
protected async handleInlineStyles(siteId?: string): Promise<void> {
if (!siteId) {
return;
}
let inlineStyles = this.element.getAttribute('style') || '';
if (!inlineStyles) {
return;
}
const urls = CoreUtils.uniqueArray(Array.from(inlineStyles.match(/https?:\/\/[^"') ;]*/g) ?? []));
if (!urls.length) {
return;
}
const promises = urls.map(async (url) => {
const finalUrl = await CoreFilepool.getSrcByUrl(siteId, url, this.component, this.componentId, 0, true, true);
this.logger.debug('Using URL ' + finalUrl + ' for ' + url + ' in inline styles');
inlineStyles = inlineStyles.replace(new RegExp(url, 'gi'), finalUrl);
});
try {
await CoreUtils.allPromises(promises);
this.element.setAttribute('style', inlineStyles);
} catch (error) {
this.logger.error('Error treating inline styles.', this.element);
}
}
/**
* Handle video subtitles if any.
*
* @param video Video element.
*/
protected handleVideoSubtitles(video: HTMLVideoElement): void {
if (!video.textTracks) {
return;
}
// It's a video with subtitles. Fix some issues with subtitles.
video.textTracks.onaddtrack = (event): void => {
const track = <TextTrack> event.track;
if (track) {
track.oncuechange = (): void => {
if (!track.cues) {
return;
}
// Position all subtitles to a percentage of video height.
Array.from(track.cues).forEach((cue: VTTCue) => {
cue.snapToLines = false;
cue.line = 90;
cue.size = 100; // This solves some Android issue.
});
// Delete listener.
track.oncuechange = null;
};
}
};
}
/**
* Get the URL to use in the element. E.g. if the file is already downloaded it will return the local URL.
*
* @param targetAttr Attribute to modify.
* @param url Original URL to treat.
* @param site Site.
* @returns Promise resolved with the URL.
*/
protected async getUrlToUse(targetAttr: string, url: string, site: CoreSite): Promise<string> {
const tagName = this.element.tagName;
let finalUrl: string;
// Download images, tracks and posters if size is unknown.
const downloadUnknown = tagName == 'IMG' || tagName == 'TRACK' || targetAttr == 'poster';
if (targetAttr === 'src' && tagName !== 'SOURCE' && tagName !== 'TRACK' && tagName !== 'VIDEO' && tagName !== 'AUDIO') {
finalUrl = await CoreFilepool.getSrcByUrl(
site.getId(),
url,
this.component,
this.componentId,
0,
true,
downloadUnknown,
);
} else if (tagName === 'TRACK') {
// Download tracks right away. Using an online URL for tracks can give a CORS error in Android.
finalUrl = await CoreFilepool.downloadUrl(site.getId(), url, false, this.component, this.componentId);
finalUrl = CoreFile.convertFileSrc(finalUrl);
} else {
finalUrl = await CoreFilepool.getUrlByUrl(
site.getId(),
url,
this.component,
this.componentId,
0,
true,
downloadUnknown,
);
finalUrl = CoreFile.convertFileSrc(finalUrl);
}
if (!CoreUrlUtils.isLocalFileUrl(finalUrl) && !finalUrl.includes('#') && tagName !== 'A') {
/* In iOS, if we use the same URL in embedded file and background download then the download only
downloads a few bytes (cached ones). Add an anchor to the URL so both URLs are different.
Don't add this anchor if the URL already has an anchor, otherwise other anchors might not work.
The downloaded URL won't have anchors so the URLs will already be different. */
finalUrl = finalUrl + '#moodlemobile-embedded';
}
return finalUrl;
}
/**
* Set listeners if needed.
*
* @param targetAttr Attribute to modify.
* @param url Original URL to treat.
* @param site Site.
* @returns Promise resolved when done.
*/
protected async setListeners(targetAttr: string, url: string, site: CoreSite): Promise<void> {
if (this.fileEventObserver) {
// Already listening to events.
return;
}
const tagName = this.element.tagName;
let state = await CoreFilepool.getFileStateByUrl(site.getId(), url);
// Listen for download changes in the file.
const eventName = await CoreFilepool.getFileEventNameByUrl(site.getId(), url);
this.fileEventObserver = CoreEvents.on(eventName, async (data: CoreFilepoolFileEventData) => {
if (data.action === CoreFilepoolFileActions.DOWNLOAD && !data.success) {
// Error downloading the file. Don't try again.
return;
}
const newState = await CoreFilepool.getFileStateByUrl(site.getId(), url);
if (newState === state) {
return;
}
state = newState;
if (state === CoreConstants.DOWNLOADING) {
return;
}
// The file state has changed. Handle the file again, maybe it's downloaded now or the file has been deleted.
this.checkAndHandleExternalContent();
});
// Set events to download big files (not downloaded automatically).
if (targetAttr !== 'poster' && (tagName === 'VIDEO' || tagName === 'AUDIO' || tagName === 'A' || tagName === 'SOURCE')) {
const eventName = tagName == 'A' ? 'click' : 'play';
let clickableEl: Element | null = this.element;
if (tagName == 'SOURCE') {
clickableEl = this.element.closest('video,audio');
}
if (!clickableEl) {
return;
}
clickableEl.addEventListener(eventName, () => {
// User played media or opened a downloadable link.
// Download the file if in wifi and it hasn't been downloaded already (for big files).
if (state !== CoreConstants.DOWNLOADED && state !== CoreConstants.DOWNLOADING && CoreNetwork.isWifi()) {
// We aren't using the result, so it doesn't matter which of the 2 functions we call.
CoreFilepool.getUrlByUrl(site.getId(), url, this.component, this.componentId, 0, false);
}
});
}
}
/**
* Wait for the image to be loaded or error, and emit an event when it happens.
*/
protected waitForLoad(): void {
const listener = (): void => {
this.element.removeEventListener('load', listener);
this.element.removeEventListener('error', listener);
this.onLoad.emit();
this.loaded = true;
};
this.element.addEventListener('load', listener);
this.element.addEventListener('error', listener);
}
/**
* @inheritdoc
*/
ngOnDestroy(): void {
this.fileEventObserver?.off();
}
/**
* @inheritdoc
*/
async ready(): Promise<void> {
return this.onReadyPromise;
}
}