commit
d23160df19
|
@ -980,10 +980,7 @@
|
|||
"addon.mod_scorm.errorcreateofflineattempt": "local_moodlemobileapp",
|
||||
"addon.mod_scorm.errordownloadscorm": "local_moodlemobileapp",
|
||||
"addon.mod_scorm.errorgetscorm": "local_moodlemobileapp",
|
||||
"addon.mod_scorm.errorinvalidversion": "local_moodlemobileapp",
|
||||
"addon.mod_scorm.errornotdownloadable": "local_moodlemobileapp",
|
||||
"addon.mod_scorm.errornovalidsco": "local_moodlemobileapp",
|
||||
"addon.mod_scorm.errorpackagefile": "local_moodlemobileapp",
|
||||
"addon.mod_scorm.errorsyncscorm": "local_moodlemobileapp",
|
||||
"addon.mod_scorm.exceededmaxattempts": "scorm",
|
||||
"addon.mod_scorm.failed": "scorm",
|
||||
|
|
|
@ -17,7 +17,7 @@
|
|||
|
||||
<!-- Activity info. -->
|
||||
<core-course-module-info [module]="module" [description]="description" [component]="component" [componentId]="componentId"
|
||||
[courseId]="courseId" [hasDataToSync]="!errorMessage && hasOffline" (completionChanged)="onCompletionChange()" />
|
||||
[courseId]="courseId" [hasDataToSync]="hasOffline" (completionChanged)="onCompletionChange()" />
|
||||
|
||||
<!-- Warning message. -->
|
||||
<ion-card class="core-info-card" *ngIf="scorm && scorm.warningMessage">
|
||||
|
@ -162,21 +162,8 @@
|
|||
|
||||
<div collapsible-footer *ngIf="!showLoading" slot="fixed">
|
||||
<div class="list-item-limited-width" *ngIf="scorm && !scorm.warningMessage">
|
||||
<!-- Open in browser button. -->
|
||||
<ng-container *ngIf="errorMessage">
|
||||
<ion-item class="ion-text-wrap">
|
||||
<ion-label>
|
||||
<p class="text-danger">{{ errorMessage | translate }}</p>
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
<ion-button class="ion-margin ion-text-wrap" expand="block" [href]="module.url" core-link [showBrowserWarning]="false">
|
||||
{{ 'core.openinbrowser' | translate }}
|
||||
<ion-icon name="fas-up-right-from-square" slot="end" aria-hidden="true" />
|
||||
</ion-button>
|
||||
</ng-container>
|
||||
|
||||
<!-- Warning that user doesn't have any more attempts. -->
|
||||
<ion-card *ngIf="!errorMessage && attemptsLeft === 0" class="core-danger-card">
|
||||
<ion-card *ngIf="attemptsLeft === 0" class="core-danger-card">
|
||||
<ion-item class="ion-text-wrap">
|
||||
<ion-label>
|
||||
<p>{{ 'addon.mod_scorm.exceededmaxattempts' | translate }}</p>
|
||||
|
@ -184,7 +171,16 @@
|
|||
</ion-item>
|
||||
</ion-card>
|
||||
|
||||
<ng-container *ngIf="!errorMessage && (!scorm.lastattemptlock || attemptsLeft > 0)">
|
||||
<ion-card *ngIf="useOnlinePlayer && !isOnline" class="core-warning-card">
|
||||
<ion-item class="ion-text-wrap">
|
||||
<ion-icon name="fas-triangle-exclamation" slot="start" aria-hidden="true" />
|
||||
<ion-label>
|
||||
{{ 'core.course.activitynotavailableoffline' | translate }} {{ 'core.needinternettoaccessit' | translate }}
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
</ion-card>
|
||||
|
||||
<ng-container *ngIf="(!scorm.lastattemptlock || attemptsLeft > 0) && (!useOnlinePlayer || isOnline)">
|
||||
<!-- Open SCORM in app form -->
|
||||
<ng-container *ngIf="!downloading && !skip">
|
||||
<!-- Create new attempt -->
|
||||
|
|
|
@ -13,7 +13,7 @@
|
|||
// limitations under the License.
|
||||
|
||||
import { DownloadStatus } from '@/core/constants';
|
||||
import { Component, Input, OnInit, Optional } from '@angular/core';
|
||||
import { Component, Input, OnDestroy, OnInit, Optional } from '@angular/core';
|
||||
import { CoreError } from '@classes/errors/error';
|
||||
import { CoreCourseModuleMainActivityComponent } from '@features/course/classes/main-activity-component';
|
||||
import { CoreCourseContentsPage } from '@features/course/pages/contents/contents';
|
||||
|
@ -23,7 +23,7 @@ import { CoreNavigator } from '@services/navigator';
|
|||
import { CoreSync } from '@services/sync';
|
||||
import { CoreDomUtils } from '@services/utils/dom';
|
||||
import { CoreObject } from '@singletons/object';
|
||||
import { Translate } from '@singletons';
|
||||
import { NgZone, Translate } from '@singletons';
|
||||
import { CoreEventObserver, CoreEvents } from '@singletons/events';
|
||||
import { AddonModScormPrefetchHandler } from '../../services/handlers/prefetch';
|
||||
import {
|
||||
|
@ -51,6 +51,8 @@ import {
|
|||
} from '../../constants';
|
||||
import { CoreWait } from '@singletons/wait';
|
||||
import { CorePromiseUtils } from '@singletons/promise-utils';
|
||||
import { CoreNetwork } from '@services/network';
|
||||
import { Subscription } from 'rxjs';
|
||||
|
||||
/**
|
||||
* Component that displays a SCORM entry page.
|
||||
|
@ -60,7 +62,7 @@ import { CorePromiseUtils } from '@singletons/promise-utils';
|
|||
templateUrl: 'addon-mod-scorm-index.html',
|
||||
styleUrl: 'index.scss',
|
||||
})
|
||||
export class AddonModScormIndexComponent extends CoreCourseModuleMainActivityComponent implements OnInit {
|
||||
export class AddonModScormIndexComponent extends CoreCourseModuleMainActivityComponent implements OnInit, OnDestroy {
|
||||
|
||||
@Input() autoPlayData?: AddonModScormAutoPlayData; // Data to use to play the SCORM automatically.
|
||||
|
||||
|
@ -73,7 +75,6 @@ export class AddonModScormIndexComponent extends CoreCourseModuleMainActivityCom
|
|||
}; // Selected organization.
|
||||
|
||||
startNewAttempt = false;
|
||||
errorMessage?: string; // Error message.
|
||||
syncTime?: string; // Last sync time.
|
||||
hasOffline = false; // Whether the SCORM has offline data.
|
||||
attemptToContinue?: number; // The attempt to continue or review.
|
||||
|
@ -96,6 +97,7 @@ export class AddonModScormIndexComponent extends CoreCourseModuleMainActivityCom
|
|||
onlineAttempts: AttemptGrade[] = []; // Grades for online attempts.
|
||||
offlineAttempts: AttemptGrade[] = []; // Grades for offline attempts.
|
||||
gradesExpanded = false;
|
||||
isOnline: boolean;
|
||||
|
||||
protected fetchContentDefaultError = 'addon.mod_scorm.errorgetscorm'; // Default error to show when loading contents.
|
||||
protected syncEventName = ADDON_MOD_SCORM_DATA_AUTO_SYNCED;
|
||||
|
@ -105,12 +107,22 @@ export class AddonModScormIndexComponent extends CoreCourseModuleMainActivityCom
|
|||
protected hasPlayed = false; // Whether the user has opened the player page.
|
||||
protected dataSentObserver?: CoreEventObserver; // To detect data sent to server.
|
||||
protected dataSent = false; // Whether some data was sent to server while playing the SCORM.
|
||||
protected useOnlinePlayer = false; // Whether the SCORM needs to be played using an online player.
|
||||
protected onlineObserver: Subscription;
|
||||
|
||||
constructor(
|
||||
protected content?: IonContent,
|
||||
@Optional() courseContentsPage?: CoreCourseContentsPage,
|
||||
) {
|
||||
super('AddonModScormIndexComponent', content, courseContentsPage);
|
||||
|
||||
this.isOnline = CoreNetwork.isOnline();
|
||||
this.onlineObserver = CoreNetwork.onChange().subscribe(() => {
|
||||
// Execute the callback in the Angular zone, so change detection doesn't stop working.
|
||||
NgZone.run(() => {
|
||||
this.isOnline = CoreNetwork.isOnline();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -181,19 +193,19 @@ export class AddonModScormIndexComponent extends CoreCourseModuleMainActivityCom
|
|||
|
||||
this.dataRetrieved.emit(this.scorm);
|
||||
this.description = this.scorm.intro || this.description;
|
||||
this.errorMessage = AddonModScorm.isScormUnsupported(this.scorm);
|
||||
this.useOnlinePlayer = AddonModScorm.useOnlinePlayer(this.scorm);
|
||||
|
||||
if (this.scorm.warningMessage) {
|
||||
return; // SCORM is closed or not open yet, we can't get more data.
|
||||
}
|
||||
|
||||
if (sync) {
|
||||
if (sync && !this.useOnlinePlayer) {
|
||||
// Try to synchronize the SCORM.
|
||||
await CorePromiseUtils.ignoreErrors(this.syncActivity(showErrors));
|
||||
}
|
||||
|
||||
const [syncTime, accessInfo] = await Promise.all([
|
||||
AddonModScormSync.getReadableSyncTime(this.scorm.id),
|
||||
this.useOnlinePlayer ? undefined : AddonModScormSync.getReadableSyncTime(this.scorm.id),
|
||||
AddonModScorm.getAccessInformation(this.scorm.id, { cmId: this.module.id }),
|
||||
this.fetchAttemptData(this.scorm),
|
||||
]);
|
||||
|
@ -203,7 +215,7 @@ export class AddonModScormIndexComponent extends CoreCourseModuleMainActivityCom
|
|||
|
||||
// Check whether to launch the SCORM immediately.
|
||||
if (this.skip === undefined) {
|
||||
this.skip = !this.hasOffline && !this.errorMessage && (!this.scorm.lastattemptlock || this.attemptsLeft > 0) &&
|
||||
this.skip = !this.hasOffline && (!this.scorm.lastattemptlock || this.attemptsLeft > 0) &&
|
||||
(
|
||||
!!this.autoPlayData
|
||||
||
|
||||
|
@ -268,7 +280,7 @@ export class AddonModScormIndexComponent extends CoreCourseModuleMainActivityCom
|
|||
* @returns Promise resolved when done.
|
||||
*/
|
||||
protected async loadPackageSize(scorm: AddonModScormScorm): Promise<void> {
|
||||
if (scorm.packagesize || this.errorMessage) {
|
||||
if (scorm.packagesize || this.useOnlinePlayer) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
@ -498,6 +510,15 @@ export class AddonModScormIndexComponent extends CoreCourseModuleMainActivityCom
|
|||
event?.preventDefault();
|
||||
event?.stopPropagation();
|
||||
|
||||
if (this.useOnlinePlayer) {
|
||||
// No need to download the package, just open it.
|
||||
if (this.isOnline) {
|
||||
this.openScorm(scoId, preview);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.downloading || !this.scorm) {
|
||||
// Scope is being downloaded, abort.
|
||||
return;
|
||||
|
@ -569,8 +590,10 @@ export class AddonModScormIndexComponent extends CoreCourseModuleMainActivityCom
|
|||
}
|
||||
}, this.siteId);
|
||||
|
||||
const pageRoute = this.useOnlinePlayer ? 'online-player' : 'player';
|
||||
|
||||
CoreNavigator.navigateToSitePath(
|
||||
`${ADDON_MOD_SCORM_PAGE_NAME}/${this.courseId}/${this.module.id}/player`,
|
||||
`${ADDON_MOD_SCORM_PAGE_NAME}/${this.courseId}/${this.module.id}/${pageRoute}`,
|
||||
{
|
||||
params: {
|
||||
mode: autoPlayData?.mode ?? (preview ? AddonModScormMode.BROWSE : AddonModScormMode.NORMAL),
|
||||
|
@ -587,6 +610,11 @@ export class AddonModScormIndexComponent extends CoreCourseModuleMainActivityCom
|
|||
* @inheritdoc
|
||||
*/
|
||||
protected async showStatus(status: DownloadStatus): Promise<void> {
|
||||
if (this.useOnlinePlayer) {
|
||||
this.statusMessage = '';
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (status === DownloadStatus.OUTDATED && this.scorm) {
|
||||
// Only show the outdated message if the file should be downloaded.
|
||||
|
@ -635,6 +663,13 @@ export class AddonModScormIndexComponent extends CoreCourseModuleMainActivityCom
|
|||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritdoc
|
||||
*/
|
||||
ngOnDestroy(): void {
|
||||
this.onlineObserver.unsubscribe();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -14,10 +14,7 @@
|
|||
"errorcreateofflineattempt": "An error occurred while creating a new offline attempt. Please try again.",
|
||||
"errordownloadscorm": "Error downloading SCORM: \"{{name}}\".",
|
||||
"errorgetscorm": "Error getting SCORM data.",
|
||||
"errorinvalidversion": "Sorry, the application only supports SCORM 1.2.",
|
||||
"errornotdownloadable": "Your school or learning provider has disabled the download of SCORM packages.",
|
||||
"errornovalidsco": "This SCORM package doesn't have a visible SCO to load.",
|
||||
"errorpackagefile": "Sorry, the application only supports ZIP packages.",
|
||||
"errorsyncscorm": "An error occurred while synchronising. Please try again.",
|
||||
"exceededmaxattempts": "You have reached the maximum number of attempts.",
|
||||
"failed": "Failed",
|
||||
|
|
|
@ -0,0 +1,23 @@
|
|||
<ion-header>
|
||||
<ion-toolbar>
|
||||
<ion-buttons slot="start">
|
||||
<ion-back-button [text]="'core.back' | translate" />
|
||||
</ion-buttons>
|
||||
<ion-title>
|
||||
<h1>
|
||||
<core-format-text *ngIf="scorm" [text]="scorm.name" contextLevel="module" [contextInstanceId]="cmId"
|
||||
[courseId]="courseId" />
|
||||
</h1>
|
||||
</ion-title>
|
||||
<!-- Add empty ion-buttons to let iframe add the full screen button -->
|
||||
<ion-buttons slot="end" />
|
||||
</ion-toolbar>
|
||||
</ion-header>
|
||||
<ion-content>
|
||||
<core-loading [hideUntil]="loaded">
|
||||
<core-iframe *ngIf="loaded" id="scorm_object" [src]="src" [iframeWidth]="scormWidth" [iframeHeight]="scormHeight"
|
||||
[showFullscreenOnToolbar]="true" [autoFullscreenOnRotate]="enableFullScreenOnRotate" (loaded)="iframeLoaded()" />
|
||||
|
||||
<p *ngIf="!src && errorMessage">{{ errorMessage | translate }}</p>
|
||||
</core-loading>
|
||||
</ion-content>
|
|
@ -0,0 +1,299 @@
|
|||
// (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, ViewChild } from '@angular/core';
|
||||
import { CoreNavigator } from '@services/navigator';
|
||||
import { CoreSitesReadingStrategy } from '@services/sites';
|
||||
import { CoreDomUtils } from '@services/utils/dom';
|
||||
import { CoreEvents } from '@singletons/events';
|
||||
import {
|
||||
AddonModScorm,
|
||||
AddonModScormAttemptCountResult,
|
||||
AddonModScormGetScormAccessInformationWSResponse,
|
||||
AddonModScormScorm,
|
||||
AddonModScormScoWithData,
|
||||
} from '../../services/scorm';
|
||||
import { AddonModScormHelper } from '../../services/scorm-helper';
|
||||
import { AddonModScormMode } from '../../constants';
|
||||
import { CoreSharedModule } from '@/core/shared.module';
|
||||
import { Subscription } from 'rxjs';
|
||||
import { CoreNetwork } from '@services/network';
|
||||
import { NgZone, Translate } from '@singletons';
|
||||
import { CoreError } from '@classes/errors/error';
|
||||
import { CoreWait } from '@singletons/wait';
|
||||
import { CoreIframeComponent } from '@components/iframe/iframe';
|
||||
|
||||
/**
|
||||
* Page that allows playing a SCORM in online, served from the server.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'page-addon-mod-scorm-online-player',
|
||||
templateUrl: 'online-player.html',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CoreSharedModule,
|
||||
],
|
||||
})
|
||||
export default class AddonModScormOnlinePlayerPage implements OnInit, OnDestroy {
|
||||
|
||||
@ViewChild(CoreIframeComponent) iframe?: CoreIframeComponent;
|
||||
|
||||
scorm!: AddonModScormScorm; // The SCORM object.
|
||||
loaded = false; // Whether the data has been loaded.
|
||||
src?: string; // Iframe src.
|
||||
errorMessage?: string; // Error message.
|
||||
scormWidth?: number; // Width applied to scorm iframe.
|
||||
scormHeight?: number; // Height applied to scorm iframe.
|
||||
cmId!: number; // Course module ID.
|
||||
courseId!: number; // Course ID.
|
||||
enableFullScreenOnRotate = false;
|
||||
|
||||
protected mode!: AddonModScormMode; // 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 = 0; // The attempt number.
|
||||
protected initialScoId?: number; // Initial SCO ID to load.
|
||||
protected onlineObserver: Subscription;
|
||||
protected isDestroyed = false;
|
||||
|
||||
constructor() {
|
||||
let isOnline = CoreNetwork.isOnline();
|
||||
this.onlineObserver = CoreNetwork.onChange().subscribe(() => {
|
||||
// Execute the callback in the Angular zone, so change detection doesn't stop working.
|
||||
NgZone.run(() => {
|
||||
const wasOnline = isOnline;
|
||||
isOnline = CoreNetwork.isOnline();
|
||||
|
||||
if (!isOnline && wasOnline) {
|
||||
// User lost connection while playing an online package. Show an error.
|
||||
CoreDomUtils.showErrorModal(new CoreError(Translate.instant('core.course.changesofflinemaybelost'), {
|
||||
title: Translate.instant('core.youreoffline'),
|
||||
}));
|
||||
|
||||
return;
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritdoc
|
||||
*/
|
||||
async ngOnInit(): Promise<void> {
|
||||
try {
|
||||
this.cmId = CoreNavigator.getRequiredRouteNumberParam('cmId');
|
||||
this.courseId = CoreNavigator.getRequiredRouteNumberParam('courseId');
|
||||
this.mode = CoreNavigator.getRouteParam('mode') || AddonModScormMode.NORMAL;
|
||||
this.moduleUrl = CoreNavigator.getRouteParam('moduleUrl') || '';
|
||||
this.newAttempt = !!CoreNavigator.getRouteBooleanParam('newAttempt');
|
||||
this.organizationId = CoreNavigator.getRouteParam('organizationId');
|
||||
this.initialScoId = CoreNavigator.getRouteNumberParam('scoId');
|
||||
} catch (error) {
|
||||
CoreDomUtils.showErrorModal(error);
|
||||
CoreNavigator.back();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Fetch the SCORM data.
|
||||
await this.fetchData();
|
||||
|
||||
if (!this.src) {
|
||||
CoreNavigator.back();
|
||||
|
||||
return;
|
||||
}
|
||||
} finally {
|
||||
this.loaded = true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize.
|
||||
*/
|
||||
protected async initialize(): Promise<void> {
|
||||
// Get the SCORM instance.
|
||||
this.scorm = await AddonModScorm.getScorm(this.courseId, this.cmId, {
|
||||
moduleUrl: this.moduleUrl,
|
||||
readingStrategy: CoreSitesReadingStrategy.PREFER_CACHE,
|
||||
});
|
||||
|
||||
if (!this.scorm.popup || !this.scorm.width || this.scorm.width <= 100) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If we receive a value > 100 we assume it's a fixed pixel size.
|
||||
this.scormWidth = this.scorm.width;
|
||||
|
||||
// Only get fixed size on height if width is also fixed.
|
||||
if (this.scorm.height && this.scorm.height > 100) {
|
||||
this.scormHeight = this.scorm.height;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine the attempt to use, the mode (normal/preview) and if it's offline or online.
|
||||
*
|
||||
* @param attemptsData Attempts count.
|
||||
* @param accessInfo Access info.
|
||||
*/
|
||||
protected async determineAttemptAndMode(
|
||||
attemptsData: AddonModScormAttemptCountResult,
|
||||
accessInfo: AddonModScormGetScormAccessInformationWSResponse,
|
||||
): Promise<void> {
|
||||
const data = await AddonModScormHelper.determineAttemptToContinue(this.scorm, attemptsData);
|
||||
|
||||
let incomplete = false;
|
||||
this.attempt = data.num;
|
||||
|
||||
// Check if current attempt is incomplete.
|
||||
if (this.attempt > 0) {
|
||||
incomplete = await AddonModScorm.isAttemptIncomplete(this.scorm.id, this.attempt, {
|
||||
cmId: this.cmId,
|
||||
});
|
||||
}
|
||||
|
||||
// Determine mode and attempt to use.
|
||||
const result = AddonModScorm.determineAttemptAndMode(
|
||||
this.scorm,
|
||||
this.mode,
|
||||
this.attempt,
|
||||
this.newAttempt,
|
||||
incomplete,
|
||||
accessInfo.cansavetrack,
|
||||
);
|
||||
|
||||
if (result.attempt > this.attempt) {
|
||||
// We're creating a new attempt, 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.ONLY_NETWORK,
|
||||
});
|
||||
}
|
||||
|
||||
this.mode = result.mode;
|
||||
this.newAttempt = result.newAttempt;
|
||||
this.attempt = result.attempt;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch data needed to play the SCORM.
|
||||
*/
|
||||
protected async fetchData(): Promise<void> {
|
||||
await this.initialize();
|
||||
|
||||
try {
|
||||
// Get attempts data.
|
||||
const [attemptsData, accessInfo] = await Promise.all([
|
||||
AddonModScorm.getAttemptCount(this.scorm.id, { cmId: this.cmId }),
|
||||
AddonModScorm.getAccessInformation(this.scorm.id, {
|
||||
cmId: this.cmId,
|
||||
}),
|
||||
]);
|
||||
|
||||
await this.determineAttemptAndMode(attemptsData, accessInfo);
|
||||
|
||||
const sco = await this.getScoToLoad();
|
||||
if (!sco) {
|
||||
// We couldn't find a SCO to load: they're all inactive or without launch URL.
|
||||
this.errorMessage = 'addon.mod_scorm.errornovalidsco';
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Load SCO.
|
||||
this.src = await AddonModScorm.getScoSrcForOnlinePlayer(this.scorm, sco, {
|
||||
mode: this.mode,
|
||||
organization: this.organizationId,
|
||||
newAttempt: this.newAttempt,
|
||||
});
|
||||
} catch (error) {
|
||||
CoreDomUtils.showErrorModalDefault(error, 'addon.mod_scorm.errorgetscorm', true);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the TOC.
|
||||
*
|
||||
* @returns SCO to load.
|
||||
*/
|
||||
protected async getScoToLoad(): Promise<AddonModScormScoWithData | undefined> {
|
||||
// We need to check incomplete again: attempt number or status might have changed.
|
||||
const incomplete = await AddonModScorm.isAttemptIncomplete(this.scorm.id, this.attempt, {
|
||||
cmId: this.cmId,
|
||||
});
|
||||
|
||||
// Get TOC.
|
||||
const toc = await AddonModScormHelper.getToc(this.scorm.id, this.attempt, incomplete, {
|
||||
organization: this.organizationId,
|
||||
cmId: this.cmId,
|
||||
});
|
||||
|
||||
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.
|
||||
let currentSco: AddonModScormScoWithData | undefined;
|
||||
if (this.initialScoId && this.initialScoId > 0) {
|
||||
// SCO set by parameter, get it from TOC.
|
||||
currentSco = AddonModScormHelper.getScoFromToc(toc, this.initialScoId);
|
||||
}
|
||||
|
||||
if (currentSco) {
|
||||
return currentSco;
|
||||
}
|
||||
|
||||
// No SCO defined. Get the first valid one.
|
||||
return await AddonModScormHelper.getFirstSco(this.scorm.id, this.attempt, {
|
||||
toc,
|
||||
organization: this.organizationId,
|
||||
mode: this.mode,
|
||||
cmId: this.cmId,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritdoc
|
||||
*/
|
||||
ngOnDestroy(): void {
|
||||
this.isDestroyed = true;
|
||||
this.onlineObserver.unsubscribe();
|
||||
|
||||
// Empty src when leaving the state so unload event is triggered in the iframe.
|
||||
this.src = '';
|
||||
CoreEvents.trigger(CoreEvents.ACTIVITY_DATA_SENT, { module: 'scorm' });
|
||||
}
|
||||
|
||||
/**
|
||||
* SCORM iframe has been loaded.
|
||||
*/
|
||||
async iframeLoaded(): Promise<void> {
|
||||
// When using online player, some packages don't calculate the right height. Sending a 'resize' event doesn't fix it, but
|
||||
// changing the iframe size makes the SCORM recalculate the size.
|
||||
// Wait 1 second (to let inner iframes load) and then force full screen to make the SCORM recalculate the size.
|
||||
await CoreWait.wait(1000);
|
||||
|
||||
if (this.isDestroyed) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.iframe?.toggleFullscreen(true);
|
||||
this.enableFullScreenOnRotate = true;
|
||||
}
|
||||
|
||||
}
|
|
@ -14,7 +14,6 @@
|
|||
|
||||
import { Component, OnInit, OnDestroy } from '@angular/core';
|
||||
import { CoreNavigationBarItem } from '@components/navigation-bar/navigation-bar';
|
||||
import { CoreMainMenuPage } from '@features/mainmenu/pages/menu/menu';
|
||||
import { CoreNavigator } from '@services/navigator';
|
||||
import { CoreSites, CoreSitesReadingStrategy } from '@services/sites';
|
||||
import { CoreSync } from '@services/sync';
|
||||
|
@ -89,10 +88,6 @@ export class AddonModScormPlayerPage implements OnInit, OnDestroy {
|
|||
protected launchPrevObserver?: CoreEventObserver;
|
||||
protected goOfflineObserver?: CoreEventObserver;
|
||||
|
||||
constructor(
|
||||
protected mainMenuPage: CoreMainMenuPage,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @inheritdoc
|
||||
*/
|
||||
|
@ -311,8 +306,6 @@ export class AddonModScormPlayerPage implements OnInit, OnDestroy {
|
|||
|
||||
/**
|
||||
* Fetch data needed to play the SCORM.
|
||||
*
|
||||
* @returns Promise resolved when done.
|
||||
*/
|
||||
protected async fetchData(): Promise<void> {
|
||||
if (!this.scorm) {
|
||||
|
|
|
@ -29,6 +29,10 @@ const routes: Routes = [
|
|||
path: ':courseId/:cmId/player',
|
||||
component: AddonModScormPlayerPage,
|
||||
},
|
||||
{
|
||||
path: ':courseId/:cmId/online-player',
|
||||
loadComponent: () => import('./pages/online-player/online-player'),
|
||||
},
|
||||
];
|
||||
|
||||
@NgModule({
|
||||
|
|
|
@ -23,7 +23,7 @@ import { CoreFileSizeSum } from '@services/plugin-file-delegate';
|
|||
import { CoreSites, CoreSitesReadingStrategy } from '@services/sites';
|
||||
import { CorePromiseUtils } from '@singletons/promise-utils';
|
||||
import { CoreWSFile } from '@services/ws';
|
||||
import { makeSingleton, Translate } from '@singletons';
|
||||
import { makeSingleton } from '@singletons';
|
||||
import { AddonModScorm, AddonModScormScorm } from '../scorm';
|
||||
import { AddonModScormSync } from '../scorm-sync';
|
||||
import { ADDON_MOD_SCORM_COMPONENT } from '../../constants';
|
||||
|
@ -177,10 +177,9 @@ export class AddonModScormPrefetchHandlerService extends CoreCourseActivityPrefe
|
|||
|
||||
siteId = siteId || CoreSites.getCurrentSiteId();
|
||||
|
||||
const result = AddonModScorm.isScormUnsupported(scorm);
|
||||
|
||||
if (result) {
|
||||
throw new CoreError(Translate.instant(result));
|
||||
if (AddonModScorm.useOnlinePlayer(scorm)) {
|
||||
// Shouldn't happen, if scorm uses online player it shouldn't be downloaded.
|
||||
throw new CoreError('This SCORM cannot be downloaded.');
|
||||
}
|
||||
|
||||
// First verify that the file needs to be downloaded.
|
||||
|
@ -270,7 +269,7 @@ export class AddonModScormPrefetchHandlerService extends CoreCourseActivityPrefe
|
|||
async getDownloadSize(module: CoreCourseAnyModuleData, courseId: number): Promise<CoreFileSizeSum> {
|
||||
const scorm = await this.getScorm(module, courseId);
|
||||
|
||||
if (AddonModScorm.isScormUnsupported(scorm)) {
|
||||
if (AddonModScorm.useOnlinePlayer(scorm)) {
|
||||
return { size: -1, total: false };
|
||||
} else if (!scorm.packagesize) {
|
||||
// We don't have package size, try to calculate it.
|
||||
|
@ -353,7 +352,7 @@ export class AddonModScormPrefetchHandlerService extends CoreCourseActivityPrefe
|
|||
return false;
|
||||
}
|
||||
|
||||
if (AddonModScorm.isScormUnsupported(scorm)) {
|
||||
if (AddonModScorm.useOnlinePlayer(scorm)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
|
|
@ -962,6 +962,32 @@ export class AddonModScormProvider {
|
|||
return CorePath.concatenatePaths(dirPath, launchUrl);
|
||||
}
|
||||
|
||||
/**
|
||||
* Given a SCORM and a SCO, returns the full launch URL for the SCO to be used in an online player.
|
||||
*
|
||||
* @param scorm SCORM.
|
||||
* @param sco SCO.
|
||||
* @param options Other options.
|
||||
* @returns The URL.
|
||||
*/
|
||||
async getScoSrcForOnlinePlayer(
|
||||
scorm: AddonModScormScorm,
|
||||
sco: AddonModScormWSSco,
|
||||
options: AddonModScormGetScoSrcForOnlinePlayerOptions = {},
|
||||
): Promise<string> {
|
||||
const site = await CoreSites.getSite(options.siteId);
|
||||
|
||||
// Use online player.
|
||||
return CoreUrl.addParamsToUrl(CorePath.concatenatePaths(site.getURL(), '/mod/scorm/player.php'), {
|
||||
a: scorm.id,
|
||||
scoid: sco.id,
|
||||
display: 'popup',
|
||||
mode: options.mode,
|
||||
currentorg: options.organization,
|
||||
newattempt: options.newAttempt ? 'on' : 'off',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the path to the folder where a SCORM is downloaded.
|
||||
*
|
||||
|
@ -985,7 +1011,7 @@ export class AddonModScormProvider {
|
|||
getScormFileList(scorm: AddonModScormScorm): CoreWSFile[] {
|
||||
const files: CoreWSFile[] = [];
|
||||
|
||||
if (!this.isScormUnsupported(scorm) && !scorm.warningMessage) {
|
||||
if (!this.useOnlinePlayer(scorm) && !scorm.warningMessage) {
|
||||
files.push({
|
||||
fileurl: this.getPackageUrl(scorm),
|
||||
filepath: '/',
|
||||
|
@ -1359,22 +1385,6 @@ export class AddonModScormProvider {
|
|||
return !!(scorm.timeopen && scorm.timeopen > CoreTimeUtils.timestamp());
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a SCORM is unsupported in the app. If it's not, returns the error code to show.
|
||||
*
|
||||
* @param scorm SCORM to check.
|
||||
* @returns String with error code if unsupported, undefined if supported.
|
||||
*/
|
||||
isScormUnsupported(scorm: AddonModScormScorm): string | undefined {
|
||||
if (!this.isScormValidVersion(scorm)) {
|
||||
return 'addon.mod_scorm.errorinvalidversion';
|
||||
} else if (!this.isScormDownloadable(scorm)) {
|
||||
return 'addon.mod_scorm.errornotdownloadable';
|
||||
} else if (!this.isValidPackageUrl(this.getPackageUrl(scorm))) {
|
||||
return 'addon.mod_scorm.errorpackagefile';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if it's a valid SCORM 1.2.
|
||||
*
|
||||
|
@ -1698,6 +1708,17 @@ export class AddonModScormProvider {
|
|||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a SCORM should use an online player.
|
||||
*
|
||||
* @param scorm SCORM to check.
|
||||
* @returns True if it should use an online player.
|
||||
*/
|
||||
useOnlinePlayer(scorm: AddonModScormScorm): boolean {
|
||||
return !this.isScormValidVersion(scorm) || !this.isScormDownloadable(scorm) ||
|
||||
!this.isValidPackageUrl(this.getPackageUrl(scorm));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export const AddonModScorm = makeSingleton(AddonModScormProvider);
|
||||
|
@ -2066,6 +2087,16 @@ export type AddonModScormScoIcon = {
|
|||
description: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Options to pass to getScoSrcForOnlinePlayer.
|
||||
*/
|
||||
export type AddonModScormGetScoSrcForOnlinePlayerOptions = {
|
||||
siteId?: string;
|
||||
mode?: string; // Navigation mode.
|
||||
organization?: string; // Organization ID.
|
||||
newAttempt?: boolean; // Whether to start a new attempt.
|
||||
};
|
||||
|
||||
declare module '@singletons/events' {
|
||||
|
||||
/**
|
||||
|
|
|
@ -159,13 +159,27 @@ Feature: Test basic usage of SCORM activity in app
|
|||
# And I go back in the app
|
||||
# Then I should find "1" within "Number of attempts you have made" "ion-item" in the app
|
||||
|
||||
Scenario: Unsupported SCORM
|
||||
Scenario: SCORM 2004 works online
|
||||
Given the following "activities" exist:
|
||||
| activity | name | course | idnumber | packagefilepath |
|
||||
| scorm | SCORM 1.2 | C1 | scorm | mod/scorm/tests/packages/RuntimeBasicCalls_SCORM20043rdEdition.zip |
|
||||
| scorm | SCORM 2004 | C1 | scorm | mod/scorm/tests/packages/RuntimeBasicCalls_SCORM20043rdEdition.zip |
|
||||
And I entered the course "Course 1" as "student1" in the app
|
||||
When I press "SCORM 1.2" in the app
|
||||
Then I should find "Sorry, the application only supports SCORM 1.2." in the app
|
||||
When I press "SCORM 2004" in the app
|
||||
Then I should be able to press "Enter" in the app
|
||||
|
||||
When I switch network connection to offline
|
||||
Then I should find "This activity is not available offline" in the app
|
||||
And I should not be able to press "Enter" in the app
|
||||
|
||||
When I switch network connection to wifi
|
||||
And I press "Enter" in the app
|
||||
And I press "Disable fullscreen" in the app
|
||||
Then I should not be able to press "TOC" in the app
|
||||
|
||||
When I switch network connection to offline
|
||||
Then I should find "Any changes you make to this activity while offline may not be saved" in the app
|
||||
|
||||
# TODO: When iframes are fixed, test that the iframe actually works. However, the Golf 2004 SCORM has some issues.
|
||||
|
||||
Scenario: Hidden SCOs not displayed in TOC
|
||||
Given the following "activities" exist:
|
||||
|
|
|
@ -64,6 +64,7 @@ export class CoreIframeComponent implements OnChanges, OnDestroy {
|
|||
|
||||
initialized = false;
|
||||
|
||||
protected fullScreenInitialized = false;
|
||||
protected iframe?: HTMLIFrameElement;
|
||||
protected style?: HTMLStyleElement;
|
||||
protected orientationObs?: CoreEventObserver;
|
||||
|
@ -87,36 +88,6 @@ export class CoreIframeComponent implements OnChanges, OnDestroy {
|
|||
|
||||
this.initialized = true;
|
||||
|
||||
if (this.showFullscreenOnToolbar || this.autoFullscreenOnRotate) {
|
||||
// Leave fullscreen when navigating.
|
||||
this.navSubscription = Router.events
|
||||
.pipe(filter(event => event instanceof NavigationStart))
|
||||
.subscribe(async () => {
|
||||
if (this.fullscreen) {
|
||||
this.toggleFullscreen(false);
|
||||
}
|
||||
});
|
||||
|
||||
const shadow =
|
||||
this.elementRef.nativeElement.closest('.ion-page')?.querySelector('ion-header ion-toolbar')?.shadowRoot;
|
||||
if (shadow) {
|
||||
this.style = document.createElement('style');
|
||||
shadow.appendChild(this.style);
|
||||
}
|
||||
|
||||
if (this.autoFullscreenOnRotate) {
|
||||
this.toggleFullscreen(CoreScreen.isLandscape);
|
||||
|
||||
this.orientationObs = CoreEvents.on(CoreEvents.ORIENTATION_CHANGE, (data) => {
|
||||
if (this.isInHiddenPage()) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.toggleFullscreen(data.orientation == CoreScreenOrientation.LANDSCAPE);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Show loading only with external URLs.
|
||||
this.loading = !this.src || !CoreUrl.isLocalFileUrl(this.src);
|
||||
|
||||
|
@ -127,6 +98,72 @@ export class CoreIframeComponent implements OnChanges, OnDestroy {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure fullscreen based on the inputs.
|
||||
*/
|
||||
protected configureFullScreen(): void {
|
||||
if (!this.showFullscreenOnToolbar && !this.autoFullscreenOnRotate) {
|
||||
// Full screen disabled, stop watchers if enabled.
|
||||
this.navSubscription?.unsubscribe();
|
||||
this.orientationObs?.off();
|
||||
this.style?.remove();
|
||||
this.navSubscription = undefined;
|
||||
this.orientationObs = undefined;
|
||||
this.style = undefined;
|
||||
this.fullScreenInitialized = true;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.navSubscription) {
|
||||
// Leave fullscreen when navigating.
|
||||
this.navSubscription = Router.events
|
||||
.pipe(filter(event => event instanceof NavigationStart))
|
||||
.subscribe(async () => {
|
||||
if (this.fullscreen) {
|
||||
this.toggleFullscreen(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (!this.style) {
|
||||
const shadow = this.elementRef.nativeElement.closest('.ion-page')?.querySelector('ion-header ion-toolbar')?.shadowRoot;
|
||||
if (shadow) {
|
||||
this.style = document.createElement('style');
|
||||
shadow.appendChild(this.style);
|
||||
}
|
||||
}
|
||||
|
||||
if (!this.autoFullscreenOnRotate) {
|
||||
this.orientationObs?.off();
|
||||
this.orientationObs = undefined;
|
||||
this.fullScreenInitialized = true;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.orientationObs) {
|
||||
this.fullScreenInitialized = true;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.fullScreenInitialized) {
|
||||
// Only change full screen value if it's being initialized.
|
||||
this.toggleFullscreen(CoreScreen.isLandscape);
|
||||
}
|
||||
|
||||
this.orientationObs = CoreEvents.on(CoreEvents.ORIENTATION_CHANGE, (data) => {
|
||||
if (this.isInHiddenPage()) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.toggleFullscreen(data.orientation == CoreScreenOrientation.LANDSCAPE);
|
||||
});
|
||||
|
||||
this.fullScreenInitialized = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize things related to the iframe element.
|
||||
*/
|
||||
|
@ -170,6 +207,10 @@ export class CoreIframeComponent implements OnChanges, OnDestroy {
|
|||
}
|
||||
|
||||
if (!changes.src) {
|
||||
if (changes.showFullscreenOnToolbar || changes.autoFullscreenOnRotate) {
|
||||
this.configureFullScreen();
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
|
@ -212,6 +253,9 @@ export class CoreIframeComponent implements OnChanges, OnDestroy {
|
|||
// Now that the URL has been set, initialize the iframe. Wait for the iframe to the added to the DOM.
|
||||
setTimeout(() => {
|
||||
this.init();
|
||||
if (changes.showFullscreenOnToolbar || changes.autoFullscreenOnRotate) {
|
||||
this.configureFullScreen();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -229,6 +273,12 @@ export class CoreIframeComponent implements OnChanges, OnDestroy {
|
|||
this.orientationObs?.off();
|
||||
this.navSubscription?.unsubscribe();
|
||||
window.removeEventListener('message', this.messageListenerFunction);
|
||||
|
||||
if (this.fullscreen) {
|
||||
// Make sure to leave fullscreen mode when the iframe is destroyed. This can happen if there's a race condition
|
||||
// between clicking back button and some code toggling the fullscreen on.
|
||||
this.toggleFullscreen(false);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -2,6 +2,7 @@
|
|||
@import "utilities/borders";
|
||||
@import "utilities/flex";
|
||||
@import "utilities/float";
|
||||
@import "utilities/screenreaders";
|
||||
@import "utilities/spacing";
|
||||
@import "utilities/text";
|
||||
@import "utilities/visibility";
|
||||
|
|
|
@ -5,10 +5,22 @@
|
|||
|
||||
/* Bootstrap 5 bridge classes */
|
||||
|
||||
/*
|
||||
* These variables used to bridge the gap between Bootstrap 4 and Bootstrap 5 for
|
||||
* alert and list-group-item.
|
||||
*/
|
||||
|
||||
// Reduces the background color intensity by 80%. This is the definition in BS5.
|
||||
$alert-bg-scale: -80% !default;
|
||||
// Reduces the border color intensity by 70%. This is the definition in BS5.
|
||||
$alert-border-scale: -70% !default;
|
||||
// Increases the text color intensity by 50%. This is the definition in BS5.
|
||||
$alert-color-scale: 50% !default;
|
||||
|
||||
/*
|
||||
* These function used to bridge the gap between Bootstrap 4 and Bootstrap 5 and
|
||||
* and will be located in __functions.scss in Bootstrap 5
|
||||
* This file should be removed as part of MDL-75669.
|
||||
*/
|
||||
|
||||
// Tint a color: mix a color with white based on the provided weight.
|
||||
|
@ -27,6 +39,15 @@
|
|||
@return if($weight > 0, shade-color($color, $weight), tint-color($color, -$weight));
|
||||
}
|
||||
|
||||
// Visually hidden mixins
|
||||
@mixin visually-hidden() {
|
||||
@include sr-only();
|
||||
}
|
||||
|
||||
@mixin visually-hidden-focusable() {
|
||||
@include sr-only-focusable();
|
||||
}
|
||||
|
||||
/* These classes are used to bridge the gap between Bootstrap 4 and Bootstrap 5. */
|
||||
/* This file should be removed as part of MDL-75669. */
|
||||
.g-0 {
|
||||
|
@ -107,3 +128,39 @@
|
|||
.rounded-end {
|
||||
@extend .rounded-right;
|
||||
}
|
||||
|
||||
// Generate sized rounded classes.
|
||||
.rounded-1 {
|
||||
@extend .rounded-sm;
|
||||
}
|
||||
.rounded-3 {
|
||||
@extend .rounded-lg;
|
||||
}
|
||||
|
||||
// Generate all font-weight and font-style classes.
|
||||
.fw-light {
|
||||
@extend .font-weight-light;
|
||||
}
|
||||
.fw-lighter {
|
||||
@extend .font-weight-lighter;
|
||||
}
|
||||
.fw-normal {
|
||||
@extend .font-weight-normal;
|
||||
}
|
||||
.fw-bold {
|
||||
@extend .font-weight-bold;
|
||||
}
|
||||
.fw-bolder {
|
||||
@extend .font-weight-bolder;
|
||||
}
|
||||
.fst-italic {
|
||||
@extend .font-italic;
|
||||
}
|
||||
|
||||
// Generate visually-hidden classes.
|
||||
.visually-hidden {
|
||||
@extend .sr-only;
|
||||
}
|
||||
.visually-hidden-focusable {
|
||||
@extend .sr-only-focusable;
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue