MOBILE-3653 scorm: Implement player page

main
Dani Palou 2021-03-26 09:38:34 +01:00
parent a2cf8db2ea
commit fd6e2a4a03
7 changed files with 1803 additions and 0 deletions

File diff suppressed because it is too large Load Diff

View File

@ -16,10 +16,12 @@ import { NgModule } from '@angular/core';
import { AddonModScormIndexComponent } from './index/index';
import { CoreSharedModule } from '@/core/shared.module';
import { CoreCourseComponentsModule } from '@features/course/components/components.module';
import { AddonModScormTocComponent } from './toc/toc';
@NgModule({
declarations: [
AddonModScormIndexComponent,
AddonModScormTocComponent,
],
imports: [
CoreSharedModule,
@ -29,6 +31,7 @@ import { CoreCourseComponentsModule } from '@features/course/components/componen
],
exports: [
AddonModScormIndexComponent,
AddonModScormTocComponent,
],
})
export class AddonModScormComponentsModule {}

View File

@ -0,0 +1,50 @@
<ion-header>
<ion-toolbar>
<ion-title>{{ 'addon.mod_scorm.toc' | translate }}</ion-title>
<ion-buttons slot="end">
<ion-button (click)="closeModal()" [attr.aria-label]="'core.close' | translate">
<ion-icon slot="icon-only" name="fas-times"></ion-icon>
</ion-button>
</ion-buttons>
</ion-toolbar>
</ion-header>
<ion-content>
<nav>
<ion-list class="addon-mod_scorm-toc">
<ion-item class="ion-text-wrap" *ngIf="attemptToContinue">
<ion-label>
<p>{{ 'addon.mod_scorm.dataattemptshown' | translate:{number: attemptToContinue} }}</p>
</ion-label>
</ion-item>
<ion-item class="ion-text-center" *ngIf="isBrowse">
<ion-label>
<p>{{ 'addon.mod_scorm.browsemode' | translate }}</p>
</ion-label>
</ion-item>
<ion-item class="ion-text-center" *ngIf="isReview">
<ion-label>
<p>{{ 'addon.mod_scorm.reviewmode' | translate }}</p>
</ion-label>
</ion-item>
<!-- List of SCOs. -->
<ng-container *ngFor="let sco of toc">
<ion-item *ngIf="sco.isvisible" class="ion-text-wrap" [detail]="sco.prereq && sco.launch"
[ngClass]="'core-padding-' + sco.level + ' addon-mod_scorm-type-' + sco.scormtype"
[class.core-selected-item]="selected == sco.id" (click)="loadSco(sco)"
[disabled]="!sco.prereq || !sco.launch ? true : null" tappable>
<ion-icon *ngIf="sco.icon" [name]="sco.icon.icon" [attr.aria-label]="sco.icon.description" slot="start">
</ion-icon>
<ion-label>
<core-format-text [text]="sco.title" contextLevel="module" [contextInstanceId]="moduleId"
[courseId]="courseId">
</core-format-text>
<span *ngIf="accessInfo && accessInfo.canviewscores && sco.scoreraw">
({{ 'addon.mod_scorm.score' | translate }}: {{sco.scoreraw}})
</span>
</ion-label>
</ion-item>
</ng-container>
</ion-list>
</nav>
</ion-content>

View File

@ -0,0 +1,68 @@
// (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, OnInit } from '@angular/core';
import { ModalController } from '@singletons';
import { AddonModScormGetScormAccessInformationWSResponse, AddonModScormProvider } from '../../services/scorm';
import { AddonModScormTOCScoWithIcon } from '../../services/scorm-helper';
/**
* Modal to display the TOC of a SCORM.
*/
@Component({
selector: 'addon-mod-scorm-toc',
templateUrl: 'toc.html',
})
export class AddonModScormTocComponent implements OnInit {
@Input() toc: AddonModScormTOCScoWithIcon[] = [];
@Input() attemptToContinue?: number;
@Input() selected?: number;
@Input() moduleId!: number;
@Input() courseId!: number;
@Input() accessInfo!: AddonModScormGetScormAccessInformationWSResponse;
@Input() mode = '';
isBrowse = false;
isReview = false;
/**
* @inheritdoc
*/
ngOnInit(): void {
this.isBrowse = this.mode === AddonModScormProvider.MODEBROWSE;
this.isReview = this.mode === AddonModScormProvider.MODEREVIEW;
}
/**
* Function called when a SCO is clicked.
*
* @param sco Clicked SCO.
*/
loadSco(sco: AddonModScormTOCScoWithIcon): void {
if (!sco.prereq || !sco.isvisible || !sco.launch) {
return;
}
ModalController.dismiss(sco);
}
/**
* Close modal.
*/
closeModal(): void {
ModalController.dismiss();
}
}

View File

@ -0,0 +1,27 @@
<ion-header>
<ion-toolbar>
<ion-buttons slot="start">
<ion-back-button [attr.aria-label]="'core.back' | translate"></ion-back-button>
</ion-buttons>
<ion-title>
<core-format-text [text]="title" contextLevel="module" [contextInstanceId]="cmId" [courseId]="courseId">
</core-format-text>
</ion-title>
<ion-buttons slot="end">
<ion-button *ngIf="showToc && !loadingToc && toc.length" (click)="openToc()"
[attr.aria-label]="'addon.mod_scorm.toc' | translate" aria-haspopup="true">
<ion-icon name="fas-bookmark" slot="icon-only"></ion-icon>
</ion-button>
<ion-spinner *ngIf="showToc && loadingToc"></ion-spinner>
</ion-buttons>
</ion-toolbar>
</ion-header>
<ion-content>
<core-loading [hideUntil]="loaded">
<core-navigation-bar [previous]="previousSco" [next]="nextSco" (action)="loadSco($event)"></core-navigation-bar>
<core-iframe *ngIf="loaded && src" [src]="src" [iframeWidth]="scormWidth" [iframeHeight]="scormHeight"></core-iframe>
<p *ngIf="!src && errorMessage">{{ errorMessage | translate }}</p>
</core-loading>
</ion-content>

View File

@ -0,0 +1,574 @@
// (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, OnInit, OnDestroy } from '@angular/core';
import { CoreMainMenuPage } from '@features/mainmenu/pages/menu/menu';
import { CoreNavigator } from '@services/navigator';
import { CoreSites, CoreSitesReadingStrategy } from '@services/sites';
import { CoreSync } from '@services/sync';
import { CoreDomUtils } from '@services/utils/dom';
import { CoreTimeUtils } from '@services/utils/time';
import { CoreUtils } from '@services/utils/utils';
import { ModalController } from '@singletons';
import { CoreEventObserver, CoreEvents } from '@singletons/events';
import { AddonModScormDataModel12 } from '../../classes/data-model-12';
import { AddonModScormTocComponent } from '../../components/toc/toc';
import {
AddonModScorm,
AddonModScormAttemptCountResult,
AddonModScormGetScormAccessInformationWSResponse,
AddonModScormProvider,
AddonModScormScorm,
AddonModScormScoWithData,
AddonModScormUserDataMap,
} from '../../services/scorm';
import { AddonModScormHelper, AddonModScormTOCScoWithIcon } from '../../services/scorm-helper';
import { AddonModScormSync } from '../../services/scorm-sync';
/**
* Page that allows playing a SCORM.
*/
@Component({
selector: 'page-addon-mod-scorm-player',
templateUrl: 'player.html',
})
export class AddonModScormPlayerPage implements OnInit, OnDestroy {
title?: string; // Title.
scorm!: AddonModScormScorm; // The SCORM object.
showToc = false; // Whether to show the table of contents (TOC).
loadingToc = true; // Whether the TOC is being loaded.
toc: AddonModScormTOCScoWithIcon[] = []; // List of SCOs.
loaded = false; // Whether the data has been loaded.
previousSco?: AddonModScormScoWithData; // Previous SCO.
nextSco?: AddonModScormScoWithData; // Next SCO.
src?: string; // Iframe src.
errorMessage?: string; // Error message.
accessInfo?: AddonModScormGetScormAccessInformationWSResponse; // Access information.
scormWidth?: number; // Width applied to scorm iframe.
scormHeight?: number; // Height applied to scorm iframe.
incomplete = false; // Whether last attempt is incomplete.
cmId!: number; // Course module ID.
courseId!: number; // Course ID.
protected siteId!: string;
protected mode!: string; // Mode to play the SCORM.
protected moduleUrl!: string; // Module URL.
protected newAttempt = false; // Whether to start a new attempt.
protected organizationId?: string; // Organization ID to load.
protected attempt?: number; // The attempt number.
protected offline = false; // Whether it's offline mode.
protected userData?: AddonModScormUserDataMap; // User data.
protected initialScoId?: number; // Initial SCO ID to load.
protected currentSco?: AddonModScormScoWithData; // Current SCO.
protected dataModel?: AddonModScormDataModel12; // Data Model.
protected attemptToContinue?: number; // Attempt to continue (for the popover).
// Observers.
protected tocObserver?: CoreEventObserver;
protected launchNextObserver?: CoreEventObserver;
protected launchPrevObserver?: CoreEventObserver;
protected goOfflineObserver?: CoreEventObserver;
constructor(
protected mainMenuPage: CoreMainMenuPage,
) {}
/**
* @inheritdoc
*/
async ngOnInit(): Promise<void> {
this.cmId = CoreNavigator.getRouteNumberParam('cmId')!;
this.courseId = CoreNavigator.getRouteNumberParam('courseId')!;
this.mode = CoreNavigator.getRouteParam('mode') || AddonModScormProvider.MODENORMAL;
this.moduleUrl = CoreNavigator.getRouteParam('moduleUrl') || '';
this.newAttempt = !!CoreNavigator.getRouteBooleanParam('newAttempt');
this.organizationId = CoreNavigator.getRouteParam('organizationId');
this.initialScoId = CoreNavigator.getRouteNumberParam('scoId');
this.siteId = CoreSites.getCurrentSiteId();
try {
// Fetch the SCORM data.
await this.fetchData();
if (!this.currentSco) {
return;
}
// Set start time if it's a new attempt.
if (this.newAttempt) {
try {
await this.setStartTime(this.currentSco.id);
} catch (error) {
CoreDomUtils.instance.showErrorModalDefault(error, 'addon.mod_scorm.errorgetscorm', true);
}
}
// Load SCO.
this.loadSco(this.currentSco);
} finally {
this.loaded = true;
}
}
/**
* Initialize.
*
* @return Promise resolved when done.
*/
protected async initialize(): Promise<void> {
// Get the SCORM instance.
this.scorm = await AddonModScorm.getScorm(this.courseId, this.cmId, {
moduleUrl: this.moduleUrl,
readingStrategy: CoreSitesReadingStrategy.PreferCache,
});
// Block the SCORM so it cannot be synchronized.
CoreSync.blockOperation(AddonModScormProvider.COMPONENT, this.scorm.id, 'player');
// We use SCORM name at start, later we'll use the SCO title.
this.title = this.scorm.name;
this.showToc = AddonModScorm.displayTocInPlayer(this.scorm);
if (this.scorm.popup) {
this.mainMenuPage.changeVisibility(false);
// If we receive a value > 100 we assume it's a fixed pixel size.
if (this.scorm.width! > 100) {
this.scormWidth = this.scorm.width;
// Only get fixed size on height if width is also fixed.
if (this.scorm.height! > 100) {
this.scormHeight = this.scorm.height;
}
}
}
// Listen for events to update the TOC, navigate through SCOs and go offline.
this.tocObserver = CoreEvents.on(AddonModScormProvider.UPDATE_TOC_EVENT, (data) => {
if (data.scormId !== this.scorm.id) {
return;
}
if (this.offline) {
// Wait a bit to make sure data is stored.
setTimeout(this.refreshToc.bind(this), 100);
} else {
this.refreshToc();
}
}, this.siteId);
this.launchNextObserver = CoreEvents.on(AddonModScormProvider.LAUNCH_NEXT_SCO_EVENT, (data) => {
if (data.scormId === this.scorm.id && this.nextSco) {
this.loadSco(this.nextSco);
}
}, this.siteId);
this.launchPrevObserver = CoreEvents.on(AddonModScormProvider.LAUNCH_PREV_SCO_EVENT, (data) => {
if (data.scormId === this.scorm.id && this.previousSco) {
this.loadSco(this.previousSco);
}
}, this.siteId);
this.goOfflineObserver = CoreEvents.on(AddonModScormProvider.GO_OFFLINE_EVENT, (data) => {
if (data.scormId !== this.scorm.id || this.offline) {
return;
}
this.offline = true;
// Wait a bit to prevent collisions between this store and SCORM API's store.
setTimeout(async () => {
try {
AddonModScormHelper.convertAttemptToOffline(this.scorm, this.attempt!);
} catch (error) {
CoreDomUtils.instance.showErrorModalDefault(error, 'core.error', true);
}
this.refreshToc();
}, 200);
}, this.siteId);
}
/**
* Calculate the next and previous SCO.
*
* @param scoId Current SCO ID.
*/
protected calculateNextAndPreviousSco(scoId: number): void {
this.previousSco = AddonModScormHelper.getPreviousScoFromToc(this.toc, scoId);
this.nextSco = AddonModScormHelper.getNextScoFromToc(this.toc, scoId);
}
/**
* Determine the attempt to use, the mode (normal/preview) and if it's offline or online.
*
* @param attemptsData Attempts count.
* @return Promise resolved when done.
*/
protected async determineAttemptAndMode(attemptsData: AddonModScormAttemptCountResult): Promise<void> {
const data = await AddonModScormHelper.determineAttemptToContinue(this.scorm, attemptsData);
let incomplete = false;
this.attempt = data.num;
this.offline = data.offline;
if (this.attempt != attemptsData.lastAttempt.num) {
this.attemptToContinue = this.attempt;
}
// Check if current attempt is incomplete.
if (this.attempt > 0) {
incomplete = await AddonModScorm.isAttemptIncomplete(this.scorm.id, this.attempt, {
offline: this.offline,
cmId: this.cmId,
});
}
// Determine mode and attempt to use.
const result = AddonModScorm.determineAttemptAndMode(this.scorm, this.mode, this.attempt, this.newAttempt, incomplete);
if (result.attempt > this.attempt) {
// We're creating a new attempt.
if (this.offline) {
// Last attempt was offline, so we'll create a new offline attempt.
await AddonModScormHelper.createOfflineAttempt(this.scorm, result.attempt, attemptsData.online.length);
} else {
try {
// Last attempt was online, verify that we can create a new online attempt. We ignore cache.
await AddonModScorm.getScormUserData(this.scorm.id, result.attempt, {
cmId: this.cmId,
readingStrategy: CoreSitesReadingStrategy.OnlyNetwork,
});
} catch {
// Cannot communicate with the server, create an offline attempt.
this.offline = true;
await AddonModScormHelper.createOfflineAttempt(this.scorm, result.attempt, attemptsData.online.length);
}
}
}
this.mode = result.mode;
this.newAttempt = result.newAttempt;
this.attempt = result.attempt;
}
/**
* Fetch data needed to play the SCORM.
*
* @return Promise resolved when done.
*/
protected async fetchData(): Promise<void> {
if (!this.scorm) {
await this.initialize();
}
// Wait for any ongoing sync to finish. We won't sync a SCORM while it's being played.
await AddonModScormSync.waitForSync(this.scorm.id);
try {
// Get attempts data.
const attemptsData = await AddonModScorm.getAttemptCount(this.scorm.id, { cmId: this.cmId });
await this.determineAttemptAndMode(attemptsData);
const [data, accessInfo] = await Promise.all([
AddonModScorm.getScormUserData(this.scorm.id, this.attempt!, {
cmId: this.cmId,
offline: this.offline,
}),
AddonModScorm.getAccessInformation(this.scorm.id, {
cmId: this.cmId,
}),
this.fetchToc(),
]);
this.userData = data;
this.accessInfo = accessInfo;
} catch (error) {
CoreDomUtils.instance.showErrorModalDefault(error, 'addon.mod_scorm.errorgetscorm', true);
}
}
/**
* Fetch the TOC.
*
* @return Promise resolved when done.
*/
protected async fetchToc(): Promise<void> {
this.loadingToc = true;
try {
// We need to check incomplete again: attempt number or status might have changed.
this.incomplete = await AddonModScorm.isAttemptIncomplete(this.scorm.id, this.attempt!, {
offline: this.offline,
cmId: this.cmId,
});
// Get TOC.
this.toc = await AddonModScormHelper.getToc(this.scorm.id, this.attempt!, this.incomplete, {
organization: this.organizationId,
offline: this.offline,
cmId: this.cmId,
});
if (this.currentSco) {
return;
}
if (this.newAttempt) {
// Creating a new attempt, use the first SCO defined by the SCORM.
this.initialScoId = this.scorm.launch;
}
// Determine current SCO if we received an ID.
if (this.initialScoId && this.initialScoId > 0) {
// SCO set by parameter, get it from TOC.
this.currentSco = AddonModScormHelper.getScoFromToc(this.toc, this.initialScoId);
}
if (this.currentSco) {
return;
}
// No SCO defined. Get the first valid one.
const sco = await AddonModScormHelper.getFirstSco(this.scorm.id, this.attempt!, {
toc: this.toc,
organization: this.organizationId,
mode: this.mode,
offline: this.offline,
cmId: this.cmId,
});
if (sco) {
this.currentSco = sco;
} else {
// We couldn't find a SCO to load: they're all inactive or without launch URL.
this.errorMessage = 'addon.mod_scorm.errornovalidsco';
}
} finally {
this.loadingToc = false;
}
}
/**
* Page will leave.
*/
ionViewWillLeave(): void {
CoreEvents.trigger(CoreEvents.ACTIVITY_DATA_SENT, { module: 'scorm' });
// Empty src when leaving the state so unload event is triggered in the iframe.
this.src = '';
}
/**
* Load a SCO.
*
* @param sco The SCO to load.
* @return Promise resolved when done.
*/
async loadSco(sco: AddonModScormScoWithData): Promise<void> {
if (!this.dataModel) {
// Create the model.
this.dataModel = new AddonModScormDataModel12(
this.siteId,
this.scorm,
sco.id,
this.attempt!,
this.userData!,
this.mode,
this.offline,
);
// Add the model to the window so the SCORM can access it.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(<any> window).API = this.dataModel;
} else {
// Load the SCO in the existing model.
this.dataModel.loadSco(sco.id);
}
this.currentSco = sco;
this.title = sco.title || this.scorm.name; // Try to use SCO title.
this.calculateNextAndPreviousSco(sco.id);
// Load the SCO source.
this.loadScoSrc(sco);
if (sco.scormtype == 'asset') {
// Mark the asset as completed.
this.markCompleted(sco);
}
// Trigger SCO launch event.
CoreUtils.ignoreErrors(AddonModScorm.logLaunchSco(this.scorm.id, sco.id, this.scorm.name));
}
/**
* Load SCO src.
*
* @param sco SCO to load.
* @return Promise resolved when done.
*/
protected async loadScoSrc(sco: AddonModScormScoWithData): Promise<void> {
const src = await AddonModScorm.getScoSrc(this.scorm, sco);
if (src == this.src) {
// Re-loading same page. Set it to empty and then re-set the src in the next digest so it detects it has changed.
this.src = '';
await CoreUtils.nextTick();
}
this.src = src;
}
/**
* Given an SCO, mark it as completed.
*
* @param sco SCO to mark.
* @return Promise resolved when done.
*/
protected async markCompleted(sco: AddonModScormScoWithData): Promise<void> {
const tracks = [{
element: 'cmi.core.lesson_status',
value: 'completed',
}];
try {
AddonModScorm.saveTracks(sco.id, this.attempt!, tracks, this.scorm, this.offline);
} catch {
// Error saving data. Go offline if needed.
if (this.offline) {
return;
}
const data = await AddonModScorm.getScormUserData(this.scorm.id, this.attempt!, {
cmId: this.cmId,
});
if (data[sco.id] && data[sco.id].userdata['cmi.core.lesson_status'] == 'completed') {
// Already marked as completed.
return;
}
try {
// Go offline.
await AddonModScormHelper.convertAttemptToOffline(this.scorm, this.attempt!);
this.offline = true;
this.dataModel?.setOffline(true);
await AddonModScorm.saveTracks(sco.id, this.attempt!, tracks, this.scorm, true);
} catch (error) {
CoreDomUtils.instance.showErrorModalDefault(error, 'core.error', true);
}
} finally {
// Refresh TOC, some prerequisites might have changed.
this.refreshToc();
}
}
/**
* Show the TOC.
*/
async openToc(): Promise<void> {
const modal = await ModalController.create({
component: AddonModScormTocComponent,
componentProps: {
toc: this.toc,
attemptToContinue: this.attemptToContinue,
selected: this.currentSco && this.currentSco.id,
moduleId: this.cmId,
courseId: this.courseId,
accessInfo: this.accessInfo,
mode: this.mode,
},
cssClass: 'core-modal-lateral',
showBackdrop: true,
backdropDismiss: true,
// @todo enterAnimation: 'core-modal-lateral-transition',
// leaveAnimation: 'core-modal-lateral-transition'
});
await modal.present();
const result = await modal.onDidDismiss();
if (result.data) {
this.loadSco(result.data);
}
}
/**
* Refresh the TOC.
*
* @return Promise resolved when done.
*/
protected async refreshToc(): Promise<void> {
try {
await CoreUtils.ignoreErrors(AddonModScorm.invalidateAllScormData(this.scorm.id));
await this.fetchToc();
} catch (error) {
CoreDomUtils.instance.showErrorModalDefault(error, 'addon.mod_scorm.errorgetscorm', true);
}
}
/**
* Set SCORM start time.
*
* @param scoId SCO ID.
* @return Promise resolved when done.
*/
protected async setStartTime(scoId: number): Promise<void> {
const tracks = [{
element: 'x.start.time',
value: String(CoreTimeUtils.timestamp()),
}];
await AddonModScorm.saveTracks(scoId, this.attempt!, tracks, this.scorm, this.offline);
if (this.offline) {
return;
}
// New online attempt created, update cached data about online attempts.
await CoreUtils.ignoreErrors(AddonModScorm.getAttemptCount(this.scorm.id, {
cmId: this.cmId,
readingStrategy: CoreSitesReadingStrategy.OnlyNetwork,
}));
}
/**
* Component being destroyed.
*/
ngOnDestroy(): void {
// Stop listening for events.
this.tocObserver?.off();
this.launchNextObserver?.off();
this.launchPrevObserver?.off();
setTimeout(() => {
this.goOfflineObserver?.off();
}, 500);
this.mainMenuPage.changeVisibility(true);
// Unblock the SCORM so it can be synced.
CoreSync.unblockOperation(AddonModScormProvider.COMPONENT, this.scorm.id, 'player');
}
}

View File

@ -18,12 +18,17 @@ import { RouterModule, Routes } from '@angular/router';
import { CoreSharedModule } from '@/core/shared.module';
import { AddonModScormComponentsModule } from './components/components.module';
import { AddonModScormIndexPage } from './pages/index/index';
import { AddonModScormPlayerPage } from './pages/player/player';
const routes: Routes = [
{
path: ':courseId/:cmId',
component: AddonModScormIndexPage,
},
{
path: ':courseId/:cmId/player',
component: AddonModScormPlayerPage,
},
];
@NgModule({
@ -34,6 +39,7 @@ const routes: Routes = [
],
declarations: [
AddonModScormIndexPage,
AddonModScormPlayerPage,
],
})
export class AddonModScormLazyModule {}