diff --git a/src/addons/filter/displayh5p/services/handlers/displayh5p.ts b/src/addons/filter/displayh5p/services/handlers/displayh5p.ts index 67a07b92c..e4ac253c8 100644 --- a/src/addons/filter/displayh5p/services/handlers/displayh5p.ts +++ b/src/addons/filter/displayh5p/services/handlers/displayh5p.ts @@ -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 { - // @todo - // const placeholders = Array.from(container.querySelectorAll('div.core-h5p-tmp-placeholder')); + const placeholders = 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(factory); + // Create the component to display the player. + const factory = this.factoryResolver.resolveComponentFactory(CoreH5PPlayerComponent); + const componentRef = viewContainerRef.createComponent(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); + }); } } diff --git a/src/assets/img/icons/h5p.svg b/src/assets/img/icons/h5p.svg new file mode 100644 index 000000000..7856f9efb --- /dev/null +++ b/src/assets/img/icons/h5p.svg @@ -0,0 +1,14 @@ + + + + + + + + diff --git a/src/core/features/h5p/components/components.module.ts b/src/core/features/h5p/components/components.module.ts new file mode 100644 index 000000000..e7842c6f6 --- /dev/null +++ b/src/core/features/h5p/components/components.module.ts @@ -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 {} diff --git a/src/core/features/h5p/components/h5p-iframe/core-h5p-iframe.html b/src/core/features/h5p/components/h5p-iframe/core-h5p-iframe.html new file mode 100644 index 000000000..943c32102 --- /dev/null +++ b/src/core/features/h5p/components/h5p-iframe/core-h5p-iframe.html @@ -0,0 +1,5 @@ + + + + + diff --git a/src/core/features/h5p/components/h5p-iframe/h5p-iframe.ts b/src/core/features/h5p/components/h5p-iframe/h5p-iframe.ts new file mode 100644 index 000000000..0bcb55350 --- /dev/null +++ b/src/core/features/h5p/components/h5p-iframe/h5p-iframe.ts @@ -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(); + + 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 { + 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 { + 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(); + } + +} diff --git a/src/core/features/h5p/components/h5p-player/core-h5p-player.html b/src/core/features/h5p/components/h5p-player/core-h5p-player.html new file mode 100644 index 000000000..cb510fd5d --- /dev/null +++ b/src/core/features/h5p/components/h5p-player/core-h5p-player.html @@ -0,0 +1,14 @@ +
+ + + + +
+ + +
+
+ + + diff --git a/src/core/features/h5p/components/h5p-player/h5p-player.scss b/src/core/features/h5p/components/h5p-player/h5p-player.scss new file mode 100644 index 000000000..6e1803220 --- /dev/null +++ b/src/core/features/h5p/components/h5p-player/h5p-player.scss @@ -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); + } + } +} diff --git a/src/core/features/h5p/components/h5p-player/h5p-player.ts b/src/core/features/h5p/components/h5p-player/h5p-player.ts new file mode 100644 index 000000000..27830dc58 --- /dev/null +++ b/src/core/features/h5p/components/h5p-player/h5p-player.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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(); + } + +} diff --git a/src/core/features/h5p/h5p.module.ts b/src/core/features/h5p/h5p.module.ts index 803599a5a..011fd98ad 100644 --- a/src/core/features/h5p/h5p.module.ts +++ b/src/core/features/h5p/h5p.module.ts @@ -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: [ { diff --git a/src/core/services/utils/url.ts b/src/core/services/utils/url.ts index 3d19bcb83..428cbce48 100644 --- a/src/core/services/utils/url.ts +++ b/src/core/services/utils/url.ts @@ -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, anchor?: string, boolToNumber?: boolean): string { let separator = url.indexOf('?') != -1 ? '&' : '?'; for (const key in params) {