/**
* File : JClicPlayer.js
* Created : 28/04/2015
* By : Francesc Busquets <francesc@gmail.com>
*
* JClic.js
* An HTML5 player of JClic activities
* https://projectestac.github.io/jclic.js
*
* @source https://github.com/projectestac/jclic.js
*
* @license EUPL-1.2
* @licstart
* (c) 2000-2022 Educational Telematic Network of Catalonia (XTEC)
*
* Licensed under the EUPL, Version 1.1 or -as soon they will be approved by
* the European Commission- subsequent versions of the EUPL (the "Licence");
* You may not use this work except in compliance with the Licence.
*
* You may obtain a copy of the Licence at:
* https://joinup.ec.europa.eu/software/page/eupl
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the Licence is distributed on an "AS IS" basis, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* Licence for the specific language governing permissions and limitations
* under the Licence.
* @licend
* @module
*/
/* global JSON, Promise, location, window, document */
import $ from 'jquery';
import JSZip from 'jszip';
import JSZipUtils from 'jszip-utils';
import { log, init, settings, getPath, endsWith, getBasePath, getRelativePath, isNullOrUndef, mReplace, toCssSize } from './Utils.js';
import { Container, Point, Action, Timer, Rectangle } from './AWT.js';
import PlayerHistory from './PlayerHistory.js';
import ActiveMediaBag from './media/ActiveMediaBag.js';
import Skin from './skins/Skin.js';
import EventSounds from './media/EventSounds.js';
import JClicProject from './project/JClicProject.js';
import JumpInfo from './bags/JumpInfo.js';
import ActiveBoxContent from './boxes/ActiveBoxContent.js';
import Reporter from './report/Reporter.js';
import MediaBagElement from './bags/MediaBagElement.js';
/**
* JClicPlayer is one of the the main classes of the JClic system. It implements the
* {@link http://projectestac.github.io/jclic/apidoc/edu/xtec/jclic/PlayStation.html PlayStation}
* interface, needed to host JClic activities.
* JClicPlayer offers to {@link module:Activity.ActivityPanel ActivityPanel} objects all the necessary resources and functions:
* media bags (to load and realize images and other media contents), sequence control, management
* of the reporting system, user interface, display of system messages, etc.
* @extends module:AWT.Container
*/
export class JClicPlayer extends Container {
/**
* JClicPlayer constructor
* @param {external:jQuery} $topDiv - The HTML `div` element where this JClicPlayer will deploy.
* @param {object} [options] - A set of optional customized options.
*/
constructor($topDiv, options) {
// JClicPlayer extends Container
super();
// Build cascading options
options = init(options);
this.options = $.extend(Object.create(this.options), options);
// Generate unique ID
this.id = `JC${(0x10000 + Math.round(Math.random() * 0xFFFF)).toString(16).toUpperCase().substring(1)}`;
// Identify the HTML element where this player will deploy
this.$topDiv = $topDiv || $('<div/>');
// Avoid side effects of 'align=center' in old HTML pages
this.$topDiv.css({ 'text-align': 'initial' });
// Special case: $topDiv inside a TD (like in http://clic.xtec.cat/gali)
if (this.$topDiv.parent().is('td')) {
// Set explicit width and height to fill-in the TD
this.$topDiv.css({
width: toCssSize(this.options.width, null, null, '100%'),
height: toCssSize(this.options.height, null, null, '100%'),
});
}
// Build the main container
this.$mainContainer = $('<div/>', { class: 'JClicContainer', id: this.id })
.css({ width: '100%', height: '100%' })
.appendTo(this.$topDiv);
// Intitialize other elements
this.localFS = location && location.protocol === 'file:';
this.$div = $('<div/>', { class: 'JClicPlayer' });
this.project = new JClicProject();
this.activeMediaBag = new ActiveMediaBag();
this.counterVal = { score: 0, actions: 0, time: 0 };
this.bgImageOrigin = new Point();
this.buildActions();
this.history = new PlayerHistory(this);
this.audioEnabled = this.options.audioEnabled;
this.navButtonsAlways = this.options.navButtonsAlways;
this.defaultSkin = Skin.getSkin(this.options.skin, this);
this.setSkin(Skin.getSkin('@empty.xml', this));
this.initTimers();
this.listenTouchEvents();
log('info', 'JClicPlayer ready');
}
/**
*
* Detects swipe-right, swipe-left and double touch gestures on touch devices,
* associating them with 'next activity', 'previous activity' and 'toggle full screen' actions
*/
listenTouchEvents() {
// Enable listeners only in touch devices
//if ('ontouchstart' in window) {
let startTouch = null;
let startTouchTime = 0;
let thisDiv = this.$div[0];
const { minSwipeX, maxSwipeY, rightToLeft } = this.options;
// Generic handler for touch events
const touchEventHandler = event => {
// Process only single-finger events targeted to our main 'div'
if (event.target === thisDiv && event.changedTouches && event.changedTouches.length === 1) {
const touch = event.changedTouches[0];
const dx = startTouch ? touch.clientX - startTouch.clientX : 0;
const dy = startTouch ? touch.clientY - startTouch.clientY : 0;
const dist = Math.sqrt(dx * dx + dy * dy);
switch (event.type) {
case 'touchstart':
const currentTime = new Date();
// Detect double taps, done in less than 800 ms and at short distance
if (
document && document.fullscreenEnabled
&& startTouch && startTouchTime
&& currentTime - startTouchTime < 800
&& dist < minSwipeX
) {
event.preventDefault();
log('info', 'Toggle full screen mode from double touch');
this.skin.setScreenFull();
startTouch = null;
}
else {
startTouch = touch;
startTouchTime = currentTime;
}
break;
case 'touchend':
// Discard non-horizontal gestures and those that do not have sufficient length
if (startTouch && Math.abs(dx) > minSwipeX && Math.abs(dy) < maxSwipeY) {
const actionName = dx < 0 && !rightToLeft ? 'next' : 'prev';
const action = this.actions[actionName];
if (action && action.enabled) {
event.preventDefault();
log('info', `Performing action "${actionName}" from touch gesture`);
action.actionPerformed(event);
}
startTouch = null;
}
// Cancel double touch detection when long gestures detected
else if (dist > minSwipeX)
startTouch = null;
break;
case 'touchcancel':
startTouch = null;
break;
}
}
else
// Cancel any started gesture
startTouch = null;
};
// Handle touch events
thisDiv.addEventListener('touchstart', touchEventHandler);
thisDiv.addEventListener('touchend', touchEventHandler);
thisDiv.addEventListener('touchcancel', touchEventHandler);
//}
}
/**
* Generates an unique ID for elements being used with this player
* @param {string} lb - The element's label
* @returns {string}
*/
getUniqueId(lb) {
return `${this.id}-${lb}`;
}
/**
* Builds the {@link module:AWT.Action} objects for this player
*/
buildActions() {
this.actions = {
'next': new Action('next', () => this.history.processJump(this.project.activitySequence.getJump(false, this.reporter), false)),
'prev': new Action('prev', () => this.history.processJump(this.project.activitySequence.getJump(true, this.reporter), false)),
'return': new Action('return', () => this.history.pop()),
'reset': new Action('reset', () => { if (this.actPanel && this.actPanel.act.canReinit()) this.initActivity(); }),
'help': new Action('help', () => { if (this.actPanel) this.actPanel.showHelp(); }),
'info': new Action('info', () => {
if (this.actPanel && this.actPanel.act.hasInfo()) {
if (this.actPanel.act.infoUrl)
this.displayURL(this.act.infoUrl, true);
else if (this.actPanel.act.infoCmd)
this.runCmd(this.actPanel.act.infoCmd);
}
}),
'reports': new Action('reports', () => this.showReports()),
'audio': new Action('audio', () => {
this.audioEnabled = !this.audioEnabled;
if (!this.audioEnabled)
this.stopMedia();
EventSounds.prototype.globalEnabled = this.audioEnabled;
})
};
$.each(this.actions, (key, value) => {
value.addStatusListener(action => { if (this.skin) this.skin.actionStatusChanged(action); });
});
}
/**
* Resets the main components of this player
*/
reset() {
log('info', 'Restoring player');
this.removeActivity();
this.end();
this.activeMediaBag = new ActiveMediaBag();
this.history.clearHistory();
this.setSkin(null);
this.setMsg(null);
this.setCounterValue('score', 0);
this.setCounterValue('actions', 0);
this.setCounterValue('time', 0);
if (this.skin)
this.skin.setWaitCursor('reset');
}
/**
* Instructs the player to stop working
*/
stop() {
this.stopMedia(-1);
}
/**
* Executes miscellaneous finalization routines.
* @returns {external:Promise} - A promise to be fullfilled when all pending tasks are finished.
*/
end() {
let result = null;
this.stopMedia();
this.closeHelpWindow();
if (this.reporter) {
result = this.reporter.end();
this.reporter = null;
}
if (this.actPanel) {
this.actPanel.end();
this.actPanel.$div.remove();
this.actPanel = null;
}
if (this.project) {
this.project.end();
this.project = null;
}
if (this.activeMediaBag)
this.activeMediaBag.removeAll();
return result || Promise.resolve(true);
}
/**
* Creates and initializes the {@link module:Reporter.Reporter Reporter} member
* @returns {external:Promise}
*/
initReporter() {
if (this.reporter) {
this.reporter.end();
this.reporter = null;
}
this.reporter = Reporter.getReporter(null, this);
return this.reporter.init();
}
/**
* Creates and initializes objects of type {@link module:AWT.Timer}
*/
initTimers() {
// Main timer
if (this.timer)
this.timer.stop();
this.timer = new Timer(() => {
this.incCounterValue('time');
if (this.actPanel && this.actPanel.act.maxTime > 0
&& this.actPanel.playing
&& this.counterVal['time'] >= this.actPanel.act.maxTime)
this.actPanel.finishActivity(false);
}, 1000, false);
// One-time timer, for delayed actions
if (this.delayedTimer)
this.delayedTimer.stop();
this.delayedTimer = new Timer(() => {
if (this.delayedAction)
this.delayedAction.processEvent(this.delayedAction, null);
}, 1000, false);
this.delayedTimer.repeats = false;
}
/**
* Opens the reports dialog
*/
showReports() {
if (this.skin) this.skin.showReports(this.reporter);
}
/**
* Closes the help dialog window
*/
closeHelpWindow() {
if (this.skin) this.skin._closeDlg(false);
}
/**
* Sets the current skin
* @param {module:skins/Skin.Skin} [newSkin] - The skin to use. When `null`, `defaultSkin` will be used.
*/
setSkin(newSkin) {
newSkin = newSkin || (this.project && this.project.skin ? this.project.skin : this.defaultSkin);
if (newSkin && (this.skin === null || newSkin.name !== this.skin.name)) {
newSkin.attach(this);
this.skin = newSkin;
this.skin.doLayout();
}
}
/**
* Sets the current project of this player, without starting any activity
* @param {module:project/JClicProject.JClicProject} project - The project to be set
*/
setProject(project) {
if (this.project) {
if (this.project !== project)
this.project.end();
this.removeActivity();
}
this.project = project || new JClicProject();
this.project.realize(this);
}
/**
* Loads the specified project and starts playing at the specified activity or sequence tag.
* @param {string|JClicProject} [project] - The project to load (if it's a string) or to use (if it's an object of type {@link module:project/JClicProject.JClicProject JClicProject}).
* When it's a `string`, it can be the absolute or relative path to:
* - A `.jclic` project file, in XML format
* - A `.jclic.json` project file in JSON format
* - A `.jclic.zip` compressed project file (containing one file of type '.jclic' or '.jclic.json')
* - A `.scorm.zip` file, as exported by JClic Author.
* - A `project.json` file, as exported by JClic Author
* When `null` or `undefined`, refers to the current project.
* @param {string|number} [sequence] - Sequence tag or numeric order in the {@link module:bags/ActivitySequence.ActivitySequence ActivitySequence}
* to be loaded. If _sequence_ and _activity_ are both `null`, the first {@link module:bags/ActivitySequenceElement.ActivitySequenceElement ActivitySequenceElement}
* will be loaded.
* @param {string} [activity] - Name of the activity to be loaded (usually `null`)
*/
load(project, sequence, activity) {
this.forceFinishActivity();
this.setWaitCursor(true);
// The ActivityPanel object to be obtained as a result of the loading process
let actp = null;
// step one: load the project
if (project) {
if (typeof project === 'string') {
// Param `project` is a file name or URL (otherwise, is a realized `JClicProject` object)
const fullPath = getPath(this.basePath, project);
// Previous step: Check if `project` points to a "project.json" file
if (fullPath.endsWith('project.json')) {
log('info', `Loading JSON info from: ${fullPath}`);
$.getJSON(fullPath).done(({ mainFile }) => {
// Read the `mainFile` field of `project.json`
if (mainFile && endsWith(mainFile, '.jclic') || endsWith(mainFile, '.jclic.json')) {
// Load project's main file
this.load(getPath(getBasePath(fullPath), mainFile), sequence, activity);
} else {
log('error', `Invalid or null "mainFile" specified in ${fullPath} - "project.json".`);
}
}).fail((jqhxr, textStatus, error) => {
const errMsg = `${textStatus} (${error}) while loading ${project}`;
log(errMsg);
window.alert(`Error!\n${errMsg}`);
}).always(
() => this.setWaitCursor(false)
);
return;
}
// Step 0: Check if `project` points to a ZIP file
if (fullPath.endsWith('.zip')) {
// TODO: Implement register of zip files in PlayerHistory
this.zip = null;
log('info', `Loading ZIP file: ${fullPath}`);
// Launch loading of ZIP file in a separated thread
JSZipUtils.getBinaryContent(fullPath, (err, data) => {
if (err) {
this.setWaitCursor(false);
log('error', `Error loading ZIP file: ${err.toString()}`);
return;
}
new JSZip().loadAsync(data).then(zip => {
this.zip = zip;
this.zip.fullZipPath = fullPath;
this.zip.zipBasePath = getBasePath(fullPath);
let fileName = null;
// Check if ZIP contains a "project.json" file (as in the ".scorm.zip" files generated by JClic Author)
if (this.zip.files['project.json']) {
this.zip.files['project.json'].async('string').then(content => {
try {
const json = JSON.parse(content);
// Read the `mainFile` field of `project.json`
if (endsWith(json['mainFile'], '.jclic')) {
// Load project's main file
this.load(getPath(this.zip.zipBasePath, json['mainFile']), sequence, activity);
} else {
log('error', `Invalid or null "mainFile" specified in ${fullPath} - "project.json".`);
}
} catch (err) {
log('error', `Error reading "project.json" in ${fullPath}: ${err ? err.toString() : 'unknown error'}`);
}
}).catch(reason => {
log('error', `Error reading ZIP file: ${reason ? reason.toString() : 'unknown reason'}`);
});
} else {
// Find first file with extension '.jclic' inside the zip file
fileName = Object.keys(this.zip.files).find(fn => fn.endsWith('.jclic')) || null;
if (fileName)
this.load(getPath(this.zip.zipBasePath, fileName), sequence, activity);
else
log('error', 'This ZIP file does not contain any JClic project!');
}
this.setWaitCursor(false);
}).catch(reason => {
log('error', `Error reading ZIP file: ${reason ? reason.toString() : 'unknown reason'}`);
this.setWaitCursor(false);
});
});
return;
} else if (this.localFS && window.JClicObject && !window.JClicObject.projectFiles[fullPath]) {
const scriptTag = document.createElement('script');
scriptTag.src = `${fullPath}.js`;
scriptTag.onload = () => {
// 25 Mar 20201:
// Workaround for a bug on Chrome and Firefox XML parsers, throwing errors whith hexadecimal character entities
if (window.JClicObject.projectFiles[fullPath]) {
window.JClicObject.projectFiles[fullPath] = mReplace([
[/
/g, '\r'],
[/
/g, '\n'],
[/	/g, '\t'],
], window.JClicObject.projectFiles[fullPath]);
this.load(project, sequence, activity);
}
};
document.head.appendChild(scriptTag);
this.setWaitCursor(false);
return;
}
// Step one: load the project file
const processProjectFile = fp => {
const isXml = fp.indexOf('data:text/xml;') === 0 || fp.endsWith('.jclic');
const loader = isXml ? $.get(fp, null, null, 'xml') : $.getJSON(fp);
loader.done(data => {
if (data === null || typeof data !== 'object') {
log('error', `Bad data. Project not loaded: ${project}`);
return;
}
const prj = new JClicProject();
if (isXml)
prj.setProperties($(data).find('JClicProject'), fullPath, this.zip, this.options);
else
prj.setAttributes(data, fullPath, this.zip, this.options);
log('info', `Project file loaded and parsed: ${project}`);
const elements = prj.mediaBag.buildAll(null, element => {
log('trace', `"${element.name}" ready.`);
this.incProgress(1);
}, this);
log('info', `Media elements to be loaded: ${elements}`);
this.setProgress(0, elements);
let loops = 0;
const interval = 500;
this.setWaitCursor(true);
const checkMedia = window.setInterval(() => {
// Wait for a maximum time of two minutes
if (++loops > this.options.maxWaitTime / interval) {
window.clearInterval(checkMedia);
this.setProgress(-1);
this.setWaitCursor(false);
log('error', 'Error loading media');
}
const waitingObjects = prj.mediaBag.countWaitingElements();
// player.setProgress(waiting)
if (waitingObjects === -1) {
window.clearInterval(checkMedia);
this.setProgress(-1);
this.setWaitCursor(false);
// Call `load` again, passing the loaded [JClicProject](JClicProject.html) as a parameter
this.load(prj, sequence, activity);
}
}, interval);
}).fail((jqXHR, textStatus, errorThrown) => {
const errMsg = `${textStatus} (${errorThrown}) while loading ${project}`;
log(errMsg);
window.alert(`Error!\n${errMsg}`);
}).always(() => this.setWaitCursor(false));
};
log('info', `Loading project: ${project}`);
let fp = fullPath;
// Special case for ZIP files
if (this.zip) {
const fName = getRelativePath(fp, this.zip.zipBasePath);
if (this.zip.files[fName]) {
this.zip.file(fName).async('string').then(text => {
processProjectFile(`data:${fName.endsWith('.jclic') ? 'text/xml' : 'application/json'};charset=UTF-8,${encodeURIComponent(text)}`);
}).catch(reason => {
log('error', `Unable to extract "${fName}" from ZIP file because of: ${reason ? reason.toString() : 'unknown reason'}`);
this.setWaitCursor(false);
});
return;
}
}
// Special case for local file systems (using `file` protocol)
else if (this.localFS) {
// Check if file is already loaded in the global variable `JClicObject`
if (window.JClicObject && window.JClicObject.projectFiles[fullPath]) {
fp = `data:${fullPath.endsWith('.jclic') ? 'text/xml' : 'application/json'};charset=UTF-8,${encodeURIComponent(window.JClicObject.projectFiles[fullPath])}`;
} else {
log('error', `Unable to load: ${fullPath}.js`);
this.setWaitCursor(false);
return;
}
}
processProjectFile(fp);
return;
}
// From here, assume that `project` is a [JClicProject](JClicProject.html)
this.setProject(project);
// If none specified, start with the first element of the sequence
if (!sequence && !activity)
sequence = '0';
// start reporter session
if (this.reporter)
this.reporter.newSession(project);
}
// Step two: load the ActivitySequenceElement
if (!isNullOrUndef(sequence)) {
log('info', `Loading sequence: ${sequence}`);
this.navButtonsDisabled = false;
// Try to load sequence by tag
let ase = null;
if (typeof sequence === 'string')
ase = this.project.activitySequence.getElementByTag(sequence, true);
if (ase === null) {
// Try to treat 'sequence' as a number
const n = parseInt(sequence, 10);
if (typeof n === 'number')
ase = this.project.activitySequence.getElement(n, true);
}
if (ase !== null) {
// Success! We have a real [ActivitySequenceElement](ActivitySequenceElement.html)
if (this.reporter)
this.reporter.newSequence(ase);
activity = ase.activity;
}
}
// Step three: load the activity
if (activity) {
const act = this.project.getActivity(activity);
if (act) {
// Success! We have a real [Activity](Activity.html)
log('info', `Loading activity: ${activity}`);
// Clean static references to previous audio elements
MediaBagElement.resetAudioElements();
act.prepareMedia(this);
this.project.activitySequence.checkCurrentActivity(act.name);
actp = act.getActivityPanel(this);
actp.buildVisualComponents();
} else {
log('error', `Missing activity: ${activity}`);
}
}
// Step four: put the activity panel on place
// Remove the current ActivityPanel
if (this.actPanel !== null) {
this.actPanel.end();
this.actPanel.$div.remove();
this.actPanel = null;
this.setCounterValue('time', 0);
}
// Attach the new ActivityPanel
if (actp) {
// Sets the actPanel member to this ActivityPanel
this.actPanel = actp;
if (this.options.fade > 0)
this.actPanel.$div.css('display', 'none');
// Places the JQuery DOM element of actPanel within the player main panel
this.$div.prepend(this.actPanel.$div);
if (this.skin)
this.skin.resetAllCounters(false);
// Sets the current skin
if (this.actPanel.skin)
this.setSkin(this.actPanel.skin);
else if (this.project.skin) {
this.setSkin(this.project.skin);
this.lastProjectSkin = this.project.skin;
}
else if (this.lastProjectSkin)
this.setSkin(this.lastProjectSkin);
else
this.setSkin(null);
if (this.skin) {
// Enable or disable actions
const hasReturn = this.history.storedElementsCount() > 0 || this.options.returnAsExit;
const navBtnFlag = this.navButtonsAlways ?
'both' : this.navButtonsDisabled ?
'none' : this.project.activitySequence.getNavButtonsFlag();
this.actions['next'].setEnabled((navBtnFlag === 'fwd' || navBtnFlag === 'both') &&
this.project.activitySequence.hasNextAct(hasReturn));
this.actions['prev'].setEnabled((navBtnFlag === 'back' || navBtnFlag === 'both') &&
this.project.activitySequence.hasPrevAct(hasReturn));
this.actions['return'].setEnabled(hasReturn);
this.actions['help'].setEnabled(this.actPanel.act.helpWindowAllowed());
this.actions['reset'].setEnabled(this.actPanel.act.canReinit());
this.actions['info'].setEnabled(this.actPanel.act.hasInfo());
}
this.doLayout();
this.initActivity();
this.history.pushBrowserHistory();
this.actPanel.$div.fadeIn(this.options.fade, () => this.activityReady());
}
this.setWaitCursor(false);
}
/**
* Forces the current activity to stop playing.
*/
forceFinishActivity() {
this.timer.stop();
this.delayedTimer.stop();
if (this.actPanel) {
this.closeHelpWindow();
this.actPanel.forceFinishActivity();
this.stopMedia();
this.activeMediaBag.removeAll();
}
}
/**
*
* Removes the current {@link module:Activity.ActivityPanel ActivityPanel} from this player
*/
removeActivity() {
this.forceFinishActivity();
if (this.actPanel) {
this.actPanel.end();
this.actPanel.$div.remove();
this.actPanel = null;
this.setMsg(null);
this.doLayout();
}
}
/**
*
* Initializes the activity
*/
initActivity() {
this.setWaitCursor(true);
this.timer.stop();
this.delayedTimer.stop();
this.setCounterValue('time', 0);
this.stopMedia();
if (this.actPanel) {
this.actPanel.initActivity();
this.timer.start();
if (!this.actPanel.act.mustPauseSequence())
this.startAutoPassTimer();
log('info', `Activity "${this.actPanel.act.name}" running`);
}
this.setWaitCursor(false);
}
/**
* Called by {@link module:JClicPlayer.JClicPlayer#load} when the {@link module:Activity.ActivityPanel ActivityPanel} is fully visible, just
* after the JQuery animation effect.
*/
activityReady() {
if (this.actPanel) {
this.actPanel.activityReady();
log('info', 'Activity ready');
}
}
/**
* Starts the activity. This method is usually called from text activities with previous text.
*/
startActivity() {
this.setWaitCursor(true);
if (this.actPanel)
this.actPanel.startActivity();
this.setWaitCursor(false);
}
/**
* Configures the layout and visual aspect of the player area.
*/
doLayout() {
// Main player area settings
const
width = this.dim.width = this.$div.width(),
height = this.dim.height = this.$div.height(),
mainCss = {
'background-color': this.actPanel ? this.actPanel.act.bgColor : 'azure',
'background-image': ''
};
if (this.actPanel) {
const act = this.actPanel.act;
if (act.bgGradient)
// Canvas version also available
mainCss['background-image'] = act.bgGradient.getCss();
if (act.bgImageFile && act.bgImageFile.length > 0) {
this.project.mediaBag.getElement(act.bgImageFile, true).getFullPathPromise().then(bgImageUrl => {
this.$div.css({
'background-image': 'url(\'' + bgImageUrl + '\')',
'background-repeat': act.tiledBgImg ? 'repeat' : 'no-repeat',
'background-position': act.tiledBgImg ? '' : 'center center'
});
});
}
// Activity panel settings
// Calc the maximum rectangle available for the activity panel
const
m = settings.BoxBase.AC_MARGIN,
proposedRect = new Rectangle(m, m, width - 2 * m, height - 2 * m);
if (this.actPanel.bgImage && !act.tiledBgImg && act.absolutePositioned) {
// Special case: when the activity has a background image not tiled, and an absolute
// position has been specified, the ActivityPanel must be placed at this absolute
// position, relative to the background image
this.bgImageOrigin.x = (width - this.actPanel.bgImage.width) / 2;
this.bgImageOrigin.y = (height - this.actPanel.bgImage.height) / 2;
proposedRect.pos.moveTo(this.bgImageOrigin);
proposedRect.dim.width -= this.bgImageOrigin.x - m;
proposedRect.dim.height -= this.bgImageOrigin.y - m;
proposedRect.dim.width = Math.min(proposedRect.dim.width, width);
proposedRect.dim.height = Math.min(proposedRect.dim.height, height);
}
// ActivityPanel will calculate and set its position and size based on the maximum and optimal
// available space
/* TODO: Try with a computed rectangle instead of "this", to avoid the loss of the right margin
* in narrow displays */
this.actPanel.fitTo(proposedRect, this);
}
this.$div.css(mainCss);
}
/**
* Plays the specified media.
* @param {module:media/MediaContent.MediaContent} mediaContent - The media to be played
* @param {module:boxes/ActiveBox.ActiveBox} [mediaPlacement] - The cell where the graphic component of this media should be placed (used with video objects)
* @param {function[]} [delayedActions] - If set, store the the action in this array for future execution
*/
playMedia(mediaContent, mediaPlacement = null, delayedActions = null) {
let ji = null;
const fn = mediaContent.file;
let action = null;
switch (mediaContent.type) {
case 'PLAY_AUDIO':
case 'PLAY_VIDEO':
case 'PLAY_MIDI':
case 'RECORD_AUDIO':
case 'PLAY_RECORDED_AUDIO':
if (this.audioEnabled) {
const amp = this.activeMediaBag.getActiveMediaPlayer(mediaContent, this.project.mediaBag, this);
if (amp)
action = () => amp.play(mediaPlacement);
}
break;
case 'RUN_CLIC_PACKAGE':
ji = new JumpInfo('JUMP', fn);
if (mediaContent.externalParam) {
// Relative path computed in History.processJump
ji.projectPath = mediaContent.externalParam;
}
action = () => this.history.processJump(ji, true);
break;
case 'RUN_CLIC_ACTIVITY':
this.history.push();
action = () => this.load(null, null, fn);
break;
case 'RETURN':
if (this.history.storedElementsCount() > 0 || !this.options.returnAsExit) {
action = () => this.history.pop();
break;
}
case 'EXIT':
ji = new JumpInfo('EXIT', fn);
action = () => this.history.processJump(ji, false);
break;
case 'RUN_EXTERNAL':
action = () => this.runCmd(fn);
break;
case 'URL':
if (fn)
// When mediaContent.level is 2 or more, the URL will be opened in a separate window.
action = () => this.displayURL(fn, mediaContent.level > 1);
break;
default:
log('error', `Unknown media type: ${mediaContent.type}`);
break;
}
// Execute the action or pass callback
if (delayedActions && action)
delayedActions.push(action);
else if (action)
action();
}
/**
* Stops currently playing media
* @param {number} [level=-1] - Sets the threshold above which all media objects with equal
* or greater `level` will also also be muted.
*/
stopMedia(level) {
if (typeof level !== 'number')
level = -1;
this.activeMediaBag.stopAll(level);
}
/**
* Launches the specified system command.
* Currently not implemented.
* @param {string} cmd
*/
runCmd(cmd) {
log('warn', `Unsupported call to external command: "${cmd}"`);
}
/**
* Called from {@link module:Activity.Activity Activity} when finished.
* @param {boolean} _completedOK - `true` when the activity was successfully completed, `false`
* otherwise.
*/
activityFinished(_completedOK) {
this.closeHelpWindow();
log('info', 'Activity finished');
this.timer.stop();
this.startAutoPassTimer();
}
/**
* Starts the automatic jump to next activity, when applicable.
*/
startAutoPassTimer() {
const ase = this.project.activitySequence.getCurrentAct();
if (ase !== null && ase.delay > 0 && !this.delayedTimer.isRunning() && !this.navButtonsDisabled) {
this.delayedAction = this.actions['next'];
this.delayedTimer.interval = ase.delay * 1000;
this.delayedTimer.start();
}
}
/**
*
* Sets the current main message.
* @param {module:boxes/ActiveBoxContent.ActiveBoxContent} abc - The content of the message
*/
setMsg(abc) {
const ab = this.skin ? this.skin.getMsgBox() : null;
if (ab) {
ab.clear();
this.skin.invalidate(ab).update();
ab.setContent(abc ? abc : ActiveBoxContent.EMPTY_CONTENT);
// TODO: Move this method to Skin
this.skin.invalidate(ab).update();
ab.playMedia(this);
}
}
/**
* Launches the active media content associated to the main message, if any.
*/
playMsg() {
if (this.skin && this.skin.getMsgBox())
this.skin.getMsgBox().playMedia(this);
}
/**
* Sets a value to the specified counter
* @param {string} counter - The id of the counter ('score', 'actions' or 'time')
* @param {number} newValue - The value to be set
*/
setCounterValue(counter, newValue) {
this.counterVal[counter] = newValue;
if (this.skin && this.skin.counters[counter])
this.skin.counters[counter].setValue(newValue);
}
/**
* Gets the current value for the specified counter
* @param {string} counter - The id of the counter ('score', 'actions' or 'time')
* @returns {number}
*/
getCounterValue(counter) {
return this.counterVal[counter];
}
/**
* Enables or disables a specific counter
* @param {string} counter - The id of the counter ('score', 'actions' or 'time')
* @param {boolean} bEnabled - When `true`, the counter will be enabled.
*/
setCounterEnabled(counter, bEnabled) {
if (this.skin) {
this.skin.enableCounter(counter, bEnabled);
this.setCountDown(counter, 0);
}
}
/**
* Increments by 1 the value of the specified counter
* @param {string} counter - The id of the counter ('score', 'actions' or 'time')
*/
incCounterValue(counter) {
this.counterVal[counter]++;
const
actp = this.actPanel,
cnt = this.skin ? this.skin.counters[counter] : null;
if (cnt)
cnt.setValue(this.counterVal[counter]);
if (counter === 'actions' && actp !== null && actp.act.maxActions > 0 && actp.playing && this.counterVal['actions'] >= actp.act.maxActions)
window.setTimeout(() => { actp.finishActivity(actp.solved); }, 0);
}
/**
* Sets the specified counter in count-down status, starting at `maxValue`.
* @param {string} counter - The id of the counter ('score', 'actions' or 'time')
* @param {number} maxValue - The value from which to start counting down
*/
setCountDown(counter, maxValue) {
//this.counterVal[counter] = maxValue
if (this.skin && this.skin.counters[counter])
this.skin.counters[counter].setCountDown(maxValue);
}
/**
* Set/unset the panel in 'wait' state
* @param {boolean} status
*/
setWaitCursor(status) {
if (this.skin)
this.skin.setWaitCursor(status);
}
/**
* Sets the current value of the progress bar
* @param {number} val - The current value. Should be less or equal than `max`. When -1, the progress bar will be hidden.
* @param {number} [max] - Optional parameter representing the maximum value. When passed, the progress bar will be displayed.
*/
setProgress(val, max) {
if (this.skin)
this.skin.setProgress(val, max);
}
/**
* Increments the progress bar value by the specified amount, only when the progress bar is running.
* @param {number} [val=1] - The amount to increment. When not defined, it's 1.
*/
incProgress(val = 1) {
if (this.skin)
this.skin.incProgress(val);
}
/**
* Builds an {@link module:media/ActiveMediaPlayer.ActiveMediaPlayer ActiveMediaPlayer} for the specified {@link module:media/MediaContent.MediaContent}
* @param {module:media/MediaContent.MediaContent} mediaContent - The media content to be played
* @returns {module:media/ActiveMediaPlayer.ActiveMediaPlayer}
*/
getActiveMediaPlayer(mediaContent) {
return this.activeMediaBag && mediaContent ? this.activeMediaBag.getActiveMediaPlayer(mediaContent, this.project.mediaBag, this) : null;
}
/**
* Notifies the reporting system that a new activity has started
* @param {module:Activity.Activity} act - The activity that is sending the notification
*/
reportNewActivity(act) {
const ase = this.project.activitySequence.getCurrentAct();
if (this.reporter) {
if (ase.tag === this.reporter.getCurrentSequenceTag())
// Notify that the sequence has changed
this.reporter.newSequence(ase);
if (act.includeInReports)
this.reporter.newActivity(act);
}
this.setCounterValue('actions', 0);
this.setCounterValue('score', 0);
}
/**
* Notifies the reporting system that a new action has been performed on the current activity
* @param {module:Activity.Activity} act - The activity that is sending the notification
* @param {string} type - Type of action (match, move, switch...)
* @param {string} source - Object acting as a source of the action (cell, grid, clue...)
* @param {string} dest - When applicable, object acting as a receiver of the action (cell, grid...)
* @param {boolean} ok - Whether the action was OK or not
* @param {number} currentScore - The current score of the activity
*/
reportNewAction(act, type, source, dest, ok, currentScore) {
if (this.reporter && act.includeInReports && act.reportActions)
this.reporter.newAction(type, source, dest, ok);
if (currentScore >= 0) {
this.incCounterValue('actions');
this.setCounterValue('score', currentScore);
}
}
/**
* Notifies the reporting system that the current activity has finished
* @param {module:Activity.Activity} act - The activity that is sending the notification
* @param {boolean} solved - Whether the activity was successfully completed or not.
*/
reportEndActivity(act, solved) {
if (this.reporter && act.includeInReports)
this.reporter.endActivity(this.counterVal['score'], this.counterVal['actions'], solved);
}
/**
* Shows the help info provided by the activity
* @param {external:jQuery} $hlpComponent - The jQuery DOM component to be shown.
* @returns {boolean} - True when the component was successfully displayed
*/
showHelp($hlpComponent) {
return this.skin ? this.skin.showHelp($hlpComponent) : false;
}
/**
* Navigates to the requested URL
* @param {string} url - The URL to navigate to
* @param {boolean} inFrame - When `true` opens in a new frame
*/
displayURL(url, inFrame) {
if (url) {
if (inFrame)
window.open(url, this.options.infoUrlFrame);
else {
this.end().then(() => { window.location.href = url; });
}
}
}
/**
* Only when `exitUrl` has been specified in `options`, navigates to the specified URL
* @param {string} url - The URL to navigate to.
*/
exit(url) {
this.displayURL(url || this.options.exitUrl, false);
}
/**
* Sets a title in a specific HTML element, if provided.
* @param {string} docTitle
*/
setWindowTitle(docTitle) {
log('info', `running ${docTitle}`);
}
}
Object.assign(JClicPlayer.prototype, {
/**
* Object with miscellaneous options.
* @name module:JClicPlayer.JClicPlayer#options
* @type {object} */
options: {
//
// Max waiting time to have all media loaded (milliseconds)
maxWaitTime: 120000,
//
// Name of the frame where to open links
infoUrlFrame: '_blank',
//
// URL where to navigate to on exit
exitUrl: null,
//
// When `true` and no elements on sequence stack, RETURN acts as EXIT
returnAsExit: false,
//
// At the beginning, the audio should be enabled or disabled
audioEnabled: true,
//
// Navigation buttons are always visible (for debugging purposes)
navButtonsAlways: false,
//
// Time (milliseconds) of the fade-in animation of the activity panel. When 0, no animation
// is performed
fade: 300,
// Minimum horizontal swipe length to be considered an activity change gesture
minSwipeX: 40,
// Maximum vertical swipe length to be considered an activity change gesture
maxSwipeY: 100,
// Read swipe gestures as in right-to-left languages (default is left-to-right)
rightToLeft: false,
},
/**
* Unique ID of this player, randomly generated by the constructor
* @name module:JClicPlayer.JClicPlayer#id
* @type {string} */
id: 'JC0000',
/**
* The JQuery "div" element used by this player as stage for activities
* @name module:JClicPlayer.JClicPlayer#$div
* @type {external:jQuery} */
$div: null,
/**
* The JQuery top container where this player will deploy
* @name module:JClicPlayer.JClicPlayer#$topDiv
* @type {external:jQuery} */
$topDiv: null,
/**
* The main container of all JClic components
* @name module:JClicPlayer.JClicPlayer#$mainContainer
* @type {external:jQuery} */
$mainContainer: null,
/**
* Flag indicatig that this player has switched to full screen at least once
* @name module:JClicPlayer.JClicPlayer#fullScreenChecked
* @type {boolean} */
fullScreenChecked: false,
/**
* The {@link module:project/JClicProject.JClicProject JClicProject} currently hosted in this player
* @name module:JClicPlayer.JClicPlayer#project
* @type {module:project/JClicProject.JClicProject} */
project: null,
/**
* Relative path or absolute URL to be used as a base to access files
* @name module:JClicPlayer.JClicPlayer#basePath
* @type {string} */
basePath: '',
/**
* A {@link external:JSZip} object pointing to a `jclic.zip` or `jclic.scorm.zip` file containing
* the current project.
* Two extra properties will be added to this object when loaded:
* - __zip.fullZipPath__ {string} - The full path of the ZIP file
* - __zip.zipBasePath__ {string} - The path to the folder containing the ZIP file
* @name module:JClicPlayer.JClicPlayer#zip
* @type {external:JSZip} */
zip: null,
/**
* This flag indicates if the player is running inside a document loaded by `file:` protocol
* @name module:JClicPlayer.JClicPlayer#localFS
* @type {boolean}
*/
localFS: false,
/**
* The {@link module:Activity.ActivityPanel ActivityPanel} currently running on this player.
* @name module:JClicPlayer.JClicPlayer#actPanel
* @type {module:Activity.Activity#Panel} */
actPanel: null,
/**
* This object records the list of the activities played during the current session.
* @name module:JClicPlayer.JClicPlayer#history
* @type {module:PlayerHistory.PlayerHistory} */
history: null,
/**
* The Skin currently used by this player.
* @name module:JClicPlayer.JClicPlayer#skin
* @type {module:skins/Skin.Skin} */
skin: null,
/**
* The default Skin to be used if none specified
* @name module:JClicPlayer.JClicPlayer#defaultSkin
* @type {module:skins/Skin.Skin} */
defaultSkin: null,
/**
* The last skin directly specified by a {@link module:project/JClicProject.JClicProject JClicProject}
* @name module:JClicPlayer.JClicPlayer#defaultSkin
* @type {module:skins/Skin.Skin} */
lastProjectSkin: null,
/**
* Object containing references to realized media objects, ready to play.
* @name module:JClicPlayer.JClicPlayer#activeMediaBag
* @type {module:media/ActiveMediaBag.ActiveMediaBag} */
activeMediaBag: null,
/**
* Object responsible for passing the scores obtained by users to a external reporting systems
* when playing activities.
* @name module:JClicPlayer.JClicPlayer#reporter
* @type {module:report/Reporter.Reporter} */
reporter: null,
/**
* Collection of {@link module:AWT.Action} objects used by this player.
* @name module:JClicPlayer.JClicPlayer#actions
* @type {module:AWT.Action[]} */
actions: {},
/**
* Main timer object used to feed the time counter. Ticks every second.
* @name module:JClicPlayer.JClicPlayer#timer
* @type {module:AWT.Timer} */
timer: null,
/**
* Timer for delayed actions
* @name module:JClicPlayer.JClicPlayer#delayedTimer
* @type {module:AWT.Timer} */
delayedTimer: null,
/**
* This variable holds the action to be executed by `delayedTimer`
* @name module:JClicPlayer.JClicPlayer#delayedAction
* @type {module:AWT.Action} */
delayedAction: null,
/**
* @typedef JClicPlayer~counterValType
* @type {object}
* @property {number} score
* @property {number} actions
* @property {number} time */
/**
* Current values of the counters
* @name module:JClicPlayer.JClicPlayer#counterVal
* @type {module:JClicPlayer.JClicPlayer~counterValType} */
counterVal: { score: 0, actions: 0, time: 0 },
/**
* Point indicating the upper-left corner of the current background image
* @name module:JClicPlayer.JClicPlayer#bgImageOrigin
* @type {module:AWT.Point} */
bgImageOrigin: null,
/**
* Whether the player must play all sounds (including system sounds) and other media content
* of the activities.
* @name module:JClicPlayer.JClicPlayer#audioEnabled
* @type {boolean} */
audioEnabled: true,
/**
* Whether the navigation buttons `next` and `back` are enabled o disabled.
* @name module:JClicPlayer.JClicPlayer#navButtonsDisabled
* @type {boolean} */
navButtonsDisabled: false,
/**
* When this flag is `true`, the navigation buttons are always enabled despite
* of the indications made by the activities or the sequence control system.
* This is used only to debug projects with complicated sequence chaining.
* @name module:JClicPlayer.JClicPlayer#navButtonsAlways
* @type {boolean} */
navButtonsAlways: false,
});
export default JClicPlayer;