From 7956d8e563dc280fa9432705bae292a9a4da4230 Mon Sep 17 00:00:00 2001
From: Dani Palou <dani@moodle.com>
Date: Wed, 16 Dec 2020 16:04:43 +0100
Subject: [PATCH] MOBILE-3666 h5p: Implement H5P components

---
 .../services/handlers/displayh5p.ts           |  39 ++-
 src/assets/img/icons/h5p.svg                  |  14 ++
 .../h5p/components/components.module.ts       |  44 ++++
 .../h5p-iframe/core-h5p-iframe.html           |   5 +
 .../h5p/components/h5p-iframe/h5p-iframe.ts   | 223 ++++++++++++++++++
 .../h5p-player/core-h5p-player.html           |  14 ++
 .../h5p/components/h5p-player/h5p-player.scss |  48 ++++
 .../h5p/components/h5p-player/h5p-player.ts   | 219 +++++++++++++++++
 src/core/features/h5p/h5p.module.ts           |   2 +
 src/core/services/utils/url.ts                |   2 +-
 10 files changed, 589 insertions(+), 21 deletions(-)
 create mode 100644 src/assets/img/icons/h5p.svg
 create mode 100644 src/core/features/h5p/components/components.module.ts
 create mode 100644 src/core/features/h5p/components/h5p-iframe/core-h5p-iframe.html
 create mode 100644 src/core/features/h5p/components/h5p-iframe/h5p-iframe.ts
 create mode 100644 src/core/features/h5p/components/h5p-player/core-h5p-player.html
 create mode 100644 src/core/features/h5p/components/h5p-player/h5p-player.scss
 create mode 100644 src/core/features/h5p/components/h5p-player/h5p-player.ts

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<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);
+        });
     }
 
 }
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 @@
+<?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>
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 @@
+<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>
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<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();
+    }
+
+}
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 @@
+<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>
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<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();
+    }
+
+}
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<string, unknown>, anchor?: string, boolToNumber?: boolean): string {
         let separator = url.indexOf('?') != -1 ? '&' : '?';
 
         for (const key in params) {