/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
/** @module */
import {Animator} from "./Animator";
import * as Timing from "./Timing";
import {CameraState} from "../model/CameraState";
import {Frame} from "../model/Presentation";
import {EventEmitter} from "events";
import * as Media from "./Media";
/** The duration of out-of-sequence transitions, in milliseconds.
*
* @readonly
* @default
* @type {number} */
const DEFAULT_TRANSITION_DURATION_MS = 500;
/** The relative zoom of out-of-sequence transitions.
*
* @readonly
* @default
* @type {number} */
const DEFAULT_RELATIVE_ZOOM = 0;
/** The timing function name of out-of-sequence transitions.
*
* @readonly
* @default
* @type {string} */
const DEFAULT_TIMING_FUNCTION = "ease";
/** The zoom step factor for user zoom action (keyboard and mouse wheel)
*
* @readonly
* @default
* @type {number} */
const SCALE_FACTOR = 1.05;
/** The rotation step angle for user zoom action (keyboard and mouse wheel), in degrees.
*
* @readonly
* @default
* @type {number} */
const ROTATE_STEP = 5;
/** Signals that the player has moved to a new frame.
*
* @event module:player/Player.frameChange
*/
/** Signals that the player has changed its `playing` status.
*
* @event module:player/Player.stateChange
*/
/** Sozi presentation player.
*
* @extends EventEmitter
*/
export class Player extends EventEmitter {
/** Initialize a new Sozi player.
*
* If the presentation is opened in edit mode, the player will disable
* these features:
* - mouse and keyboard actions for navigating in the presentation,
* - automatic transitions after a timeout.
*
* @param {module:player/Viewport.Viewport} viewport - The viewport where the presentation is rendered.
* @param {module:model/Presentation.Presentation} presentation - The presentation to play.
* @param {boolean} [editMode=false] - Is the presentation opened in edit mode?
*/
constructor(viewport, presentation, editMode = false) {
super();
/** Is the presentation opened in edit mode?
*
* @default false
* @type {boolean} */
this.editMode = !!editMode;
/** The viewport where the presentation is rendered.
*
* @type {module:player/Viewport.Viewport} */
this.viewport = viewport;
/** The presentation to play.
*
* @type {module:model/Presentation.Presentation} */
this.presentation = presentation;
/** An animator to control the transitions.
*
* @type {module:player/Animator.Animator} */
this.animator = new Animator();
/** The playing/paused state of this player.
*
* @default
* @type {boolean} */
this.playing = false;
/** Is the player waiting for a frame timeout to complete?
*
* @default
* @type {boolean} */
this.waitingTimeout = false;
/** The current frame of the presentation.
*
* @type {module:model/Presentation.Frame} */
this.currentFrame = presentation.frames[0];
/** The target frame of the current transition.
*
* @type {module:model/Presentation.Frame} */
this.targetFrame = presentation.frames[0];
/** The result of `setTimeout` when starting waiting for a frame timeout.
*
* @default
* @type {?number} */
this.timeoutHandle = null;
/** An array of tansition descriptors for each camera.
*
* @default
* @type {object[]}
* @see {@linkcode module:player/Player.Player#setupTransition}
*/
this.transitions = [];
if (!this.editMode) {
this.viewport.on("click", btn => this.onClick(btn));
window.addEventListener("keydown", evt => this.onKeyDown(evt), false);
if (this.presentation.enableMouseTranslation) {
this.viewport.on("dragStart", () => this.pause());
}
this.viewport.on("userChangeState", () => this.pause());
window.addEventListener("keypress", evt => this.onKeyPress(evt), false);
}
this.animator.on("step", p => this.onAnimatorStep(p));
this.animator.on("stop", () => this.onAnimatorStop());
this.animator.on("done", () => this.onAnimatorDone());
this.on("frameChange", () => {
// TODO Only if frame is not transient.
document.title = this.presentation.title + " \u2014 " + this.currentFrame.title;
});
}
/** Move to the next or previous frame on each click event in the viewport.
*
* This method is registered as a {@linkcode module:player/Viewport.click|click}
* event handler of the current {@linkcode module:player/Viewport.Viewport|viewport}.
*
* @param {number} button - The index of the button that was pressed.
*
* @listens module:player/Viewport.click
*/
onClick(button) {
if (this.presentation.enableMouseNavigation) {
switch (button) {
case 0: this.moveToNext(); break;
case 2: this.moveToPrevious(); break;
}
}
}
/** Process a keyboard event.
*
* This method handles the navigation keys if they are enabled in the
* current presentation:
* Arrows, Page-Up/Down, Home, End, Enter, and Space.
*
* @param {KeyboardEvent} evt - The DOM event to process.
*
* @listens keydown
*/
onKeyDown(evt) {
// Keys with Alt/Ctrl/Meta modifiers are ignored
if (evt.altKey || evt.ctrlKey || evt.metaKey) {
return;
}
switch (evt.keyCode) {
case 36: // Home
if (this.presentation.enableKeyboardNavigation) {
if (evt.shiftKey) {
this.jumpToFirst();
}
else {
this.moveToFirst();
}
}
break;
case 35: // End
if (this.presentation.enableKeyboardNavigation) {
if (evt.shiftKey) {
this.jumpToLast();
}
else {
this.moveToLast();
}
}
break;
case 38: // Arrow up
case 33: // Page up
case 37: // Arrow left
if (this.presentation.enableKeyboardNavigation) {
if (evt.shiftKey) {
this.jumpToPrevious();
}
else {
this.moveToPrevious();
}
}
break;
case 40: // Arrow down
case 34: // Page down
case 39: // Arrow right
case 13: // Enter
case 32: // Space
if (this.presentation.enableKeyboardNavigation) {
if (evt.shiftKey) {
this.jumpToNext();
}
else {
this.moveToNext();
}
}
break;
default:
return;
}
evt.stopPropagation();
evt.preventDefault();
}
/** Process a keyboard event.
*
* This method handles character keys: "+", "-", "R", "P", ".".
*
* @param {KeyboardEvent} evt - The DOM event to process.
*
* @listens keypress
*/
onKeyPress(evt) {
// Keys with modifiers are ignored
if (evt.altKey || evt.ctrlKey || evt.metaKey) {
return;
}
switch (evt.charCode || evt.which) {
case 43: // +
if (this.presentation.enableKeyboardZoom) {
this.viewport.zoom(SCALE_FACTOR, this.viewport.width / 2, this.viewport.height / 2);
this.pause();
}
break;
case 45: // -
if (this.presentation.enableKeyboardZoom) {
this.viewport.zoom(1 / SCALE_FACTOR, this.viewport.width / 2, this.viewport.height / 2);
this.pause();
}
break;
case 82: // R
if (this.presentation.enableKeyboardRotation) {
this.viewport.rotate(-ROTATE_STEP);
this.pause();
}
break;
case 114: // r
if (this.presentation.enableKeyboardRotation) {
this.viewport.rotate(ROTATE_STEP);
this.pause();
}
break;
case 80: // P
case 112: //p
if (this.playing) {
this.pause();
}
else {
this.resume();
}
break;
case 46: // .
if (this.presentation.enableKeyboardNavigation) {
this.toggleBlankScreen();
}
break;
default:
return;
}
evt.stopPropagation();
evt.preventDefault();
}
/** Find a frame by its ID or number.
*
* @param {(string|number|module:model/Presentation.Frame)} frame - An indication of the frame to find.
* @returns {?module:model/Presentation.Frame} - The frame found, or `null`.
*/
findFrame(frame) {
if (frame instanceof Frame) {
return frame;
}
if (typeof frame === "string") {
return this.presentation.getFrameWithId(frame);
}
if (typeof frame === "number") {
return this.presentation.frames[frame];
}
return null;
}
/** The frame before the current frame.
*
* If a transition is in progress, returns the frame before the target frame.
* When reaching the first frame, cycle to the last of the presentation.
*
* @readonly
* @type {module:model/Presentation.Frame}
*/
get previousFrame() {
const frame = this.animator.running ? this.targetFrame : this.currentFrame;
const index = (frame.index + this.presentation.frames.length - 1) % this.presentation.frames.length;
return this.presentation.frames[index];
}
/** The frame after the current frame.
*
* If a transition is in progress, returns the frame after the target frame.
* When reaching the last frame, cycle to the first of the presentation.
*
* @readonly
* @type {module:model/Presentation.Frame}
*/
get nextFrame() {
const frame = this.animator.running ? this.targetFrame : this.currentFrame;
const index = (frame.index + 1) % this.presentation.frames.length;
return this.presentation.frames[index];
}
/** Force the viewport to show the current frame.
*
* This method will set all cameras to the states in the current frame,
* and update the viewport.
*
* @fires {module:player/Player.frameChange}
*/
showCurrentFrame() {
this.viewport.setAtStates(this.currentFrame.cameraStates);
this.viewport.update();
this.emit("frameChange");
}
/** Start the presentation from the given frame.
*
* This method sets the {@linkcode module:player/Player.Player#playing|playing} flag,
* shows the desired frame and waits for the frame timeout if needed.
*
* @param {string|number|module:model/Presentation.Frame} frame - The first frame to show.
*
* @fires {module:player/Player.stateChange}
*/
playFromFrame(frame) {
if (!this.playing) {
this.playing = true;
this.emit("stateChange");
}
this.waitingTimeout = false;
this.targetFrame = this.currentFrame = this.findFrame(frame);
this.showCurrentFrame();
this.waitTimeout();
}
/** Pause the presentation.
*
* This method clears the {@linkcode module:player/Player.Player#playing|playing} flag.
* If the presentation was in "waiting" mode due to a timeout
* in the current frame, then it stops waiting.
* The current animation is stopped in its current state.
*
* @listens module:player/Viewport.userChangeState
* @listens module:player/Viewport.dragStart
* @fires {module:player/Player.stateChange}
*/
pause() {
this.animator.stop();
if (this.waitingTimeout) {
window.clearTimeout(this.timeoutHandle);
this.waitingTimeout = false;
}
if (this.playing) {
this.playing = false;
this.emit("stateChange");
}
this.targetFrame = this.currentFrame;
}
/** Resume playing from the current frame. */
resume() {
this.playFromFrame(this.currentFrame);
}
/** Starts waiting before moving to the next frame.
*
* If the current frame has a timeout set, this method
* will register a timer to move to the next frame automatically
* after the specified time.
*
* If the current frame is the last, the presentation will
* move to the first frame.
*/
waitTimeout() {
if (this.currentFrame.timeoutEnable) {
this.waitingTimeout = true;
this.timeoutHandle = window.setTimeout(
() => this.moveToNext(),
this.currentFrame.timeoutMs
);
}
}
/** Jump to a frame.
*
* This method does not animate the transition from the current
* state of the viewport to the desired frame.
*
* The presentation is stopped: if a timeout has been set for the
* target frame, it will be ignored.
*
* @param {string|number|module:model/Presentation.Frame} frame - The frame to show.
*
* @fires {module:player/Player.frameChange}
*/
jumpToFrame(frame) {
this.disableBlankScreen();
this.pause();
this.targetFrame = this.currentFrame = this.findFrame(frame);
this.showCurrentFrame();
}
/** Jumps to the first frame of the presentation.
*
* @fires {module:player/Player.frameChange}
*/
jumpToFirst() {
this.jumpToFrame(0);
}
/** Jump to the last frame of the presentation.
*
* @fires {module:player/Player.frameChange}
*/
jumpToLast() {
this.jumpToFrame(this.presentation.frames.length - 1);
}
/** Jump to the previous frame.
*
* @fires {module:player/Player.frameChange}
*/
jumpToPrevious() {
this.jumpToFrame(this.previousFrame);
}
/** Jumps to the next frame.
*
* @fires {module:player/Player.frameChange}
*/
jumpToNext() {
this.jumpToFrame(this.nextFrame);
}
/** Move to a frame.
*
* This method animates the transition from the current
* state of the viewport to the desired frame.
*
* If the given frame corresponds to the next frame in the list,
* the transition properties of the next frame are used.
* Otherwise, default transition properties are used.
*
* @param {string|number|module:model/Presentation.Frame} frame - The first frame to show.
*
* @fires {module:player/Player.frameChange}
* @fires {module:player/Player.stateChange}
*/
moveToFrame(frame) {
this.disableBlankScreen();
if (this.waitingTimeout) {
window.clearTimeout(this.timeoutHandle);
this.waitingTimeout = false;
}
this.targetFrame = this.findFrame(frame);
let layerProperties = null;
let durationMs = DEFAULT_TRANSITION_DURATION_MS;
let useTransitionPath = false;
let backwards = false;
if (this.currentFrame) {
if (this.targetFrame === this.nextFrame) {
durationMs = this.targetFrame.transitionDurationMs;
layerProperties = this.targetFrame.layerProperties;
useTransitionPath = true;
}
else if (this.targetFrame === this.previousFrame) {
durationMs = this.currentFrame.transitionDurationMs;
layerProperties = this.currentFrame.layerProperties;
useTransitionPath = true;
backwards = true;
}
}
if (!this.editMode && !this.playing) {
this.playing = true;
this.emit("stateChange");
}
for (let camera of this.viewport.cameras) {
let timingFunction = DEFAULT_TIMING_FUNCTION;
let relativeZoom = DEFAULT_RELATIVE_ZOOM;
let transitionPath = null;
if (layerProperties) {
const lp = layerProperties[camera.layer.index];
relativeZoom = lp.transitionRelativeZoom;
timingFunction = lp.transitionTimingFunction;
if (useTransitionPath) {
transitionPath = lp.transitionPath;
}
}
this.setupTransition(camera, timingFunction, relativeZoom, transitionPath, backwards);
}
this.animator.start(durationMs);
}
/** Move to the first frame of the presentation.
*
* @fires {module:player/Player.frameChange}
* @fires {module:player/Player.stateChange}
*/
moveToFirst() {
this.moveToFrame(0);
}
/** Move to the last frame of the presentation.
*
* @fires {module:player/Player.frameChange}
* @fires {module:player/Player.stateChange}
*/
moveToLast() {
this.moveToFrame(this.presentation.frames.length - 1);
}
/** Move to the previous frame.
*
* This method skips previous frames with 0 ms timeout.
*
* @fires {module:player/Player.frameChange}
* @fires {module:player/Player.stateChange}
*/
moveToPrevious() {
for (let index = this.previousFrame.index; index >= 0; index --) {
const frame = this.presentation.frames[index];
if (!frame.timeoutEnable || frame.timeoutMs !== 0) {
this.moveToFrame(frame);
break;
}
}
}
/** Move to the next frame.
*
* @fires {module:player/Player.frameChange}
* @fires {module:player/Player.stateChange}
*/
moveToNext() {
this.moveToFrame(this.nextFrame);
}
/** Restore the current frame.
*
* This method restores the viewport to fit the current frame,
* e.g. after the viewport has been zoomed or dragged.
*
* @fires {module:player/Player.frameChange}
* @fires {module:player/Player.stateChange}
*/
moveToCurrent() {
this.moveToFrame(this.currentFrame);
}
/** Move to a frame in *preview* mode.
*
* This method animates the transition from the current
* state of the viewport to the desired frame, using
* default transition settings.
*
* @param {string|number|module:model/Presentation.Frame} frame - The first frame to show.
*
* @fires {module:player/Player.frameChange}
* @fires {module:player/Player.stateChange}
*/
previewFrame(frame) {
this.targetFrame = this.findFrame(frame);
for (let camera of this.viewport.cameras) {
this.setupTransition(camera, DEFAULT_TIMING_FUNCTION, DEFAULT_RELATIVE_ZOOM);
}
this.animator.start(DEFAULT_TRANSITION_DURATION_MS);
}
/** Prepare a transition for a given camera.
*
* The initial state is the current state of the given camera.
* The final state is the camera state in the current layer of the target frame.
*
* A new descriptor is added to the {@linkcode module:player/Player.Player#transitions|list of transition descriptors}.
*
* @param {module:player/Camera.Camera} camera - The camera that will perform the transition.
* @param {string} timingFunction - The name of function that maps the progress indicator to the relative distance already completed between the initial and final states (between 0 and 1).
* @param {number} relativeZoom - An additional zooming factor to apply during the transition.
* @param {SVGPathElement} svgPath - An SVG path to follow during the transition.
* @param {boolean} [backwards=false] - If `true`, apply the reverse timing function and follow the transition path in the opposite direction.
*/
setupTransition(camera, timingFunction, relativeZoom, svgPath, backwards=false) {
if (this.animator.running) {
this.animator.stop();
}
timingFunction = Timing[timingFunction];
if (backwards) {
timingFunction = timingFunction.reverse;
}
this.transitions.push({
camera,
initialState: new CameraState(camera),
finalState: this.targetFrame.cameraStates[camera.layer.index],
timingFunction,
relativeZoom,
svgPath,
backwards
});
}
/** Process an animation step.
*
* This method moves each camera according to the {@linkcode module:player/Player.Player#transitions|list of transition descriptors}.
*
* @param {number} progress - The relative time already elapsed between the initial and final states of the current animation (between 0 and 1).
*
* @listens module:player/Animator.step
*
* @see {@linkcode module:player/Player.Camera#interpolate}
*/
onAnimatorStep(progress) {
for (let transition of this.transitions) {
transition.camera.interpolate(transition.initialState, transition.finalState, progress, transition.timingFunction, transition.relativeZoom, transition.svgPath, transition.reverse);
transition.camera.update();
}
}
/** Finalize a transition when the current animation is stopped.
*
* This method clears the {@linkcode module:player/Player.Player#transitions|list of transition descriptors}.
*
* @listens module:player/Animator.stop
* @fires {module:player/Player.frameChange}
*/
onAnimatorStop() {
this.transitions = [];
this.currentFrame = this.targetFrame;
this.emit("frameChange");
}
/** Finalize a transition when the current animation is complete.
*
* If the presentation is in playing state, wait for the current frame's timeout.
*
* @listens module:player/Animator.done
* @fires {module:player/Player.frameChange}
*/
onAnimatorDone() {
this.onAnimatorStop();
if (this.playing) {
this.waitTimeout();
}
}
/** Is the blank screen activated?
*
* @readonly
* @type {boolean}
*/
get blankScreenIsVisible() {
return document.querySelector(".sozi-blank-screen").style.visibility === "visible";
}
/** Enable the blank screen.
*
* This method will pause the presentation and hide the viewport under
* an opaque element.
*/
enableBlankScreen() {
this.pause();
const blankScreen = document.querySelector(".sozi-blank-screen");
if (blankScreen) {
blankScreen.style.opacity = 1;
blankScreen.style.visibility = "visible";
}
}
/** Disable the blank screen.
*
* This method will reveal the viewport again.
*/
disableBlankScreen() {
const blankScreen = document.querySelector(".sozi-blank-screen");
if (blankScreen) {
blankScreen.style.opacity = 0;
blankScreen.style.visibility = "hidden";
}
}
/** Toggle the visibility of the elements that hides the viewport. */
toggleBlankScreen() {
if (this.blankScreenIsVisible) {
this.disableBlankScreen();
}
else {
this.enableBlankScreen();
}
}
/** Disable all video and audio elements in the current presentation. */
disableMedia() {
Media.disable();
}
}