Merge pull request #4260 from dpalou/MOBILE-4669

Mobile 4669
main
Pau Ferrer Ocaña 2024-12-17 09:03:57 +01:00 committed by GitHub
commit d23160df19
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 595 additions and 99 deletions

View File

@ -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",

View File

@ -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 -->

View File

@ -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();
}
}
/**

View File

@ -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",

View File

@ -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>

View File

@ -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;
}
}

View File

@ -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) {

View File

@ -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({

View File

@ -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;
}

View File

@ -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' {
/**

View File

@ -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:

View File

@ -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);
}
}
/**

View File

@ -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";

View File

@ -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;
}