MOBILE-3666 h5p: Implement H5P components

main
Dani Palou 2020-12-16 16:04:43 +01:00
parent 0ead40c72e
commit 7956d8e563
10 changed files with 589 additions and 21 deletions

View File

@ -17,7 +17,7 @@ import { Injectable, ViewContainerRef, ComponentFactoryResolver } from '@angular
import { CoreFilterDefaultHandler } from '@features/filter/services/handlers/default-filter';
import { CoreFilterFilter, CoreFilterFormatTextOptions } from '@features/filter/services/filter';
import { makeSingleton } from '@singletons';
// @todo import { CoreH5PPlayerComponent } from '@core/h5p/components/h5p-player/h5p-player';
import { CoreH5PPlayerComponent } from '@features/h5p/components/h5p-player/h5p-player';
/**
* Handler to support the Display H5P filter.
@ -80,32 +80,31 @@ export class AddonFilterDisplayH5PHandlerService extends CoreFilterDefaultHandle
* @return If async, promise resolved when done.
*/
handleHtml(
container: HTMLElement, // eslint-disable-line @typescript-eslint/no-unused-vars
filter: CoreFilterFilter, // eslint-disable-line @typescript-eslint/no-unused-vars
options: CoreFilterFormatTextOptions, // eslint-disable-line @typescript-eslint/no-unused-vars
viewContainerRef: ViewContainerRef, // eslint-disable-line @typescript-eslint/no-unused-vars
component?: string, // eslint-disable-line @typescript-eslint/no-unused-vars
componentId?: string | number, // eslint-disable-line @typescript-eslint/no-unused-vars
container: HTMLElement,
filter: CoreFilterFilter,
options: CoreFilterFormatTextOptions,
viewContainerRef: ViewContainerRef,
component?: string,
componentId?: string | number,
siteId?: string, // eslint-disable-line @typescript-eslint/no-unused-vars
): void | Promise<void> {
// @todo
// const placeholders = <HTMLElement[]> Array.from(container.querySelectorAll('div.core-h5p-tmp-placeholder'));
const placeholders = <HTMLElement[]> Array.from(container.querySelectorAll('div.core-h5p-tmp-placeholder'));
// placeholders.forEach((placeholder) => {
// const url = placeholder.getAttribute('data-player-src');
placeholders.forEach((placeholder) => {
const url = placeholder.getAttribute('data-player-src') || '';
// Create the component to display the player.
// const factory = this.factoryResolver.resolveComponentFactory(CoreH5PPlayerComponent);
// const componentRef = viewContainerRef.createComponent<CoreH5PPlayerComponent>(factory);
// Create the component to display the player.
const factory = this.factoryResolver.resolveComponentFactory(CoreH5PPlayerComponent);
const componentRef = viewContainerRef.createComponent<CoreH5PPlayerComponent>(factory);
// componentRef.instance.src = url;
// componentRef.instance.component = component;
// componentRef.instance.componentId = componentId;
componentRef.instance.src = url;
componentRef.instance.component = component;
componentRef.instance.componentId = componentId;
// // Move the component to its right position.
// placeholder.parentElement?.replaceChild(componentRef.instance.elementRef.nativeElement, placeholder);
// });
// Move the component to its right position.
placeholder.parentElement?.replaceChild(componentRef.instance.elementRef.nativeElement, placeholder);
});
}
}

View File

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 23.0.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 345 150" style="enable-background:new 0 0 345 150;" xml:space="preserve">
<g>
<path d="M325.7,14.7C317.6,6.9,305.3,3,289,3h-43.5H234v31h-66l-5.4,22.2c4.5-2.1,10.9-4.2,15.3-5.3c4.4-1.1,8.8-0.9,13.1-0.9
c14.6,0,26.5,4.5,35.6,13.3c9.1,8.8,13.6,20,13.6,33.4c0,9.4-2.3,18.5-7,27.2s-11.3,15.4-19.9,20c-3.1,1.6-6.5,3.1-10.2,4.1h42.4
H259V95h25c18.2,0,31.7-4.2,40.6-12.5s13.3-19.9,13.3-34.6C337.9,33.6,333.8,22.5,325.7,14.7z M288.7,60.6c-3.5,3-9.6,4.4-18.3,4.4
H259V33h13.2c8.4,0,14.2,1.5,17.2,4.7c3.1,3.2,4.6,6.9,4.6,11.5C294,53.9,292.2,57.6,288.7,60.6z"/>
<path d="M176.5,76.3c-7.9,0-14.7,4.6-18,11.2L119,81.9L136.8,3h-23.6H101v62H51V3H7v145h44V95h50v53h12.2h42
c-6.7-2-12.5-4.6-17.2-8.1c-4.8-3.6-8.7-7.7-11.7-12.3c-3-4.6-5.3-9.7-7.3-16.5l39.6-5.7c3.3,6.6,10.1,11.1,17.9,11.1
c11.1,0,20.1-9,20.1-20.1S187.5,76.3,176.5,76.3z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@ -0,0 +1,44 @@
// (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 { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { IonicModule } from '@ionic/angular';
import { TranslateModule } from '@ngx-translate/core';
import { CoreDirectivesModule } from '@directives/directives.module';
import { CoreComponentsModule } from '@components/components.module';
import { CoreH5PPlayerComponent } from './h5p-player/h5p-player';
import { CoreH5PIframeComponent } from './h5p-iframe/h5p-iframe';
@NgModule({
declarations: [
CoreH5PPlayerComponent,
CoreH5PIframeComponent,
],
imports: [
CommonModule,
IonicModule,
CoreDirectivesModule,
TranslateModule.forChild(),
CoreComponentsModule,
],
providers: [
],
exports: [
CoreH5PPlayerComponent,
CoreH5PIframeComponent,
],
})
export class CoreH5PComponentsModule {}

View File

@ -0,0 +1,5 @@
<core-loading [hideUntil]="iframeSrc" class="core-loading-center safe-area-page">
<core-iframe *ngIf="iframeSrc" [src]="iframeSrc" iframeHeight="auto" [allowFullscreen]="true" (loaded)="iframeLoaded()">
</core-iframe>
<script *ngIf="resizeScript && iframeSrc" type="text/javascript" [src]="resizeScript"></script>
</core-loading>

View File

@ -0,0 +1,223 @@
// (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 { Component, Input, Output, ElementRef, OnChanges, SimpleChange, EventEmitter, OnDestroy } from '@angular/core';
import { NavigationEnd, Router } from '@angular/router';
import { Subscription } from 'rxjs';
import { filter } from 'rxjs/operators';
import { CoreFile } from '@services/file';
import { CoreFilepool } from '@services/filepool';
import { CoreFileHelper } from '@services/file-helper';
import { CoreSites } from '@services/sites';
import { CoreDomUtils } from '@services/utils/dom';
import { CoreUrlUtils } from '@services/utils/url';
import { CoreH5P } from '@features/h5p/services/h5p';
import { CoreConstants } from '@/core/constants';
import { CoreSite } from '@classes/site';
import { CoreLogger } from '@singletons/logger';
import { CoreH5PCore, CoreH5PDisplayOptions } from '../../classes/core';
import { CoreH5PHelper } from '../../classes/helper';
/**
* Component to render an iframe with an H5P package.
*/
@Component({
selector: 'core-h5p-iframe',
templateUrl: 'core-h5p-iframe.html',
})
export class CoreH5PIframeComponent implements OnChanges, OnDestroy {
@Input() fileUrl?: string; // The URL of the H5P file. If not supplied, onlinePlayerUrl is required.
@Input() displayOptions?: CoreH5PDisplayOptions; // Display options.
@Input() onlinePlayerUrl?: string; // The URL of the online player to display the H5P package.
@Input() trackComponent?: string; // Component to send xAPI events to.
@Input() contextId?: number; // Context ID. Required for tracking.
@Output() onIframeUrlSet = new EventEmitter<{src: string; online: boolean}>();
@Output() onIframeLoaded = new EventEmitter<void>();
iframeSrc?: string;
protected site: CoreSite;
protected siteId: string;
protected siteCanDownload: boolean;
protected logger: CoreLogger;
protected currentPageRoute?: string;
protected subscription: Subscription;
protected iframeLoadedOnce = false;
constructor(
public elementRef: ElementRef,
router: Router,
) {
this.logger = CoreLogger.getInstance('CoreH5PIframeComponent');
this.site = CoreSites.instance.getCurrentSite()!;
this.siteId = this.site.getId();
this.siteCanDownload = this.site.canDownloadFiles() && !CoreH5P.instance.isOfflineDisabledInSite();
// Send resize events when the page holding this component is re-entered.
// @todo: Check that this works as expected.
this.currentPageRoute = router.url;
this.subscription = router.events
.pipe(filter(event => event instanceof NavigationEnd))
.subscribe((event: NavigationEnd) => {
if (!this.iframeLoadedOnce || event.urlAfterRedirects == this.currentPageRoute) {
return;
}
window.dispatchEvent(new Event('resize'));
});
}
/**
* Detect changes on input properties.
*/
ngOnChanges(changes: {[name: string]: SimpleChange}): void {
// If it's already playing don't change it.
if ((changes.fileUrl || changes.onlinePlayerUrl) && !this.iframeSrc) {
this.play();
}
}
/**
* Play the H5P.
*
* @return Promise resolved when done.
*/
protected async play(): Promise<void> {
let localUrl: string | undefined;
let state: string;
if (this.fileUrl) {
state = await CoreFilepool.instance.getFileStateByUrl(this.siteId, this.fileUrl);
} else {
state = CoreConstants.NOT_DOWNLOADABLE;
}
if (this.siteCanDownload && CoreFileHelper.instance.isStateDownloaded(state)) {
// Package is downloaded, use the local URL.
localUrl = await this.getLocalUrl();
}
try {
if (localUrl) {
// Local package.
this.iframeSrc = localUrl;
} else {
this.onlinePlayerUrl = this.onlinePlayerUrl || CoreH5P.instance.h5pPlayer.calculateOnlinePlayerUrl(
this.site.getURL(),
this.fileUrl || '',
this.displayOptions,
this.trackComponent,
);
// Never allow downloading in the app. This will only work if the user is allowed to change the params.
const src = this.onlinePlayerUrl.replace(
CoreH5PCore.DISPLAY_OPTION_DOWNLOAD + '=1',
CoreH5PCore.DISPLAY_OPTION_DOWNLOAD + '=0',
);
// Get auto-login URL so the user is automatically authenticated.
const url = await this.site.getAutoLoginUrl(src, false);
// Add the preventredirect param so the user can authenticate.
this.iframeSrc = CoreUrlUtils.instance.addParamsToUrl(url, { preventredirect: false });
}
} catch (error) {
CoreDomUtils.instance.showErrorModalDefault(error, 'Error loading H5P package.', true);
} finally {
this.addResizerScript();
this.onIframeUrlSet.emit({ src: this.iframeSrc!, online: !!localUrl });
}
}
/**
* Get the local URL of the package.
*
* @return Promise resolved with the local URL.
*/
protected async getLocalUrl(): Promise<string | undefined> {
try {
const url = await CoreH5P.instance.h5pPlayer.getContentIndexFileUrl(
this.fileUrl!,
this.displayOptions,
this.trackComponent,
this.contextId,
this.siteId,
);
return url;
} catch (error) {
// Index file doesn't exist, probably deleted because a lib was updated. Try to create it again.
try {
const path = await CoreFilepool.instance.getInternalUrlByUrl(this.siteId, this.fileUrl!);
const file = await CoreFile.instance.getFile(path);
await CoreH5PHelper.saveH5P(this.fileUrl!, file, this.siteId);
// File treated. Try to get the index file URL again.
const url = await CoreH5P.instance.h5pPlayer.getContentIndexFileUrl(
this.fileUrl!,
this.displayOptions,
this.trackComponent,
this.contextId,
this.siteId,
);
return url;
} catch (error) {
// Still failing. Delete the H5P package?
this.logger.error('Error loading downloaded index:', error, this.fileUrl);
}
}
}
/**
* Add the resizer script if it hasn't been added already.
*/
protected addResizerScript(): void {
if (document.head.querySelector('#core-h5p-resizer-script') != null) {
// Script already added, don't add it again.
return;
}
const script = document.createElement('script');
script.id = 'core-h5p-resizer-script';
script.type = 'text/javascript';
script.src = CoreH5P.instance.h5pPlayer.getResizerScriptUrl();
document.head.appendChild(script);
}
/**
* H5P iframe has been loaded.
*/
iframeLoaded(): void {
this.onIframeLoaded.emit();
this.iframeLoadedOnce = true;
// Send a resize event to the window so H5P package recalculates the size.
window.dispatchEvent(new Event('resize'));
}
/**
* Component being destroyed.
*/
ngOnDestroy(): void {
this.subscription?.unsubscribe();
}
}

View File

@ -0,0 +1,14 @@
<div *ngIf="!showPackage && urlParams" class="core-h5p-placeholder">
<ion-button class="core-h5p-placeholder-play-button" fill="clear" (click)="play($event)">
<core-icon name="far-play-circle" slot="icon-only"></core-icon>
</ion-button>
<div class="core-h5p-placeholder-download-container">
<core-download-refresh [status]="state" [enabled]="canDownload" [loading]="calculating" [canTrustDownload]="true"
(action)="download()">
</core-download-refresh>
</div>
</div>
<core-h5p-iframe *ngIf="showPackage" [fileUrl]="urlParams!.url" [displayOptions]="displayOptions" [onlinePlayerUrl]="src">
</core-h5p-iframe>

View File

@ -0,0 +1,48 @@
:host {
--core-h5p-placeholder-bg-color: var(--gray);
--core-h5p-placeholder-text-color: var(--ion-text-color);
.core-h5p-placeholder {
position: relative;
width: 100%;
height: 230px;
background: url('../../../../../assets/img/icons/h5p.svg') center top 25px / 100px auto no-repeat var(--core-h5p-placeholder-bg-color);
color: var(--core-h5p-placeholder-text-color);
.icon:not([color="success"]) {
color: var(--core-h5p-placeholder-text-color);
}
.core-h5p-placeholder-play-button, .core-h5p-placeholder-spinner {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
.core-h5p-placeholder-play-button {
font-size: 30px;
min-height: 50px;
}
.core-h5p-placeholder-download-container {
position: absolute;
top: 0;
right: 0;
ion-spinner {
margin-right: 0.75em;
}
core-download-refresh > ion-icon {
margin: 0.4rem 0.2rem;
padding: 0 0.5em;
line-height: .67;
}
}
ion-spinner circle {
stroke: var(--core-h5p-placeholder-text-color);
}
}
}

View File

@ -0,0 +1,219 @@
// (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 { Component, Input, ElementRef, OnInit, OnDestroy, OnChanges, SimpleChange } from '@angular/core';
import { CoreApp } from '@services/app';
import { CoreFilepool } from '@services/filepool';
import { CoreSites } from '@services/sites';
import { CoreDomUtils } from '@services/utils/dom';
import { CoreUrlUtils } from '@services/utils/url';
import { CorePluginFileDelegate } from '@services/plugin-file-delegate';
import { CoreConstants } from '@/core/constants';
import { CoreSite } from '@classes/site';
import { CoreEvents, CoreEventObserver } from '@singletons/events';
import { CoreLogger } from '@singletons/logger';
import { CoreH5P } from '@features/h5p/services/h5p';
import { CoreH5PDisplayOptions } from '../../classes/core';
/**
* Component to render an H5P package.
*/
@Component({
selector: 'core-h5p-player',
templateUrl: 'core-h5p-player.html',
styleUrls: ['h5p-player.scss'],
})
export class CoreH5PPlayerComponent implements OnInit, OnChanges, OnDestroy {
@Input() src?: string; // The URL of the player to display the H5P package.
@Input() component?: string; // Component.
@Input() componentId?: string | number; // Component ID to use in conjunction with the component.
showPackage = false;
state?: string;
canDownload = false;
calculating = true;
displayOptions?: CoreH5PDisplayOptions;
urlParams?: {[name: string]: string};
protected site: CoreSite;
protected siteId: string;
protected siteCanDownload: boolean;
protected observer?: CoreEventObserver;
protected logger: CoreLogger;
constructor(
public elementRef: ElementRef,
) {
this.logger = CoreLogger.getInstance('CoreH5PPlayerComponent');
this.site = CoreSites.instance.getCurrentSite()!;
this.siteId = this.site.getId();
this.siteCanDownload = this.site.canDownloadFiles() && !CoreH5P.instance.isOfflineDisabledInSite();
}
/**
* Component being initialized.
*/
ngOnInit(): void {
this.checkCanDownload();
}
/**
* Detect changes on input properties.
*/
ngOnChanges(changes: {[name: string]: SimpleChange}): void {
// If it's already playing there's no need to check if it can be downloaded.
if (changes.src && !this.showPackage) {
this.checkCanDownload();
}
}
/**
* Play the H5P.
*
* @param e Event.
*/
async play(e: MouseEvent): Promise<void> {
e.preventDefault();
e.stopPropagation();
this.displayOptions = CoreH5P.instance.h5pPlayer.getDisplayOptionsFromUrlParams(this.urlParams);
this.showPackage = true;
if (!this.canDownload || (this.state != CoreConstants.OUTDATED && this.state != CoreConstants.NOT_DOWNLOADED)) {
return;
}
// Download the package in background if the size is low.
try {
this.attemptDownloadInBg();
} catch (error) {
this.logger.error('Error downloading H5P in background', error);
}
}
/**
* Download the package.
*
* @return Promise resolved when done.
*/
async download(): Promise<void> {
if (!CoreApp.instance.isOnline()) {
CoreDomUtils.instance.showErrorModal('core.networkerrormsg', true);
return;
}
try {
// Get the file size and ask the user to confirm.
const size = await CorePluginFileDelegate.instance.getFileSize({ fileurl: this.urlParams!.url }, this.siteId);
await CoreDomUtils.instance.confirmDownloadSize({ size: size, total: true });
// User confirmed, add to the queue.
await CoreFilepool.instance.addToQueueByUrl(this.siteId, this.urlParams!.url, this.component, this.componentId);
} catch (error) {
if (CoreDomUtils.instance.isCanceledError(error)) {
// User cancelled, stop.
return;
}
CoreDomUtils.instance.showErrorModalDefault(error, 'core.errordownloading', true);
this.calculateState();
}
}
/**
* Download the H5P in background if the size is low.
*
* @return Promise resolved when done.
*/
protected async attemptDownloadInBg(): Promise<void> {
if (!this.urlParams || !this.src || !this.siteCanDownload || !CoreH5P.instance.canGetTrustedH5PFileInSite() ||
!CoreApp.instance.isOnline()) {
return;
}
// Get the file size.
const size = await CorePluginFileDelegate.instance.getFileSize({ fileurl: this.urlParams.url }, this.siteId);
if (CoreFilepool.instance.shouldDownload(size)) {
// Download the file in background.
CoreFilepool.instance.addToQueueByUrl(this.siteId, this.urlParams.url, this.component, this.componentId);
}
}
/**
* Check if the package can be downloaded.
*
* @return Promise resolved when done.
*/
protected async checkCanDownload(): Promise<void> {
this.observer && this.observer.off();
this.urlParams = CoreUrlUtils.instance.extractUrlParams(this.src || '');
if (this.src && this.siteCanDownload && CoreH5P.instance.canGetTrustedH5PFileInSite() && this.site.containsUrl(this.src)) {
this.calculateState();
// Listen for changes in the state.
try {
const eventName = await CoreFilepool.instance.getFileEventNameByUrl(this.siteId, this.urlParams.url);
this.observer = CoreEvents.on(eventName, () => {
this.calculateState();
});
} catch (error) {
// An error probably means the file cannot be downloaded or we cannot check it (offline).
}
} else {
this.calculating = false;
this.canDownload = false;
}
}
/**
* Calculate state of the file.
*
* @param fileUrl The H5P file URL.
* @return Promise resolved when done.
*/
protected async calculateState(): Promise<void> {
this.calculating = true;
// Get the status of the file.
try {
const state = await CoreFilepool.instance.getFileStateByUrl(this.siteId, this.urlParams!.url);
this.canDownload = true;
this.state = state;
} catch (error) {
this.canDownload = false;
} finally {
this.calculating = false;
}
}
/**
* Component destroyed.
*/
ngOnDestroy(): void {
this.observer?.off();
}
}

View File

@ -16,6 +16,7 @@ import { APP_INITIALIZER, NgModule } from '@angular/core';
import { CorePluginFileDelegate } from '@services/plugin-file-delegate';
import { CORE_SITE_SCHEMAS } from '@services/sites';
import { CoreH5PComponentsModule } from './components/components.module';
import {
CONTENT_TABLE_NAME,
LIBRARIES_TABLE_NAME,
@ -27,6 +28,7 @@ import { CoreH5PPluginFileHandler } from './services/handlers/pluginfile';
@NgModule({
imports: [
CoreH5PComponentsModule,
],
providers: [
{

View File

@ -55,7 +55,7 @@ export class CoreUrlUtilsProvider {
* @param boolToNumber Whether to convert bools to 1 or 0.
* @return URL with params.
*/
addParamsToUrl(url: string, params?: CoreUrlParams, anchor?: string, boolToNumber?: boolean): string {
addParamsToUrl(url: string, params?: Record<string, unknown>, anchor?: string, boolToNumber?: boolean): string {
let separator = url.indexOf('?') != -1 ? '&' : '?';
for (const key in params) {