forked from EVOgeek/Vmeda.Online
MOBILE-2350 scorm: Implement SCORM player
parent
5793af108d
commit
8e577becc7
|
@ -20,10 +20,12 @@ import { CoreComponentsModule } from '@components/components.module';
|
||||||
import { CoreDirectivesModule } from '@directives/directives.module';
|
import { CoreDirectivesModule } from '@directives/directives.module';
|
||||||
import { CoreCourseComponentsModule } from '@core/course/components/components.module';
|
import { CoreCourseComponentsModule } from '@core/course/components/components.module';
|
||||||
import { AddonModScormIndexComponent } from './index/index';
|
import { AddonModScormIndexComponent } from './index/index';
|
||||||
|
import { AddonModScormTocPopoverComponent } from './toc-popover/toc-popover';
|
||||||
|
|
||||||
@NgModule({
|
@NgModule({
|
||||||
declarations: [
|
declarations: [
|
||||||
AddonModScormIndexComponent
|
AddonModScormIndexComponent,
|
||||||
|
AddonModScormTocPopoverComponent
|
||||||
],
|
],
|
||||||
imports: [
|
imports: [
|
||||||
CommonModule,
|
CommonModule,
|
||||||
|
@ -36,10 +38,12 @@ import { AddonModScormIndexComponent } from './index/index';
|
||||||
providers: [
|
providers: [
|
||||||
],
|
],
|
||||||
exports: [
|
exports: [
|
||||||
AddonModScormIndexComponent
|
AddonModScormIndexComponent,
|
||||||
|
AddonModScormTocPopoverComponent
|
||||||
],
|
],
|
||||||
entryComponents: [
|
entryComponents: [
|
||||||
AddonModScormIndexComponent
|
AddonModScormIndexComponent,
|
||||||
|
AddonModScormTocPopoverComponent
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
export class AddonModScormComponentsModule {}
|
export class AddonModScormComponentsModule {}
|
||||||
|
|
|
@ -0,0 +1,19 @@
|
||||||
|
<ion-list>
|
||||||
|
<ion-item text-wrap *ngIf="attemptToContinue">
|
||||||
|
<p>{{ 'addon.mod_scorm.dataattemptshown' | translate:{number: attemptToContinue} }}</p>
|
||||||
|
</ion-item>
|
||||||
|
<ion-item text-center *ngIf="isBrowse">
|
||||||
|
<p>{{ 'addon.mod_scorm.mod_scorm.browsemode' }}</p>
|
||||||
|
</ion-item>
|
||||||
|
<ion-item text-center *ngIf="isReview">
|
||||||
|
<p>{{ 'addon.mod_scorm.mod_scorm.reviewmode' }}</p>
|
||||||
|
</ion-item>
|
||||||
|
|
||||||
|
<!-- List of SCOs. -->
|
||||||
|
<ng-container *ngFor="let sco of toc">
|
||||||
|
<a *ngIf="sco.isvisible" ion-item text-wrap [ngClass]="['core-padding-' + sco.level]" (click)="loadSco(sco)" [attr.disabled]="!sco.prereq || !sco.launch ? true : null" detail-none>
|
||||||
|
<img [src]="sco.image.url" [alt]="sco.image.description" />
|
||||||
|
<span>{{ sco.title }}</span>
|
||||||
|
</a>
|
||||||
|
</ng-container>
|
||||||
|
</ion-list>
|
|
@ -0,0 +1,3 @@
|
||||||
|
addon-mod-scorm-toc-popover {
|
||||||
|
|
||||||
|
}
|
|
@ -0,0 +1,54 @@
|
||||||
|
// (C) Copyright 2015 Martin Dougiamas
|
||||||
|
//
|
||||||
|
// 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 } from '@angular/core';
|
||||||
|
import { NavParams, ViewController } from 'ionic-angular';
|
||||||
|
import { AddonModScormProvider } from '../../providers/scorm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Component to display the TOC of a SCORM.
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'addon-mod-scorm-toc-popover',
|
||||||
|
templateUrl: 'toc-popover.html'
|
||||||
|
})
|
||||||
|
export class AddonModScormTocPopoverComponent {
|
||||||
|
toc: any[];
|
||||||
|
isBrowse: boolean;
|
||||||
|
isReview: boolean;
|
||||||
|
attemptToContinue: number;
|
||||||
|
|
||||||
|
constructor(navParams: NavParams, private viewCtrl: ViewController) {
|
||||||
|
this.toc = navParams.get('toc') || [];
|
||||||
|
this.attemptToContinue = navParams.get('attemptToContinue');
|
||||||
|
|
||||||
|
const mode = navParams.get('mode');
|
||||||
|
|
||||||
|
this.isBrowse = mode === AddonModScormProvider.MODEBROWSE;
|
||||||
|
this.isReview = mode === AddonModScormProvider.MODEREVIEW;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Function called when a SCO is clicked.
|
||||||
|
*
|
||||||
|
* @param {any} sco Clicked SCO.
|
||||||
|
*/
|
||||||
|
loadSco(sco: any): void {
|
||||||
|
if (!sco.prereq || !sco.isvisible || !sco.launch) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.viewCtrl.dismiss(sco);
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,19 @@
|
||||||
|
<ion-header>
|
||||||
|
<ion-navbar>
|
||||||
|
<ion-title><core-format-text [text]="title"></core-format-text></ion-title>
|
||||||
|
|
||||||
|
<ion-buttons end>
|
||||||
|
<button *ngIf="showToc && !loadingToc && toc && toc.length" ion-button icon-only (click)="openToc($event)">
|
||||||
|
<ion-icon name="bookmark"></ion-icon>
|
||||||
|
</button>
|
||||||
|
<ion-spinner *ngIf="showToc && loadingToc"></ion-spinner>
|
||||||
|
</ion-buttons>
|
||||||
|
</ion-navbar>
|
||||||
|
</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]="scorm.popup ? scorm.width : undefined" [iframeHeight]="scorm.popup ? scorm.height : undefined"></core-iframe>
|
||||||
|
<p *ngIf="!src && errorMessage">{{ errorMessage | translate }}</p>
|
||||||
|
</core-loading>
|
||||||
|
</ion-content>
|
|
@ -0,0 +1,33 @@
|
||||||
|
// (C) Copyright 2015 Martin Dougiamas
|
||||||
|
//
|
||||||
|
// 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 { IonicPageModule } from 'ionic-angular';
|
||||||
|
import { TranslateModule } from '@ngx-translate/core';
|
||||||
|
import { CoreComponentsModule } from '@components/components.module';
|
||||||
|
import { CoreDirectivesModule } from '@directives/directives.module';
|
||||||
|
import { AddonModScormPlayerPage } from './player';
|
||||||
|
|
||||||
|
@NgModule({
|
||||||
|
declarations: [
|
||||||
|
AddonModScormPlayerPage,
|
||||||
|
],
|
||||||
|
imports: [
|
||||||
|
CoreComponentsModule,
|
||||||
|
CoreDirectivesModule,
|
||||||
|
IonicPageModule.forChild(AddonModScormPlayerPage),
|
||||||
|
TranslateModule.forChild()
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export class AddonModScormPlayerPageModule {}
|
|
@ -0,0 +1,450 @@
|
||||||
|
// (C) Copyright 2015 Martin Dougiamas
|
||||||
|
//
|
||||||
|
// 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 { IonicPage, NavParams, PopoverController } from 'ionic-angular';
|
||||||
|
import { CoreEventsProvider } from '@providers/events';
|
||||||
|
import { CoreSitesProvider } from '@providers/sites';
|
||||||
|
import { CoreSyncProvider } from '@providers/sync';
|
||||||
|
import { CoreDomUtilsProvider } from '@providers/utils/dom';
|
||||||
|
import { CoreTimeUtilsProvider } from '@providers/utils/time';
|
||||||
|
import { AddonModScormProvider, AddonModScormAttemptCountResult } from '../../providers/scorm';
|
||||||
|
import { AddonModScormHelperProvider } from '../../providers/helper';
|
||||||
|
import { AddonModScormSyncProvider } from '../../providers/scorm-sync';
|
||||||
|
import { AddonModScormDataModel12 } from '../../classes/data-model-12';
|
||||||
|
import { AddonModScormTocPopoverComponent } from '../../components/toc-popover/toc-popover';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Page that allows playing a SCORM.
|
||||||
|
*/
|
||||||
|
@IonicPage({ segment: 'addon-mod-scorm-player' })
|
||||||
|
@Component({
|
||||||
|
selector: 'page-addon-mod-scorm-player',
|
||||||
|
templateUrl: 'player.html',
|
||||||
|
})
|
||||||
|
export class AddonModScormPlayerPage implements OnInit, OnDestroy {
|
||||||
|
|
||||||
|
title: string; // Title.
|
||||||
|
scorm: any; // The SCORM object.
|
||||||
|
showToc: boolean; // Whether to show the table of contents (TOC).
|
||||||
|
loadingToc = true; // Whether the TOC is being loaded.
|
||||||
|
toc: any[]; // List of SCOs.
|
||||||
|
loaded: boolean; // Whether the data has been loaded.
|
||||||
|
previousSco: any; // Previous SCO.
|
||||||
|
nextSco: any; // Next SCO.
|
||||||
|
src: string; // Iframe src.
|
||||||
|
errorMessage: string; // Error message.
|
||||||
|
|
||||||
|
protected siteId: string;
|
||||||
|
protected mode: string; // Mode to play the SCORM.
|
||||||
|
protected newAttempt: boolean; // 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: any; // User data.
|
||||||
|
protected initialScoId: number; // Initial SCO ID to load.
|
||||||
|
protected currentSco: any; // Current SCO.
|
||||||
|
protected dataModel: AddonModScormDataModel12; // Data Model.
|
||||||
|
protected attemptToContinue: number; // Attempt to continue (for the popover).
|
||||||
|
|
||||||
|
// Observers.
|
||||||
|
protected tocObserver: any;
|
||||||
|
protected launchNextObserver: any;
|
||||||
|
protected launchPrevObserver: any;
|
||||||
|
protected goOfflineObserver: any;
|
||||||
|
|
||||||
|
constructor(navParams: NavParams, protected popoverCtrl: PopoverController, protected eventsProvider: CoreEventsProvider,
|
||||||
|
protected sitesProvider: CoreSitesProvider, protected syncProvider: CoreSyncProvider,
|
||||||
|
protected domUtils: CoreDomUtilsProvider, protected timeUtils: CoreTimeUtilsProvider,
|
||||||
|
protected scormProvider: AddonModScormProvider, protected scormHelper: AddonModScormHelperProvider,
|
||||||
|
protected scormSyncProvider: AddonModScormSyncProvider) {
|
||||||
|
|
||||||
|
this.scorm = navParams.get('scorm') || {};
|
||||||
|
this.mode = navParams.get('mode') || AddonModScormProvider.MODENORMAL;
|
||||||
|
this.newAttempt = !!navParams.get('newAttempt');
|
||||||
|
this.organizationId = navParams.get('organizationId');
|
||||||
|
this.initialScoId = navParams.get('scoId');
|
||||||
|
this.siteId = this.sitesProvider.getCurrentSiteId();
|
||||||
|
|
||||||
|
// We use SCORM name at start, later we'll use the SCO title.
|
||||||
|
this.title = this.scorm.name;
|
||||||
|
|
||||||
|
// Block the SCORM so it cannot be synchronized.
|
||||||
|
this.syncProvider.blockOperation(AddonModScormProvider.COMPONENT, this.scorm.id, 'player');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Component being initialized.
|
||||||
|
*/
|
||||||
|
ngOnInit(): void {
|
||||||
|
|
||||||
|
this.showToc = this.scormProvider.displayTocInPlayer(this.scorm);
|
||||||
|
|
||||||
|
if (this.scorm.popup) {
|
||||||
|
// If we receive a value <= 100 we need to assume it's a percentage.
|
||||||
|
if (this.scorm.width <= 100) {
|
||||||
|
this.scorm.width = this.scorm.width + '%';
|
||||||
|
}
|
||||||
|
if (this.scorm.height <= 100) {
|
||||||
|
this.scorm.height = this.scorm.height + '%';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch the SCORM data.
|
||||||
|
this.fetchData().then(() => {
|
||||||
|
if (this.currentSco) {
|
||||||
|
// Set start time if it's a new attempt.
|
||||||
|
const promise = this.newAttempt ? this.setStartTime(this.currentSco.id) : Promise.resolve();
|
||||||
|
|
||||||
|
return promise.catch((error) => {
|
||||||
|
this.domUtils.showErrorModalDefault(error, 'addon.mod_scorm.errorgetscorm', true);
|
||||||
|
}).finally(() => {
|
||||||
|
// Load SCO.
|
||||||
|
this.loadSco(this.currentSco);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}).finally(() => {
|
||||||
|
this.loaded = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Listen for events to update the TOC, navigate through SCOs and go offline.
|
||||||
|
this.tocObserver = this.eventsProvider.on(AddonModScormProvider.UPDATE_TOC_EVENT, (data) => {
|
||||||
|
if (data.scormId === this.scorm.id) {
|
||||||
|
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 = this.eventsProvider.on(AddonModScormProvider.LAUNCH_NEXT_SCO_EVENT, (data) => {
|
||||||
|
if (data.scormId === this.scorm.id && this.nextSco) {
|
||||||
|
this.loadSco(this.nextSco);
|
||||||
|
}
|
||||||
|
}, this.siteId);
|
||||||
|
|
||||||
|
this.launchPrevObserver = this.eventsProvider.on(AddonModScormProvider.LAUNCH_PREV_SCO_EVENT, (data) => {
|
||||||
|
if (data.scormId === this.scorm.id && this.previousSco) {
|
||||||
|
this.loadSco(this.previousSco);
|
||||||
|
}
|
||||||
|
}, this.siteId);
|
||||||
|
|
||||||
|
this.goOfflineObserver = this.eventsProvider.on(AddonModScormProvider.GO_OFFLINE_EVENT, (data) => {
|
||||||
|
if (data.scormId === this.scorm.id && !this.offline) {
|
||||||
|
this.offline = true;
|
||||||
|
|
||||||
|
// Wait a bit to prevent collisions between this store and SCORM API's store.
|
||||||
|
setTimeout(() => {
|
||||||
|
this.scormHelper.convertAttemptToOffline(this.scorm, this.attempt).catch((error) => {
|
||||||
|
this.domUtils.showErrorModalDefault(error, 'core.error', true);
|
||||||
|
}).then(() => {
|
||||||
|
this.refreshToc();
|
||||||
|
});
|
||||||
|
}, 200);
|
||||||
|
}
|
||||||
|
}, this.siteId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate the next and previous SCO.
|
||||||
|
*
|
||||||
|
* @param {number} scoId Current SCO ID.
|
||||||
|
*/
|
||||||
|
protected calculateNextAndPreviousSco(scoId: number): void {
|
||||||
|
this.previousSco = this.scormHelper.getPreviousScoFromToc(this.toc, scoId);
|
||||||
|
this.nextSco = this.scormHelper.getNextScoFromToc(this.toc, scoId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine the attempt to use, the mode (normal/preview) and if it's offline or online.
|
||||||
|
*
|
||||||
|
* @param {AddonModScormAttemptCountResult} attemptsData Attempts count.
|
||||||
|
* @return {Promise<any>} Promise resolved when done.
|
||||||
|
*/
|
||||||
|
protected determineAttemptAndMode(attemptsData: AddonModScormAttemptCountResult): Promise<any> {
|
||||||
|
let result;
|
||||||
|
|
||||||
|
return this.scormHelper.determineAttemptToContinue(this.scorm, attemptsData).then((data) => {
|
||||||
|
this.attempt = data.number;
|
||||||
|
this.offline = data.offline;
|
||||||
|
|
||||||
|
if (this.attempt != attemptsData.lastAttempt.number) {
|
||||||
|
this.attemptToContinue = this.attempt;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if current attempt is incomplete.
|
||||||
|
if (this.attempt > 0) {
|
||||||
|
return this.scormProvider.isAttemptIncomplete(this.scorm.id, this.attempt, this.offline);
|
||||||
|
} else {
|
||||||
|
// User doesn't have attempts. Last attempt is not incomplete (since he doesn't have any).
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}).then((incomplete) => {
|
||||||
|
// Determine mode and attempt to use.
|
||||||
|
result = this.scormProvider.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.
|
||||||
|
return this.scormHelper.createOfflineAttempt(this.scorm, result.attempt, attemptsData.online.length);
|
||||||
|
} else {
|
||||||
|
// Last attempt was online, verify that we can create a new online attempt. We ignore cache.
|
||||||
|
return this.scormProvider.getScormUserData(this.scorm.id, result.attempt, undefined, false, true).catch(() => {
|
||||||
|
// Cannot communicate with the server, create an offline attempt.
|
||||||
|
this.offline = true;
|
||||||
|
|
||||||
|
return this.scormHelper.createOfflineAttempt(this.scorm, result.attempt, attemptsData.online.length);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}).then(() => {
|
||||||
|
this.mode = result.mode;
|
||||||
|
this.newAttempt = result.newAttempt;
|
||||||
|
this.attempt = result.attempt;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch data needed to play the SCORM.
|
||||||
|
*
|
||||||
|
* @return {Promise<any>} Promise resolved when done.
|
||||||
|
*/
|
||||||
|
protected fetchData(): Promise<any> {
|
||||||
|
// Wait for any ongoing sync to finish. We won't sync a SCORM while it's being played.
|
||||||
|
return this.scormSyncProvider.waitForSync(this.scorm.id).then(() => {
|
||||||
|
// Get attempts data.
|
||||||
|
return this.scormProvider.getAttemptCount(this.scorm.id).then((attemptsData) => {
|
||||||
|
return this.determineAttemptAndMode(attemptsData).then(() => {
|
||||||
|
// Fetch TOC and get user data.
|
||||||
|
const promises = [];
|
||||||
|
|
||||||
|
promises.push(this.fetchToc());
|
||||||
|
promises.push(this.scormProvider.getScormUserData(this.scorm.id, this.attempt, undefined, this.offline)
|
||||||
|
.then((data) => {
|
||||||
|
this.userData = data;
|
||||||
|
}));
|
||||||
|
|
||||||
|
return Promise.all(promises);
|
||||||
|
});
|
||||||
|
}).catch((error) => {
|
||||||
|
this.domUtils.showErrorModalDefault(error, 'addon.mod_scorm.errorgetscorm', true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch the TOC.
|
||||||
|
*
|
||||||
|
* @return {Promise<any>} Promise resolved when done.
|
||||||
|
*/
|
||||||
|
protected fetchToc(): Promise<any> {
|
||||||
|
this.loadingToc = true;
|
||||||
|
|
||||||
|
// We need to check incomplete again: attempt number or status might have changed.
|
||||||
|
return this.scormProvider.isAttemptIncomplete(this.scorm.id, this.attempt, this.offline).then((incomplete) => {
|
||||||
|
this.scorm.incomplete = incomplete;
|
||||||
|
|
||||||
|
// Get TOC.
|
||||||
|
return this.scormProvider.getOrganizationToc(this.scorm.id, this.attempt, this.organizationId, this.offline);
|
||||||
|
}).then((toc) => {
|
||||||
|
this.toc = this.scormProvider.formatTocToArray(toc);
|
||||||
|
|
||||||
|
// Get images for each SCO.
|
||||||
|
this.toc.forEach((sco) => {
|
||||||
|
sco.image = this.scormProvider.getScoStatusIcon(sco, this.scorm.incomplete);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Determine current SCO if we received an ID..
|
||||||
|
if (this.initialScoId > 0) {
|
||||||
|
// SCO set by parameter, get it from TOC.
|
||||||
|
this.currentSco = this.scormHelper.getScoFromToc(this.toc, this.initialScoId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.currentSco) {
|
||||||
|
// No SCO defined. Get the first valid one.
|
||||||
|
return this.scormHelper.getFirstSco(this.scorm.id, this.attempt, this.toc, this.organizationId, this.offline)
|
||||||
|
.then((sco) => {
|
||||||
|
|
||||||
|
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 {
|
||||||
|
// Empty src when leaving the state so unload event is triggered in the iframe.
|
||||||
|
this.src = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load a SCO.
|
||||||
|
*
|
||||||
|
* @param {any} sco The SCO to load.
|
||||||
|
*/
|
||||||
|
protected loadSco(sco: any): void {
|
||||||
|
if (!this.dataModel) {
|
||||||
|
// Create the model.
|
||||||
|
this.dataModel = new AddonModScormDataModel12(this.eventsProvider, this.scormProvider, 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.
|
||||||
|
(<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.scormProvider.getScoSrc(this.scorm, sco).then((src) => {
|
||||||
|
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 = '';
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
this.src = src;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
this.src = src;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (sco.scormtype == 'asset') {
|
||||||
|
// Mark the asset as completed.
|
||||||
|
const tracks = [{
|
||||||
|
element: 'cmi.core.lesson_status',
|
||||||
|
value: 'completed'
|
||||||
|
}];
|
||||||
|
|
||||||
|
this.scormProvider.saveTracks(sco.id, this.attempt, tracks, this.scorm, this.offline).catch(() => {
|
||||||
|
// Error saving data. We'll go offline if we're online and the asset is not marked as completed already.
|
||||||
|
if (!this.offline) {
|
||||||
|
return this.scormProvider.getScormUserData(this.scorm.id, this.attempt, undefined, false).then((data) => {
|
||||||
|
if (!data[sco.id] || data[sco.id].userdata['cmi.core.lesson_status'] != 'completed') {
|
||||||
|
// Go offline.
|
||||||
|
return this.scormHelper.convertAttemptToOffline(this.scorm, this.attempt).then(() => {
|
||||||
|
this.offline = true;
|
||||||
|
this.dataModel.setOffline(true);
|
||||||
|
|
||||||
|
return this.scormProvider.saveTracks(sco.id, this.attempt, tracks, this.scorm, true);
|
||||||
|
}).catch((error) => {
|
||||||
|
this.domUtils.showErrorModalDefault(error, 'core.error', true);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}).then(() => {
|
||||||
|
// Refresh TOC, some prerequisites might have changed.
|
||||||
|
this.refreshToc();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trigger SCO launch event.
|
||||||
|
this.scormProvider.logLaunchSco(this.scorm.id, sco.id).catch(() => {
|
||||||
|
// Ignore errors.
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show the TOC.
|
||||||
|
*
|
||||||
|
* @param {MouseEvent} event Event.
|
||||||
|
*/
|
||||||
|
openToc(event: MouseEvent): void {
|
||||||
|
const popover = this.popoverCtrl.create(AddonModScormTocPopoverComponent, {
|
||||||
|
toc: this.toc,
|
||||||
|
attemptToContinue: this.attemptToContinue,
|
||||||
|
mode: this.mode
|
||||||
|
});
|
||||||
|
|
||||||
|
// If the popover sends back a SCO, load it.
|
||||||
|
popover.onDidDismiss((sco) => {
|
||||||
|
if (sco) {
|
||||||
|
this.loadSco(sco);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
popover.present({
|
||||||
|
ev: event
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Refresh the TOC.
|
||||||
|
*
|
||||||
|
* @return {Promise<any>} Promise resolved when done.
|
||||||
|
*/
|
||||||
|
protected refreshToc(): Promise<any> {
|
||||||
|
return this.scormProvider.invalidateAllScormData(this.scorm.id).catch(() => {
|
||||||
|
// Ignore errors.
|
||||||
|
}).then(() => {
|
||||||
|
return this.fetchToc();
|
||||||
|
}).catch((error) => {
|
||||||
|
this.domUtils.showErrorModalDefault(error, 'addon.mod_scorm.errorgetscorm', true);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set SCORM start time.
|
||||||
|
*
|
||||||
|
* @param {number} scoId SCO ID.
|
||||||
|
* @return {Promise<any>} Promise resolved when done.
|
||||||
|
*/
|
||||||
|
protected setStartTime(scoId: number): Promise<any> {
|
||||||
|
const tracks = [{
|
||||||
|
element: 'x.start.time',
|
||||||
|
value: this.timeUtils.timestamp()
|
||||||
|
}];
|
||||||
|
|
||||||
|
return this.scormProvider.saveTracks(scoId, this.attempt, tracks, this.scorm, this.offline).then(() => {
|
||||||
|
if (!this.offline) {
|
||||||
|
// New online attempt created, update cached data about online attempts.
|
||||||
|
this.scormProvider.getAttemptCount(this.scorm.id, false, true).catch(() => {
|
||||||
|
// Ignore errors.
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Component being destroyed.
|
||||||
|
*/
|
||||||
|
ngOnDestroy(): void {
|
||||||
|
// Stop listening for events.
|
||||||
|
this.tocObserver && this.tocObserver.off();
|
||||||
|
this.launchNextObserver && this.launchNextObserver.off();
|
||||||
|
this.launchPrevObserver && this.launchPrevObserver.off();
|
||||||
|
this.goOfflineObserver && this.goOfflineObserver.off();
|
||||||
|
|
||||||
|
// Unblock the SCORM so it can be synced.
|
||||||
|
this.syncProvider.unblockOperation(AddonModScormProvider.COMPONENT, this.scorm.id, 'player');
|
||||||
|
}
|
||||||
|
}
|
|
@ -13,8 +13,12 @@
|
||||||
// limitations under the License.
|
// limitations under the License.
|
||||||
|
|
||||||
import { Injectable } from '@angular/core';
|
import { Injectable } from '@angular/core';
|
||||||
|
import { TranslateService } from '@ngx-translate/core';
|
||||||
|
import { CoreSitesProvider } from '@providers/sites';
|
||||||
import { CoreDomUtilsProvider } from '@providers/utils/dom';
|
import { CoreDomUtilsProvider } from '@providers/utils/dom';
|
||||||
|
import { CoreUtilsProvider } from '@providers/utils/utils';
|
||||||
import { AddonModScormProvider, AddonModScormAttemptCountResult } from './scorm';
|
import { AddonModScormProvider, AddonModScormAttemptCountResult } from './scorm';
|
||||||
|
import { AddonModScormOfflineProvider } from './scorm-offline';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Helper service that provides some features for SCORM.
|
* Helper service that provides some features for SCORM.
|
||||||
|
@ -22,9 +26,13 @@ import { AddonModScormProvider, AddonModScormAttemptCountResult } from './scorm'
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AddonModScormHelperProvider {
|
export class AddonModScormHelperProvider {
|
||||||
|
|
||||||
protected div = document.createElement('div'); // A div element to search in HTML code.
|
// List of elements we want to ignore when copying attempts (they're calculated).
|
||||||
|
protected elementsToIgnore = ['status', 'score_raw', 'total_time', 'session_time', 'student_id', 'student_name', 'credit',
|
||||||
|
'mode', 'entry'];
|
||||||
|
|
||||||
constructor(private domUtils: CoreDomUtilsProvider, private scormProvider: AddonModScormProvider) { }
|
constructor(private sitesProvider: CoreSitesProvider, private translate: TranslateService,
|
||||||
|
private domUtils: CoreDomUtilsProvider, private utils: CoreUtilsProvider,
|
||||||
|
private scormProvider: AddonModScormProvider, private scormOfflineProvider: AddonModScormOfflineProvider) { }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Show a confirm dialog if needed. If SCORM doesn't have size, try to calculate it.
|
* Show a confirm dialog if needed. If SCORM doesn't have size, try to calculate it.
|
||||||
|
@ -58,6 +66,93 @@ export class AddonModScormHelperProvider {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new offline attempt based on an existing online attempt.
|
||||||
|
*
|
||||||
|
* @param {any} scorm SCORM.
|
||||||
|
* @param {number} attempt Number of the online attempt.
|
||||||
|
* @param {string} [siteId] Site ID. If not defined, current site.
|
||||||
|
* @return {Promise<any>} Promise resolved when the attempt is created.
|
||||||
|
*/
|
||||||
|
convertAttemptToOffline(scorm: any, attempt: number, siteId?: string): Promise<any> {
|
||||||
|
siteId = siteId || this.sitesProvider.getCurrentSiteId();
|
||||||
|
|
||||||
|
// Get data from the online attempt.
|
||||||
|
return this.scormProvider.getScormUserData(scorm.id, attempt, undefined, false, false, siteId).then((onlineData) => {
|
||||||
|
// The SCORM API might have written some data to the offline attempt already.
|
||||||
|
// We don't want to override it with cached online data.
|
||||||
|
return this.scormOfflineProvider.getScormUserData(scorm.id, attempt, undefined, siteId).catch(() => {
|
||||||
|
// Ignore errors.
|
||||||
|
}).then((offlineData) => {
|
||||||
|
const dataToStore = this.utils.clone(onlineData);
|
||||||
|
|
||||||
|
// Filter the data to copy.
|
||||||
|
for (const scoId in dataToStore) {
|
||||||
|
const sco = dataToStore[scoId];
|
||||||
|
|
||||||
|
// Delete calculated data.
|
||||||
|
this.elementsToIgnore.forEach((el) => {
|
||||||
|
delete sco.userdata[el];
|
||||||
|
});
|
||||||
|
|
||||||
|
// Don't override offline data.
|
||||||
|
if (offlineData && offlineData[sco.scoid] && offlineData[sco.scoid].userdata) {
|
||||||
|
const scoUserData = {};
|
||||||
|
|
||||||
|
for (const element in sco.userdata) {
|
||||||
|
if (!offlineData[sco.scoid].userdata[element]) {
|
||||||
|
// This element is not stored in offline, we can save it.
|
||||||
|
scoUserData[element] = sco.userdata[element];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sco.userdata = scoUserData;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.scormOfflineProvider.createNewAttempt(scorm, attempt, dataToStore, onlineData, siteId);
|
||||||
|
});
|
||||||
|
}).catch(() => {
|
||||||
|
// Shouldn't happen.
|
||||||
|
return Promise.reject(this.translate.instant('addon.mod_scorm.errorcreateofflineattempt'));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new offline attempt.
|
||||||
|
*
|
||||||
|
* @param {any} scorm SCORM.
|
||||||
|
* @param {number} newAttempt Number of the new attempt.
|
||||||
|
* @param {number} lastOnline Number of the last online attempt.
|
||||||
|
* @param {string} [siteId] Site ID. If not defined, current site.
|
||||||
|
* @return {Promise<any>} Promise resolved when the attempt is created.
|
||||||
|
*/
|
||||||
|
createOfflineAttempt(scorm: any, newAttempt: number, lastOnline: number, siteId?: string): Promise<any> {
|
||||||
|
siteId = siteId || this.sitesProvider.getCurrentSiteId();
|
||||||
|
|
||||||
|
// Try to get data from online attempts.
|
||||||
|
return this.searchOnlineAttemptUserData(scorm.id, lastOnline, siteId).then((userData) => {
|
||||||
|
// We're creating a new attempt, remove all the user data that is not needed for a new attempt.
|
||||||
|
for (const scoId in userData) {
|
||||||
|
const sco = userData[scoId],
|
||||||
|
filtered = {};
|
||||||
|
|
||||||
|
for (const element in sco.userdata) {
|
||||||
|
if (element.indexOf('.') == -1 && this.elementsToIgnore.indexOf(element) == -1) {
|
||||||
|
// The element doesn't use a dot notation, probably SCO data.
|
||||||
|
filtered[element] = sco.userdata[element];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sco.userdata = filtered;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.scormOfflineProvider.createNewAttempt(scorm, newAttempt, userData, undefined, siteId);
|
||||||
|
}).catch(() => {
|
||||||
|
return Promise.reject(this.translate.instant('addon.mod_scorm.errorcreateofflineattempt'));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Determines the attempt to continue/review. It will be:
|
* Determines the attempt to continue/review. It will be:
|
||||||
* - The last incomplete online attempt if it hasn't been continued in offline and all offline attempts are complete.
|
* - The last incomplete online attempt if it hasn't been continued in offline and all offline attempts are complete.
|
||||||
|
@ -97,6 +192,41 @@ export class AddonModScormHelperProvider {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the first SCO to load in a SCORM. If a non-empty TOC is provided, it will be the first valid SCO in the TOC.
|
||||||
|
* Otherwise, it will be the first valid SCO returned by $mmaModScorm#getScos.
|
||||||
|
*
|
||||||
|
* @param {number} scormId Scorm ID.
|
||||||
|
* @param {number} attempt Attempt number.
|
||||||
|
* @param {any[]} [toc] SCORM's TOC.
|
||||||
|
* @param {string} [organization] Organization to use.
|
||||||
|
* @param {boolean} [offline] Whether the attempt is offline.
|
||||||
|
* @param {string} [siteId] Site ID. If not defined, current site.
|
||||||
|
* @return {Promise<any>} Promise resolved with the first SCO.
|
||||||
|
*/
|
||||||
|
getFirstSco(scormId: number, attempt: number, toc?: any[], organization?: string, offline?: boolean, siteId?: string)
|
||||||
|
: Promise<any> {
|
||||||
|
|
||||||
|
let promise;
|
||||||
|
if (toc && toc.length) {
|
||||||
|
promise = Promise.resolve(toc);
|
||||||
|
} else {
|
||||||
|
// SCORM doesn't have a TOC. Get all the scos.
|
||||||
|
promise = this.scormProvider.getScosWithData(scormId, attempt, organization, offline, false, siteId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return promise.then((scos) => {
|
||||||
|
// Search the first valid SCO.
|
||||||
|
for (let i = 0; i < scos.length; i++) {
|
||||||
|
const sco = scos[i];
|
||||||
|
|
||||||
|
if (sco.isvisible && sco.prereq && sco.launch) {
|
||||||
|
return sco;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the last attempt (number and whether it's offline).
|
* Get the last attempt (number and whether it's offline).
|
||||||
* It'll be the highest number as long as it doesn't surpass the max number of attempts.
|
* It'll be the highest number as long as it doesn't surpass the max number of attempts.
|
||||||
|
@ -118,4 +248,83 @@ export class AddonModScormHelperProvider {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Given a TOC in array format and a scoId, return the next available SCO.
|
||||||
|
*
|
||||||
|
* @param {any[]} toc SCORM's TOC.
|
||||||
|
* @param {number} scoId SCO ID.
|
||||||
|
* @return {any} Next SCO.
|
||||||
|
*/
|
||||||
|
getNextScoFromToc(toc: any, scoId: number): any {
|
||||||
|
for (let i = 0; i < toc.length; i++) {
|
||||||
|
if (toc[i].id == scoId) {
|
||||||
|
// We found the current SCO. Now let's search the next visible SCO with fulfilled prerequisites.
|
||||||
|
for (let j = i + 1; j < toc.length; j++) {
|
||||||
|
if (toc[j].isvisible && toc[j].prereq && toc[j].launch) {
|
||||||
|
return toc[j];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Given a TOC in array format and a scoId, return the previous available SCO.
|
||||||
|
*
|
||||||
|
* @param {any[]} toc SCORM's TOC.
|
||||||
|
* @param {number} scoId SCO ID.
|
||||||
|
* @return {any} Previous SCO.
|
||||||
|
*/
|
||||||
|
getPreviousScoFromToc(toc: any, scoId: number): any {
|
||||||
|
for (let i = 0; i < toc.length; i++) {
|
||||||
|
if (toc[i].id == scoId) {
|
||||||
|
// We found the current SCO. Now let's search the previous visible SCO with fulfilled prerequisites.
|
||||||
|
for (let j = i - 1; j >= 0; j--) {
|
||||||
|
if (toc[j].isvisible && toc[j].prereq && toc[j].launch) {
|
||||||
|
return toc[j];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Given a TOC in array format and a scoId, return the SCO.
|
||||||
|
*
|
||||||
|
* @param {any[]} toc SCORM's TOC.
|
||||||
|
* @param {number} scoId SCO ID.
|
||||||
|
* @return {any} SCO.
|
||||||
|
*/
|
||||||
|
getScoFromToc(toc: any[], scoId: number): any {
|
||||||
|
for (let i = 0; i < toc.length; i++) {
|
||||||
|
if (toc[i].id == scoId) {
|
||||||
|
return toc[i];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Searches user data for an online attempt. If the data can't be retrieved, re-try with the previous online attempt.
|
||||||
|
*
|
||||||
|
* @param {number} scormId SCORM ID.
|
||||||
|
* @param {number} attempt Online attempt to get the data.
|
||||||
|
* @param {string} [siteId] Site ID. If not defined, current site.
|
||||||
|
* @return {Promise<any>} Promise resolved with user data.
|
||||||
|
*/
|
||||||
|
searchOnlineAttemptUserData(scormId: number, attempt: number, siteId?: string): Promise<any> {
|
||||||
|
siteId = siteId || this.sitesProvider.getCurrentSiteId();
|
||||||
|
|
||||||
|
return this.scormProvider.getScormUserData(scormId, attempt, undefined, false, false, siteId).catch(() => {
|
||||||
|
if (attempt > 0) {
|
||||||
|
// We couldn't retrieve the data. Try again with the previous online attempt.
|
||||||
|
return this.searchOnlineAttemptUserData(scormId, attempt - 1, siteId);
|
||||||
|
} else {
|
||||||
|
// No more attempts to try. Reject
|
||||||
|
return Promise.reject(null);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -139,6 +139,13 @@ export class CoreIframeComponent implements OnInit, OnChanges {
|
||||||
winAndDoc = this.getContentWindowAndDocument(element);
|
winAndDoc = this.getContentWindowAndDocument(element);
|
||||||
this.redefineWindowOpen(element, winAndDoc.window, winAndDoc.document);
|
this.redefineWindowOpen(element, winAndDoc.window, winAndDoc.document);
|
||||||
this.treatLinks(element, winAndDoc.document);
|
this.treatLinks(element, winAndDoc.document);
|
||||||
|
|
||||||
|
if (winAndDoc.window) {
|
||||||
|
// Send a resize events to the iframe so it calculates the right size if needed.
|
||||||
|
setTimeout(() => {
|
||||||
|
winAndDoc.window.dispatchEvent(new Event('resize'));
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue