VulpesOS/gaia/apps/ftu/js/tutorial.js
OverSu b38dea0597 Publish Vulpes Preview 15 sources for desktop, Android and Tundra
Share Gaia assets and adapters, keep private profiles and development documents local, and update both public READMEs.
2026-09-30 23:13:03 +02:00

383 lines
12 KiB
JavaScript
Executable file

/* global ScreenLayout, Promise,
Utils, FinishScreen, LazyLoader, TutorialUtils */
/* exported Tutorial */
(function(exports) {
'use strict';
// default layout
var currentLayout = 'tiny';
// Most used DOM elements
var dom = {};
/**
* Manages and controls the configuration, content and state of the tutorial
* @module Tutorial
*/
var elementIDs = [
'tutorial',
'tutorial-step-title',
'tutorial-step-media',
'tutorial-step-image',
'tutorial-step-video',
'forward-tutorial',
'back-tutorial'
];
var Tutorial = {
// A configuration object.
config: null,
// Config for the current tutorial steps
_stepsConfig: {},
// Which tutorial steps to use
_stepsKey: '',
// Keeps track of the current step
_currentStep: 1,
// Track initialized state of tutorial
_initialized: false,
/**
* Initialize the tutorial. This is async as a config file must be loaded.
* A Sequence (array of sync or async functions) is used to manage the init
* tasks.
* When complete, the tutorial is ready to be shown via the 'start' method
*
* @param {String} stepsKey a key into the tutorial config object, i
* i.e. a version or version delta like 1.3..2.0
* @param {Function} onLoaded optional callback for when init is complete
* @memberof Tutorial
*/
init: function(stepsKey, onLoaded) {
// init is async
// need to load config, then load the first step and its assets.
if (this._initialized || this._initialization) {
// init already underway
return;
}
var initTasks = this._initialization = new TutorialUtils.Sequence(
// config should load or already be loaded.
// failure should abort
this.loadConfig.bind(this),
this._initWithConfig.bind(this, stepsKey)
);
initTasks.onabort = this._onAbortInitialization.bind(this);
initTasks.oncomplete =
this._onCompleteInitialization.bind(this, onLoaded);
// first time here: cache DOM elements
elementIDs.forEach(function(name) {
dom[Utils.camelCase(name)] = document.getElementById(name);
if (!dom[Utils.camelCase(name)]) {
console.error('Cache DOM elements: couldnt cache: ' + name);
}
}, this);
initTasks.next();
// watch a ton of video events as context for the tutorial
// media loading/playing
var mediaEvents = this._mediaEvents = ['abort',
'canplay',
'canplaythrough',
'durationchange',
'emptied',
'ended',
'error',
'interruptbegin',
'interruptend',
'loadeddata',
'loadedmetadata',
'loadstart',
'mozaudioavailable',
'pause',
'play',
'playing',
'stalled',
'suspend',
'waiting'];
this._debugEventHandler = {
handleEvent: function(evt) {
var errCodes = {
'1': 'MEDIA_ERR_ABORTED',
'2': 'MEDIA_ERR_NETWORK',
'3': 'MEDIA_ERR_DECODE',
'4': 'MEDIA_ERR_SRC_NOT_SUPPORTED'
};
console.log('TUTORIAL: media-event: '+ evt.type);
if (evt.type == 'error') {
var errtype = errCodes[evt.target.error.code] || 'unknown';
console.log('TUTORIAL: media-event, ' + errtype + ' error');
}
}
};
mediaEvents.forEach(name => {
dom.tutorialStepVideo.addEventListener(name, this._debugEventHandler);
});
},
/**
* Show the tutorial and play the first step
* May be called during or after the init process
* We defer rasing the 'tutorialinitialized' event until this point
* as it signals the tutorial step content is loaded and displayed
*
* @param {Function} onReady optional callback for when start is complete
* @memberof Tutorial
*/
start: function(onReady) {
var sequence;
var initInProgress = false;
if (this._initialization) {
// init still underway, tack steps onto existing sequence
sequence = this._initialization;
initInProgress = true;
} else {
// init already complete, create new sequence
this._initialization = sequence = new TutorialUtils.Sequence();
sequence.onabort = this._onAbortInitialization.bind(this);
sequence.oncomplete =
this._onCompleteInitialization.bind(this);
}
sequence.push(function setInitialStep() {
// setStep should return promise given by _loadMedia
console.log('TUTORIAL: setInitialStep');
return this._setStep();
}.bind(this));
sequence.push(function showTutorialAndFinishInit() {
// Show the panel
dom.tutorial.classList.add('show');
// Custom event that can be used to apply (screen reader) visibility
// changes.
console.log('TUTORIAL: dispatching tutorialinitialized');
window.dispatchEvent(new CustomEvent('tutorialinitialized'));
});
if(typeof onReady === 'function') {
sequence.push(onReady);
}
if (!initInProgress) {
// init done, starting start sequence
sequence.next();
}
},
/**
* Continue initialization once config data is loaded
* Called as a part of the init sequence
*
* @param {String} stepsKey a key into the tutorial config object, i
* i.e. a version or version delta like 1.3..2.0
* @memberof Tutorial
*/
_initWithConfig: function(stepsKey) {
stepsKey = stepsKey || 'default';
this._stepsKey = stepsKey;
this._stepsConfig = this.config[stepsKey] || this.config['default'];
// Add event listeners
dom.forwardTutorial.addEventListener('click', this);
dom.backTutorial.addEventListener('click', this);
// Set the first step
this._currentStep = 1;
},
_onAbortInitialization: function() {
this._initialization = null;
},
_onCompleteInitialization: function(onReady) {
this._initialization = null;
this._initialized = true;
if(typeof onReady === 'function') {
onReady();
}
},
/**
* Advance the tutorial to the given step (first step if no value given)
*
* @param {Number} value number of the step to show (1-based)
* @memberof Tutorial
*/
_setStep: function (value) {
// If value is bigger than the max, show finish screen
value = (typeof value === 'number') ? value : this._currentStep;
var stepIndex = value - 1;
if (stepIndex >= this._stepsConfig.steps.length) {
return Promise.resolve().then(function() {
Tutorial.done();
});
}
var stepData = this._stepsConfig.steps[stepIndex];
if (!stepData) {
return Promise.reject('No data for step: ' + value);
}
// Set the step
dom.tutorial.dataset.step = this._currentStep;
// Internationalize
document.l10n.setAttributes(
dom.tutorialStepTitle,
stepData.l10nKey
);
// Update the image/video
var imgElement = dom.tutorialStepImage,
videoElement = dom.tutorialStepVideo;
var stepPromise;
if (stepData.video) {
stepPromise = TutorialUtils.getBestAssetForDirection(stepData.video)
.then((bestSrc) => {
return TutorialUtils.loadAndPlayMedia(videoElement, bestSrc);
});
videoElement.hidden = false;
imgElement.hidden = true;
} else {
imgElement.hidden = false;
stepPromise = TutorialUtils.loadMedia(imgElement, stepData.image);
imgElement.hidden = false;
videoElement.hidden = true;
}
return stepPromise;
},
/**
* DOM Event handler
*
* @param {DOMEvent} evt Event object
* @memberof Tutorial
*/
handleEvent: function(evt) {
if (evt.type === 'click') {
switch(evt.target) {
case dom.forwardTutorial:
this.next(evt);
break;
case dom.backTutorial:
this.back(evt);
break;
}
}
},
/**
* Advance to the next step in the tutorial
* @memberof Tutorial
*/
next: function() {
return this._setStep(++this._currentStep);
},
/**
* Go back to the previous step in the tutorial
* @memberof Tutorial
*/
back: function() {
return this._setStep(--this._currentStep);
},
/**
* Tutorial complete
* @memberof Tutorial
*/
done: function() {
var isUpgrade = this._stepsKey && this._stepsKey !== 'default';
FinishScreen.init(isUpgrade);
dom.tutorial.classList.remove('show');
dom.tutorialStepVideo.removeAttribute('src');
dom.tutorialStepImage.removeAttribute('src');
},
/**
* Load the config with steps and associated resources for the tutorial
* We have different config files for each screen category:
* phone is 'tiny', tablet is 'large'
* and corresponding tiny.json, large.json
*
* @returns {Promise}
* @memberof Tutorial
*/
loadConfig: function() {
if (!this._configPromise) {
// Update the value of the layout if needed
// 'ScreenLayout' give us 4 different values
// tiny: '(max-width: 767px)',
// small: '(min-width: 768px) and (max-width: 991px)',
// medium: '(min-width: 992px) and (max-width: 1200px)',
// large: '(min-width: 1201px)',
//
// Currently we are taking into account only 'tiny', and we are
// going to consider 'tablet' as 'large'. If we want to add more
// or specific features for 'small' & 'medium', we should add more
// logic here.
currentLayout = ScreenLayout.getCurrentLayout() === 'tiny' ?
'tiny' : 'large';
var configUrl = '/config/' + currentLayout + '.json';
this._configPromise = LazyLoader.getJSON(configUrl)
.then(function(json) {
Tutorial.config = json;
return Tutorial.config;
}, function() {
return new Error('Tutorial config failed to load from: ' + configUrl);
});
}
return this._configPromise;
},
/**
* Test helper to reset the tutorial to its pre-initialized state
* to allow init to be called again
* @memberof Tutorial
*/
reset: function() {
var resetPromise = Promise.resolve();
if (dom.tutorialStepVideo) {
this._mediaEvents.forEach(name => {
dom.tutorialStepVideo.removeEventListener(
name,
this._debugEventHandler
);
});
dom.tutorialStepVideo.hidden = true;
if (dom.tutorialStepVideo.src) {
resetPromise = new Promise((resolve, reject) => {
dom.tutorialStepVideo.addEventListener('emptied', () => {
resolve();
});
dom.tutorialStepVideo.removeAttribute('src');
dom.tutorialStepVideo.load();
});
}
}
if (this._initialization) {
this._initialization.abort();
this._initialization = null;
}
this._configPromise = null;
this._currentStep = 1;
this._stepsConfig = this.config = null;
if (this._initialized) {
dom.tutorial.classList.remove('show');
this._initialized = false;
}
document.getElementById('tutorial').classList.remove('show');
return resetPromise;
}
};
exports.Tutorial = Tutorial;
})(this);