Share Gaia assets and adapters, keep private profiles and development documents local, and update both public READMEs.
1782 lines
54 KiB
JavaScript
1782 lines
54 KiB
JavaScript
/* exported GestureDetector */
|
|
|
|
|
|
|
|
/**
|
|
* GestureDetector.js: generate events for one and two finger gestures.
|
|
*
|
|
* A GestureDetector object listens for touch events on a specified
|
|
* element and generates higher-level events that describe one and two finger
|
|
* gestures on the element.
|
|
*
|
|
* Supported events:
|
|
*
|
|
* tap like a click event
|
|
* dbltap like dblclick
|
|
* pan one finger motion
|
|
* swipe when a finger is released following pan events
|
|
* holdstart touch and hold. Must set an option to get these.
|
|
* holdmove motion after a holdstart event
|
|
* holdend when the finger goes up after holdstart/holdmove
|
|
* transform 2-finger pinch and twist gestures for scaling and rotation
|
|
*
|
|
* Each of these events is a bubbling CustomEvent with important details in the
|
|
* event.detail field. The event details are not yet stable and are not yet
|
|
* documented. See the calls to emitEvent() for details.
|
|
*
|
|
* To use this library, create a GestureDetector object by passing an element to
|
|
* the GestureDetector() constructor and then calling startDetecting() on it.
|
|
* The element will be the target of all the emitted gesture events. You can
|
|
* also pass an optional object as the second constructor argument. If you're
|
|
* interested in holdstart/holdmove/holdend events, pass {holdEvents:true} as
|
|
* this second argument. Otherwise they will not be generated.
|
|
* If you want to customize the pan threshold, pass {panThreshold:X}
|
|
* (X and Y in pixels) in the options argument.
|
|
*
|
|
* Implementation note: event processing is done with a simple finite-state
|
|
* machine. This means that in general, the various kinds of gestures are
|
|
* mutually exclusive. You won't get pan events until your finger has
|
|
* moved more than a minimum threshold, for example, but it does, the FSM enters
|
|
* a new state in which it can emit pan and swipe events and cannot emit hold
|
|
* events. Similarly, if you've started a 1 finger pan/swipe gesture and
|
|
* accidentally touch with a second finger, you'll continue to get pan events,
|
|
* and won't suddenly start getting 2-finger transform events.
|
|
*
|
|
* This library never calls preventDefault() or stopPropagation on any of the
|
|
* events it processes, so the raw touch events should still be
|
|
* available for other code to process. It is not clear to me whether this is a
|
|
* feature or a bug.
|
|
*/
|
|
|
|
var GestureDetector = (function() {
|
|
|
|
//
|
|
// Constructor
|
|
//
|
|
function GD(e, options) {
|
|
this.element = e;
|
|
this.options = options || {};
|
|
this.options.panThreshold = this.options.panThreshold || GD.PAN_THRESHOLD;
|
|
this.state = initialState;
|
|
this.timers = {};
|
|
}
|
|
|
|
//
|
|
// Public methods
|
|
//
|
|
|
|
GD.prototype.startDetecting = function() {
|
|
var self = this;
|
|
eventtypes.forEach(function(t) {
|
|
self.element.addEventListener(t, self);
|
|
});
|
|
};
|
|
|
|
GD.prototype.stopDetecting = function() {
|
|
var self = this;
|
|
eventtypes.forEach(function(t) {
|
|
self.element.removeEventListener(t, self);
|
|
});
|
|
};
|
|
|
|
//
|
|
// Internal methods
|
|
//
|
|
|
|
GD.prototype.handleEvent = function(e) {
|
|
var handler = this.state[e.type];
|
|
if (!handler) {
|
|
return;
|
|
}
|
|
// If this is a touch event handle each changed touch separately
|
|
if (e.changedTouches) {
|
|
for (var i = 0; i < e.changedTouches.length; i++) {
|
|
handler(this, e, e.changedTouches[i]);
|
|
// The first changed touch might have changed the state of the FSM.
|
|
handler = this.state[e.type];
|
|
if (!handler) {
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
else { // Otherwise, just dispatch the event to the handler
|
|
handler(this, e);
|
|
}
|
|
};
|
|
|
|
GD.prototype.startTimer = function(type, time) {
|
|
this.clearTimer(type);
|
|
var self = this;
|
|
this.timers[type] = setTimeout(function() {
|
|
self.timers[type] = null;
|
|
var handler = self.state[type];
|
|
if (handler) {
|
|
handler(self, type);
|
|
}
|
|
}, time);
|
|
};
|
|
|
|
GD.prototype.clearTimer = function(type) {
|
|
if (this.timers[type]) {
|
|
clearTimeout(this.timers[type]);
|
|
this.timers[type] = null;
|
|
}
|
|
};
|
|
|
|
// Switch to a new FSM state, and call the init() function of that
|
|
// state, if it has one. The event and touch arguments are optional
|
|
// and are just passed through to the state init function.
|
|
GD.prototype.switchTo = function(state, event, touch) {
|
|
this.state = state;
|
|
if (state.init) {
|
|
state.init(this, event, touch);
|
|
}
|
|
};
|
|
|
|
GD.prototype.emitEvent = function(type, detail) {
|
|
if (!this.target) {
|
|
console.error('Attempt to emit event with no target');
|
|
return;
|
|
}
|
|
|
|
var event = this.element.ownerDocument.createEvent('CustomEvent');
|
|
event.initCustomEvent(type, true, true, detail);
|
|
this.target.dispatchEvent(event);
|
|
};
|
|
|
|
//
|
|
// Tuneable parameters
|
|
//
|
|
GD.HOLD_INTERVAL = 1000; // Hold events after 1000 ms
|
|
GD.PAN_THRESHOLD = 20; // 20 pixels movement before touch panning
|
|
GD.DOUBLE_TAP_DISTANCE = 50;
|
|
GD.DOUBLE_TAP_TIME = 500;
|
|
GD.VELOCITY_SMOOTHING = 0.5;
|
|
|
|
// Don't start sending transform events until the gesture exceeds a threshold
|
|
GD.SCALE_THRESHOLD = 20; // pixels
|
|
GD.ROTATE_THRESHOLD = 22.5; // degrees
|
|
|
|
// For pans and zooms, we compute new starting coordinates that are part way
|
|
// between the initial event and the event that crossed the threshold so that
|
|
// the first event we send doesn't cause a big lurch. This constant must be
|
|
// between 0 and 1 and says how far along the line between the initial value
|
|
// and the new value we pick
|
|
GD.THRESHOLD_SMOOTHING = 0.9;
|
|
|
|
//
|
|
// Helpful shortcuts and utility functions
|
|
//
|
|
|
|
var abs = Math.abs, floor = Math.floor, sqrt = Math.sqrt, atan2 = Math.atan2;
|
|
var PI = Math.PI;
|
|
|
|
// The names of events that we need to register handlers for
|
|
var eventtypes = [
|
|
'touchstart',
|
|
'touchmove',
|
|
'touchend'
|
|
];
|
|
|
|
// Return the event's timestamp in ms
|
|
function eventTime(e) {
|
|
// In gecko, synthetic events seem to be in microseconds rather than ms.
|
|
// So if the timestamp is much larger than the current time, assue it is
|
|
// in microseconds and divide by 1000
|
|
var ts = e.timeStamp;
|
|
if (ts > 2 * Date.now()) {
|
|
return Math.floor(ts / 1000);
|
|
} else {
|
|
return ts;
|
|
}
|
|
}
|
|
|
|
|
|
// Return an object containg the space and time coordinates of
|
|
// and event and touch. We freeze the object to make it immutable so
|
|
// we can pass it in events and not worry about values being changed.
|
|
function coordinates(e, t) {
|
|
return Object.freeze({
|
|
screenX: t.screenX,
|
|
screenY: t.screenY,
|
|
clientX: t.clientX,
|
|
clientY: t.clientY,
|
|
timeStamp: eventTime(e)
|
|
});
|
|
}
|
|
|
|
// Like coordinates(), but return the midpoint between two touches
|
|
function midpoints(e, t1, t2) {
|
|
return Object.freeze({
|
|
screenX: floor((t1.screenX + t2.screenX) / 2),
|
|
screenY: floor((t1.screenY + t2.screenY) / 2),
|
|
clientX: floor((t1.clientX + t2.clientX) / 2),
|
|
clientY: floor((t1.clientY + t2.clientY) / 2),
|
|
timeStamp: eventTime(e)
|
|
});
|
|
}
|
|
|
|
// Given coordinates objects c1 and c2, return a new coordinates object
|
|
// representing a point and time along the line between those points.
|
|
// The position of the point is controlled by the THRESHOLD_SMOOTHING constant
|
|
function between(c1, c2) {
|
|
var r = GD.THRESHOLD_SMOOTHING;
|
|
return Object.freeze({
|
|
screenX: floor(c1.screenX + r * (c2.screenX - c1.screenX)),
|
|
screenY: floor(c1.screenY + r * (c2.screenY - c1.screenY)),
|
|
clientX: floor(c1.clientX + r * (c2.clientX - c1.clientX)),
|
|
clientY: floor(c1.clientY + r * (c2.clientY - c1.clientY)),
|
|
timeStamp: floor(c1.timeStamp + r * (c2.timeStamp - c1.timeStamp))
|
|
});
|
|
}
|
|
|
|
// Compute the distance between two touches
|
|
function touchDistance(t1, t2) {
|
|
var dx = t2.screenX - t1.screenX;
|
|
var dy = t2.screenY - t1.screenY;
|
|
return sqrt(dx * dx + dy * dy);
|
|
}
|
|
|
|
// Compute the direction (as an angle) of the line between two touches
|
|
// Returns a number d, -180 < d <= 180
|
|
function touchDirection(t1, t2) {
|
|
return atan2(t2.screenY - t1.screenY,
|
|
t2.screenX - t1.screenX) * 180 / PI;
|
|
}
|
|
|
|
// Compute the clockwise angle between direction d1 and direction d2.
|
|
// Returns an angle a -180 < a <= 180.
|
|
function touchRotation(d1, d2) {
|
|
var angle = d2 - d1;
|
|
if (angle > 180) {
|
|
angle -= 360;
|
|
} else if (angle <= -180) {
|
|
angle += 360;
|
|
}
|
|
return angle;
|
|
}
|
|
|
|
// Determine if two taps are close enough in time and space to
|
|
// trigger a dbltap event. The arguments are objects returned
|
|
// by the coordinates() function.
|
|
function isDoubleTap(lastTap, thisTap) {
|
|
var dx = abs(thisTap.screenX - lastTap.screenX);
|
|
var dy = abs(thisTap.screenY - lastTap.screenY);
|
|
var dt = thisTap.timeStamp - lastTap.timeStamp;
|
|
return (dx < GD.DOUBLE_TAP_DISTANCE &&
|
|
dy < GD.DOUBLE_TAP_DISTANCE &&
|
|
dt < GD.DOUBLE_TAP_TIME);
|
|
}
|
|
|
|
//
|
|
// The following objects are the states of our Finite State Machine
|
|
//
|
|
|
|
// In this state we're not processing any gestures, just waiting
|
|
// for an event to start a gesture and ignoring others
|
|
var initialState = {
|
|
name: 'initialState',
|
|
init: function(d, e, t) {
|
|
// When we enter or return to the initial state, clear
|
|
// the detector properties that were tracking gestures
|
|
// Don't clear d.lastTap here, though. We need it for dbltap events
|
|
d.target = null;
|
|
d.start = d.last = null;
|
|
d.touch1 = d.touch2 = null;
|
|
d.vx = d.vy = null;
|
|
d.startDistance = d.lastDistance = null;
|
|
d.startDirection = d.lastDirection = null;
|
|
d.lastMidpoint = null;
|
|
d.scaled = d.rotated = null;
|
|
|
|
// If we were invoked with an event and touch, then process it.
|
|
if (e && t && e.type === 'touchstart') {
|
|
initialState.touchstart(d, e, t);
|
|
}
|
|
},
|
|
|
|
// Switch to the touchstarted state and process the touch event there
|
|
touchstart: function(d, e, t) {
|
|
d.switchTo(touchStartedState, e, t);
|
|
}
|
|
};
|
|
|
|
// One finger is down but we haven't generated any event yet. We're
|
|
// waiting to see... If the finger goes up soon, its a tap. If the finger
|
|
// stays down and still, its a hold. If the finger moves its a pan/swipe.
|
|
// And if a second finger goes down, its a transform
|
|
var touchStartedState = {
|
|
name: 'touchStartedState',
|
|
init: function(d, e, t) {
|
|
// Remember the target of the event
|
|
d.target = e.target;
|
|
// Remember the id of the touch that started
|
|
d.touch1 = t.identifier;
|
|
// Get the coordinates of the touch
|
|
d.start = d.last = coordinates(e, t);
|
|
// Start a timer for a hold
|
|
// If we're doing hold events, start a timer for them
|
|
if (d.options.holdEvents) {
|
|
d.startTimer('holdtimeout', GD.HOLD_INTERVAL);
|
|
}
|
|
},
|
|
|
|
touchstart: function(d, e, t) {
|
|
// If another finger goes down in this state, then
|
|
// go to transform state to start 2-finger gestures.
|
|
d.clearTimer('holdtimeout');
|
|
|
|
if (e.touches.length > 1) { // verify that we have 2 fingers
|
|
d.switchTo(transformState, e, t);
|
|
}
|
|
else {
|
|
// If Gecko fails to deliver a touchend event to us (bug 1162771, e.g.)
|
|
// then we might get two touchstart events in a row and end up here
|
|
// when there is actually only one finger currently on the screen.
|
|
// In that case, we switch back to the initial state and process
|
|
// this touch from that state. (We don't switch directly to
|
|
// touchStartedState because we need to reset things in the
|
|
// initialState init() function.)
|
|
console.warn('Ignoring missing touchend event. See bug 1162771.');
|
|
d.switchTo(initialState, e, t);
|
|
}
|
|
},
|
|
touchmove: function(d, e, t) {
|
|
// Ignore any touches but the initial one
|
|
// This could happen if there was still a finger down after
|
|
// the end of a previous 2-finger gesture, e.g.
|
|
if (t.identifier !== d.touch1) {
|
|
return;
|
|
}
|
|
|
|
if (abs(t.screenX - d.start.screenX) > d.options.panThreshold ||
|
|
abs(t.screenY - d.start.screenY) > d.options.panThreshold) {
|
|
d.clearTimer('holdtimeout');
|
|
d.switchTo(panStartedState, e, t);
|
|
}
|
|
},
|
|
touchend: function(d, e, t) {
|
|
// Ignore any touches but the initial one
|
|
if (t.identifier !== d.touch1) {
|
|
return;
|
|
}
|
|
|
|
// If there was a previous tap that was close enough in time
|
|
// and space, then emit a 'dbltap' event
|
|
if (d.lastTap && isDoubleTap(d.lastTap, d.start)) {
|
|
d.emitEvent('tap', d.start);
|
|
d.emitEvent('dbltap', d.start);
|
|
// clear the lastTap property, so we don't get another one
|
|
d.lastTap = null;
|
|
}
|
|
else {
|
|
// Emit a 'tap' event using the starting coordinates
|
|
// as the event details
|
|
d.emitEvent('tap', d.start);
|
|
|
|
// Remember the coordinates of this tap so we can detect double taps
|
|
d.lastTap = coordinates(e, t);
|
|
}
|
|
|
|
// In either case clear the timer and go back to the initial state
|
|
d.clearTimer('holdtimeout');
|
|
d.switchTo(initialState);
|
|
},
|
|
|
|
holdtimeout: function(d) {
|
|
d.switchTo(holdState);
|
|
}
|
|
|
|
};
|
|
|
|
// A single touch has moved enough to exceed the pan threshold and now
|
|
// we're going to generate pan events after each move and a swipe event
|
|
// when the touch ends. We ignore any other touches that occur while this
|
|
// pan/swipe gesture is in progress.
|
|
var panStartedState = {
|
|
name: 'panStartedState',
|
|
init: function(d, e, t) {
|
|
// Panning doesn't start until the touch has moved more than a
|
|
// certain threshold. But we don't want the pan to have a jerky
|
|
// start where the first event is a big distance. So proceed as
|
|
// pan actually started at a point along the path between the
|
|
// first touch and this current touch.
|
|
d.start = d.last = between(d.start, coordinates(e, t));
|
|
|
|
// If we transition into this state with a touchmove event,
|
|
// then process it with that handler. If we don't do this then
|
|
// we can end up with swipe events that don't know their velocity
|
|
if (e.type === 'touchmove') {
|
|
panStartedState.touchmove(d, e, t);
|
|
}
|
|
},
|
|
|
|
touchmove: function(d, e, t) {
|
|
// Ignore any fingers other than the one we're tracking
|
|
if (t.identifier !== d.touch1) {
|
|
return;
|
|
}
|
|
|
|
// Each time the touch moves, emit a pan event but stay in this state
|
|
var current = coordinates(e, t);
|
|
d.emitEvent('pan', {
|
|
absolute: {
|
|
dx: current.screenX - d.start.screenX,
|
|
dy: current.screenY - d.start.screenY
|
|
},
|
|
relative: {
|
|
dx: current.screenX - d.last.screenX,
|
|
dy: current.screenY - d.last.screenY
|
|
},
|
|
position: current
|
|
});
|
|
|
|
// Track the pan velocity so we can report this with the swipe
|
|
// Use a exponential moving average for a bit of smoothing
|
|
// on the velocity
|
|
var dt = current.timeStamp - d.last.timeStamp;
|
|
var vx = (current.screenX - d.last.screenX) / dt;
|
|
var vy = (current.screenY - d.last.screenY) / dt;
|
|
|
|
if (d.vx == null) { // first time; no average
|
|
d.vx = vx;
|
|
d.vy = vy;
|
|
}
|
|
else {
|
|
d.vx = d.vx * GD.VELOCITY_SMOOTHING +
|
|
vx * (1 - GD.VELOCITY_SMOOTHING);
|
|
d.vy = d.vy * GD.VELOCITY_SMOOTHING +
|
|
vy * (1 - GD.VELOCITY_SMOOTHING);
|
|
}
|
|
|
|
d.last = current;
|
|
},
|
|
touchend: function(d, e, t) {
|
|
// Ignore any fingers other than the one we're tracking
|
|
if (t.identifier !== d.touch1) {
|
|
return;
|
|
}
|
|
|
|
// Emit a swipe event when the finger goes up.
|
|
// Report start and end point, dx, dy, dt, velocity and direction
|
|
var current = coordinates(e, t);
|
|
var dx = current.screenX - d.start.screenX;
|
|
var dy = current.screenY - d.start.screenY;
|
|
// angle is a positive number of degrees, starting at 0 on the
|
|
// positive x axis and increasing clockwise.
|
|
var angle = atan2(dy, dx) * 180 / PI;
|
|
if (angle < 0) {
|
|
angle += 360;
|
|
}
|
|
|
|
// Direction is 'right', 'down', 'left' or 'up'
|
|
var direction;
|
|
if (angle >= 315 || angle < 45) {
|
|
direction = 'right';
|
|
} else if (angle >= 45 && angle < 135) {
|
|
direction = 'down';
|
|
} else if (angle >= 135 && angle < 225) {
|
|
direction = 'left';
|
|
} else if (angle >= 225 && angle < 315) {
|
|
direction = 'up';
|
|
}
|
|
|
|
d.emitEvent('swipe', {
|
|
start: d.start,
|
|
end: current,
|
|
dx: dx,
|
|
dy: dy,
|
|
dt: e.timeStamp - d.start.timeStamp,
|
|
vx: d.vx,
|
|
vy: d.vy,
|
|
direction: direction,
|
|
angle: angle
|
|
});
|
|
|
|
// Go back to the initial state
|
|
d.switchTo(initialState);
|
|
}
|
|
};
|
|
|
|
// We enter this state if the user touches and holds for long enough
|
|
// without moving much. When we enter we emit a holdstart event. Motion
|
|
// after the holdstart generates holdmove events. And when the touch ends
|
|
// we generate a holdend event. holdmove and holdend events can be used
|
|
// kind of like drag and drop events in a mouse-based UI. Currently,
|
|
// these events just report the coordinates of the touch. Do we need
|
|
// other details?
|
|
var holdState = {
|
|
name: 'holdState',
|
|
init: function(d) {
|
|
d.emitEvent('holdstart', d.start);
|
|
},
|
|
|
|
touchmove: function(d, e, t) {
|
|
var current = coordinates(e, t);
|
|
d.emitEvent('holdmove', {
|
|
absolute: {
|
|
dx: current.screenX - d.start.screenX,
|
|
dy: current.screenY - d.start.screenY
|
|
},
|
|
relative: {
|
|
dx: current.screenX - d.last.screenX,
|
|
dy: current.screenY - d.last.screenY
|
|
},
|
|
position: current
|
|
});
|
|
|
|
d.last = current;
|
|
},
|
|
|
|
touchend: function(d, e, t) {
|
|
var current = coordinates(e, t);
|
|
d.emitEvent('holdend', {
|
|
start: d.start,
|
|
end: current,
|
|
dx: current.screenX - d.start.screenX,
|
|
dy: current.screenY - d.start.screenY
|
|
});
|
|
d.switchTo(initialState);
|
|
}
|
|
};
|
|
|
|
// We enter this state if a second touch starts before we start
|
|
// recoginzing any other gesture. As the touches move we track the
|
|
// distance and angle between them to report scale and rotation values
|
|
// in transform events.
|
|
var transformState = {
|
|
name: 'transformState',
|
|
init: function(d, e, t) {
|
|
// Remember the id of the second touch
|
|
d.touch2 = t.identifier;
|
|
|
|
// Get the two Touch objects
|
|
var t1 = e.touches.identifiedTouch(d.touch1);
|
|
var t2 = e.touches.identifiedTouch(d.touch2);
|
|
|
|
// Compute and remember the initial distance and angle
|
|
d.startDistance = d.lastDistance = touchDistance(t1, t2);
|
|
d.startDirection = d.lastDirection = touchDirection(t1, t2);
|
|
|
|
// Don't start emitting events until we're past a threshold
|
|
d.scaled = d.rotated = false;
|
|
},
|
|
|
|
touchmove: function(d, e, t) {
|
|
// Ignore touches we're not tracking
|
|
if (t.identifier !== d.touch1 && t.identifier !== d.touch2) {
|
|
return;
|
|
}
|
|
|
|
// Get the two Touch objects
|
|
var t1 = e.touches.identifiedTouch(d.touch1);
|
|
var t2 = e.touches.identifiedTouch(d.touch2);
|
|
|
|
// Compute the new midpoints, distance and direction
|
|
var midpoint = midpoints(e, t1, t2);
|
|
var distance = touchDistance(t1, t2);
|
|
var direction = touchDirection(t1, t2);
|
|
var rotation = touchRotation(d.startDirection, direction);
|
|
|
|
// Check all of these numbers against the thresholds. Otherwise
|
|
// the transforms are too jittery even when you try to hold your
|
|
// fingers still.
|
|
if (!d.scaled) {
|
|
if (abs(distance - d.startDistance) > GD.SCALE_THRESHOLD) {
|
|
d.scaled = true;
|
|
d.startDistance = d.lastDistance =
|
|
floor(d.startDistance +
|
|
GD.THRESHOLD_SMOOTHING * (distance - d.startDistance));
|
|
} else {
|
|
distance = d.startDistance;
|
|
}
|
|
}
|
|
if (!d.rotated) {
|
|
if (abs(rotation) > GD.ROTATE_THRESHOLD) {
|
|
d.rotated = true;
|
|
} else {
|
|
direction = d.startDirection;
|
|
}
|
|
}
|
|
|
|
// If nothing has exceeded the threshold yet, then we
|
|
// don't even have to fire an event.
|
|
if (d.scaled || d.rotated) {
|
|
// The detail field for the transform gesture event includes
|
|
// 'absolute' transformations against the initial values and
|
|
// 'relative' transformations against the values from the last
|
|
// transformgesture event.
|
|
d.emitEvent('transform', {
|
|
absolute: { // transform details since gesture start
|
|
scale: distance / d.startDistance,
|
|
rotate: touchRotation(d.startDirection, direction)
|
|
},
|
|
relative: { // transform since last gesture change
|
|
scale: distance / d.lastDistance,
|
|
rotate: touchRotation(d.lastDirection, direction)
|
|
},
|
|
midpoint: midpoint
|
|
});
|
|
|
|
d.lastDistance = distance;
|
|
d.lastDirection = direction;
|
|
d.lastMidpoint = midpoint;
|
|
}
|
|
},
|
|
|
|
touchend: function(d, e, t) {
|
|
// If either finger goes up, we're done with the gesture.
|
|
// The user might move that finger and put it right back down
|
|
// again to begin another 2-finger gesture, so we can't go
|
|
// back to the initial state while one of the fingers remains up.
|
|
// On the other hand, we can't go back to touchStartedState because
|
|
// that would mean that the finger left down could cause a tap or
|
|
// pan event. So we need an afterTransform state that waits for
|
|
// a finger to come back down or the other finger to go up.
|
|
if (t.identifier === d.touch2) {
|
|
d.touch2 = null;
|
|
} else if (t.identifier === d.touch1) {
|
|
d.touch1 = d.touch2;
|
|
d.touch2 = null;
|
|
} else {
|
|
return; // It was a touch we weren't tracking
|
|
}
|
|
|
|
// If we emitted any transform events, now we need to emit
|
|
// a transformend event to end the series. The details of this
|
|
// event use the values from the last touchmove, and the
|
|
// relative amounts will 1 and 0, but they are included for
|
|
// completeness even though they are not useful.
|
|
if (d.scaled || d.rotated) {
|
|
d.emitEvent('transformend', {
|
|
absolute: { // transform details since gesture start
|
|
scale: d.lastDistance / d.startDistance,
|
|
rotate: touchRotation(d.startDirection, d.lastDirection)
|
|
},
|
|
relative: { // nothing has changed relative to the last touchmove
|
|
scale: 1,
|
|
rotate: 0
|
|
},
|
|
midpoint: d.lastMidpoint
|
|
});
|
|
}
|
|
|
|
d.switchTo(afterTransformState);
|
|
}
|
|
};
|
|
|
|
// We did a tranform and one finger went up. Wait for that finger to
|
|
// come back down or the other finger to go up too.
|
|
var afterTransformState = {
|
|
name: 'afterTransformState',
|
|
touchstart: function(d, e, t) {
|
|
d.switchTo(transformState, e, t);
|
|
},
|
|
|
|
touchend: function(d, e, t) {
|
|
if (t.identifier === d.touch1) {
|
|
d.switchTo(initialState);
|
|
}
|
|
}
|
|
};
|
|
|
|
return GD;
|
|
}());
|
|
|
|
define("shared/js/gesture_detector", (function (global) {
|
|
return function () {
|
|
var ret, fn;
|
|
return ret || global.GestureDetector;
|
|
};
|
|
}(this)));
|
|
|
|
|
|
define('text!picker/spinner.html',[],function () { return '<div class="picker-unit" data-value="${value}">${text}</div>\n';});
|
|
|
|
define('picker/spinner',['require','template','shared/js/gesture_detector','text!picker/spinner.html'],function(require) {
|
|
'use strict';
|
|
/* global IntlHelper */
|
|
|
|
var Template = require('template');
|
|
var GestureDetector = require('shared/js/gesture_detector');
|
|
var spinnerHtml = require('text!picker/spinner.html');
|
|
|
|
// units covered per millisecond threshold to kick off inertia
|
|
var SPEED_THRESHOLD = 0.01;
|
|
|
|
// max units covered by inertia
|
|
var INERTIA_MAXIMUM = 15;
|
|
|
|
// not the same as animation duration, this accounts for "slowing down",
|
|
// measured in miliseconds
|
|
var INERTIA_DURATION = 300;
|
|
|
|
// number of milliseconds after last motion without leting go
|
|
// we will select whatever is being "hovered" and cancel momentum
|
|
var DRAGGING_TIMEOUT = 200;
|
|
|
|
IntlHelper.define('digit-padding', 'number', {
|
|
style: 'decimal',
|
|
useGrouping: false,
|
|
minimumIntegerDigits: 2
|
|
});
|
|
|
|
function calculateSpeed(previous, current) {
|
|
/* jshint validthis:true */
|
|
var motion = (previous.y - current.y) / this.unitHeight;
|
|
var delta = current.time - previous.time;
|
|
var speed = motion / delta;
|
|
|
|
return parseFloat(speed.toFixed(4)) || 0;
|
|
}
|
|
|
|
function Touch(touch = {}) {
|
|
this.x = touch.x || 0;
|
|
this.y = touch.y || 0;
|
|
this.time = touch.time || 0;
|
|
}
|
|
|
|
/**
|
|
* Spinner
|
|
*
|
|
* Create a select list spinner.
|
|
*
|
|
* @param {Object} setup An object containing setup data.
|
|
* - element, a DOM element to create
|
|
* the spinner with.
|
|
* - values, an array of values to
|
|
* populate the spinner with.
|
|
*
|
|
*
|
|
* new Spinner({
|
|
* element: this.selector[picker],
|
|
* values: values
|
|
* });
|
|
*
|
|
*
|
|
*/
|
|
function Spinner(setup = {}) {
|
|
// define some non writable properties
|
|
Object.defineProperties(this, {
|
|
value: {
|
|
get: function() {
|
|
return this.values[this.index];
|
|
},
|
|
set: function(value) {
|
|
this.select(this.values.indexOf(parseInt(value)));
|
|
}
|
|
},
|
|
unitHeight: {
|
|
get: function() {
|
|
return this.element.children[0].clientHeight;
|
|
}
|
|
},
|
|
space: {
|
|
get: function() {
|
|
return this.unitHeight * this.length;
|
|
}
|
|
},
|
|
container: {
|
|
value: setup.element.parentNode
|
|
},
|
|
element: {
|
|
value: setup.element
|
|
},
|
|
values: {
|
|
value: setup.values
|
|
},
|
|
length: {
|
|
value: setup.values.length
|
|
},
|
|
textValues: {
|
|
value: setup.textValues || setup.values
|
|
},
|
|
isPadded: {
|
|
value: setup.isPadded || false
|
|
},
|
|
l10nId: {
|
|
value: setup.l10nId || null
|
|
}
|
|
});
|
|
|
|
this.template = new Template(spinnerHtml);
|
|
|
|
this.top = 0;
|
|
this.index = 0;
|
|
|
|
this.previous = new Touch();
|
|
this.current = new Touch();
|
|
|
|
this.timeout = null;
|
|
|
|
var html = '';
|
|
|
|
var formatter = IntlHelper.get(
|
|
this.isPadded ? 'digit-padding' : 'digit-nopadding');
|
|
|
|
for (var i = 0; i < this.length; i++) {
|
|
html += this.template.interpolate({
|
|
value: this.values[i].toString(),
|
|
text: formatter.format(this.values[i])
|
|
});
|
|
}
|
|
|
|
this.element.innerHTML = html;
|
|
|
|
this.container.addEventListener('touchstart', this, false);
|
|
this.container.addEventListener('pan', this, false);
|
|
this.container.addEventListener('swipe', this, false);
|
|
this.container.addEventListener('keypress', this, false);
|
|
this.container.setAttribute('aria-valuemax', this.upper);
|
|
this.container.setAttribute('aria-valuemin', this.lower);
|
|
this.container.setAttribute('aria-valuenow', this.index);
|
|
if (this.l10nId) {
|
|
document.l10n.setAttributes(
|
|
this.container,
|
|
this.l10nId,
|
|
{n: this.values[i]}
|
|
);
|
|
}
|
|
|
|
IntlHelper.observe(['digit-padding', 'digit-nopadding'],
|
|
this.resetValues.bind(this));
|
|
|
|
this.reset();
|
|
|
|
new GestureDetector(this.container).startDetecting();
|
|
}
|
|
|
|
Spinner.prototype.resetValues = function() {
|
|
var formatter = IntlHelper.get(
|
|
this.isPadded ? 'digit-padding' : 'digit-nopadding');
|
|
|
|
var units = [...this.container.querySelectorAll('.picker-unit')];
|
|
|
|
units.forEach(unit => {
|
|
var value = parseInt(unit.dataset.value);
|
|
unit.textContent = formatter.format(value);
|
|
});
|
|
};
|
|
|
|
Spinner.prototype.reset = function() {
|
|
this.index = 0;
|
|
this.top = 0;
|
|
this.update();
|
|
};
|
|
|
|
Spinner.prototype.update = function() {
|
|
this.container.setAttribute('aria-valuenow', this.index);
|
|
this.container.setAttribute('aria-valuetext', this.textValues[this.index]);
|
|
this.element.style.transform = 'translateY(' + this.top + 'px)';
|
|
};
|
|
|
|
Spinner.prototype.select = function(index) {
|
|
index = Math.round(index);
|
|
|
|
if (index < 0) {
|
|
index = 0;
|
|
}
|
|
|
|
if (index > this.length - 1) {
|
|
index = this.length - 1;
|
|
}
|
|
|
|
if (index !== this.index) {
|
|
this.index = index;
|
|
}
|
|
|
|
this.top = -this.index * this.unitHeight;
|
|
this.update();
|
|
|
|
return index;
|
|
};
|
|
|
|
Spinner.prototype.handleEvent = function(event) {
|
|
this['on' + event.type](event);
|
|
};
|
|
|
|
Spinner.prototype.stopInteraction = function() {
|
|
this.element.classList.add('animation-on');
|
|
|
|
clearTimeout(this.timeout);
|
|
|
|
this.select(this.index);
|
|
this.speed = 0;
|
|
};
|
|
|
|
/**
|
|
* ontouchstart - prevent default action (stops scrolling)
|
|
*/
|
|
Spinner.prototype.ontouchstart = function(event) {
|
|
event.preventDefault();
|
|
};
|
|
|
|
Spinner.prototype.onpan = function(event) {
|
|
event.stopPropagation();
|
|
var position = event.detail.position;
|
|
var diff;
|
|
|
|
// If this is the first pan event after a swipe...
|
|
if (this.element.classList.contains('animation-on')) {
|
|
this.element.classList.remove('animation-on');
|
|
|
|
this.select(this.index);
|
|
|
|
this.previous.y = position.clientY;
|
|
this.previous.time = position.timeStamp;
|
|
return;
|
|
}
|
|
|
|
this.current.y = position.clientY;
|
|
this.current.time = position.timeStamp;
|
|
this.speed = calculateSpeed.call(this, this.previous, this.current);
|
|
|
|
diff = this.current.y - this.previous.y;
|
|
|
|
this.top = this.top + diff;
|
|
|
|
if (this.top > 0) {
|
|
this.top = 0;
|
|
}
|
|
if (this.top < -this.space) {
|
|
this.top = -this.space;
|
|
}
|
|
|
|
this.index = Math.round(Math.abs(this.top) / this.unitHeight);
|
|
this.update();
|
|
|
|
clearTimeout(this.timeout);
|
|
|
|
var stopInteraction = this.stopInteraction.bind(this);
|
|
this.timeout = setTimeout(stopInteraction, DRAGGING_TIMEOUT);
|
|
|
|
this.previous.y = this.current.y;
|
|
this.previous.time = this.current.time;
|
|
};
|
|
|
|
Spinner.prototype.onswipe = function(event) {
|
|
|
|
event.stopPropagation();
|
|
|
|
// Add momentum if speed is higher than a given threshold.
|
|
var direction = this.speed > 0 ? 1 : -1;
|
|
var speed = this.speed / direction;
|
|
if (speed >= SPEED_THRESHOLD) {
|
|
this.index += Math.round(
|
|
Math.min(speed * INERTIA_DURATION, INERTIA_MAXIMUM) * direction
|
|
);
|
|
}
|
|
|
|
this.stopInteraction();
|
|
|
|
};
|
|
|
|
Spinner.prototype.onkeypress = function(event) {
|
|
this.element.classList.add('animation-on');
|
|
if (event.keyCode == KeyEvent.DOM_VK_DOWN) {
|
|
this.select(this.index - 1);
|
|
} else {
|
|
this.select(this.index + 1);
|
|
}
|
|
};
|
|
|
|
return Spinner;
|
|
});
|
|
|
|
define('picker/picker',['require','picker/spinner'],function(require) {
|
|
'use strict';
|
|
var Spinner = require('picker/spinner');
|
|
|
|
/**
|
|
* Picker
|
|
*
|
|
* Create an inline "picker", comprised of n "spinners".
|
|
*
|
|
* @param {Object} setup An object containing setup data.
|
|
* - element, The container element.
|
|
* - pickers, an object whose properties
|
|
* correspond to a Picker that will
|
|
* be created.
|
|
*
|
|
*
|
|
* new Picker({
|
|
* element: 'time-picker',
|
|
* pickers: {
|
|
* hours: {
|
|
* range: [0, 24],
|
|
* l10nId: 'nSpinnerHours'
|
|
* },
|
|
* minutes: {
|
|
* range: [0, 60],
|
|
* isPadded: true,
|
|
* l10nId: 'nSpinnerMinutes'
|
|
* }
|
|
* }
|
|
* });
|
|
*
|
|
*/
|
|
function Picker(setup) {
|
|
this.nodes = {};
|
|
this.spinners = {};
|
|
this.pickers = Object.keys(setup.pickers);
|
|
|
|
this.pickers.forEach(function(picker) {
|
|
var values = [];
|
|
var range = setup.pickers[picker].range;
|
|
var isPadded = setup.pickers[picker].isPadded || false;
|
|
|
|
this.nodes[picker] = setup.element.querySelector('.picker-' + picker);
|
|
|
|
for (var i = range[0]; i <= range[1]; i++) {
|
|
values.push(i);
|
|
}
|
|
|
|
this.spinners[picker] = new Spinner({
|
|
element: this.nodes[picker],
|
|
values: values,
|
|
isPadded: isPadded,
|
|
l10nId: setup.pickers[picker].l10nId
|
|
});
|
|
}, this);
|
|
}
|
|
|
|
Picker.prototype = {
|
|
get value() {
|
|
// Protect against uninitialized [[Get]] access
|
|
if (typeof this.pickers === 'undefined') {
|
|
return null;
|
|
}
|
|
|
|
return this.pickers.map(function(picker) {
|
|
return this.spinners[picker].value;
|
|
}, this).join(':');
|
|
},
|
|
|
|
set value(value) {
|
|
// Protect against uninitialized [[Set]] access
|
|
if (typeof this.pickers === 'undefined') {
|
|
return null;
|
|
}
|
|
|
|
value.split(':').forEach(function(value, i) {
|
|
this.spinners[this.pickers[i]].value = value;
|
|
}, this);
|
|
|
|
return this.value;
|
|
},
|
|
|
|
reset: function() {
|
|
this.pickers.forEach(function(picker) {
|
|
this.spinners[picker].reset();
|
|
}, this);
|
|
}
|
|
};
|
|
|
|
return Picker;
|
|
});
|
|
|
|
// outer IIFE
|
|
define('form_button',['require','utils'],function(require) {
|
|
'use strict';
|
|
|
|
var Utils = require('utils');
|
|
|
|
function createButton(formButton) {
|
|
var button = document.createElement(formButton.tagName);
|
|
button.className = formButton.className;
|
|
if (formButton.id) {
|
|
button.id = formButton.id;
|
|
}
|
|
var input = formButton.input;
|
|
input.parentNode.insertBefore(button, input.nextSibling);
|
|
formButton.button = button;
|
|
}
|
|
|
|
/**
|
|
* A FormButton is a button that triggers an input. The text
|
|
* of the currently selected value will display on the buttons's face.
|
|
*
|
|
* The `config` paramater supports the following optional properties.
|
|
* `formatLabel` - A function that is given the current value of the input
|
|
* and should return a string which will be used as the textContent of
|
|
* the button.
|
|
*
|
|
* `tagName` - The name of the tag to create and insert into the
|
|
* document as the main button used to trigger the input. The default
|
|
* value is 'button'
|
|
*
|
|
* `className` The value of the className property that will be assigned to
|
|
* the button element the default value is 'icon icon-dialog'.
|
|
*
|
|
* `id` - A string that is used as the id of the button element.
|
|
*
|
|
* @constructor
|
|
* @param {HTMLElement} input The input element to trigger.
|
|
* @param {Object} config An optional config object.
|
|
*
|
|
*/
|
|
function FormButton(input, config) {
|
|
config = config || {};
|
|
Utils.extend(this, config);
|
|
|
|
this.input = input;
|
|
createButton(this);
|
|
|
|
this.input.classList.add('form-button-input');
|
|
// hide input
|
|
this.input.classList.add('form-button-hide');
|
|
|
|
// set isSelect
|
|
Object.defineProperty(this, 'isSelect', {
|
|
value: this.input.nodeName === 'SELECT'
|
|
});
|
|
|
|
this.button.addEventListener('click', this.focus.bind(this), false);
|
|
|
|
input.addEventListener('change', this.refresh.bind(this), false);
|
|
input.addEventListener('blur', this.refresh.bind(this), false);
|
|
|
|
// Bind this.refresh so that the listener can be easily removed.
|
|
this.refresh = this.refresh.bind(this);
|
|
// Update the dropdown when the language changes.
|
|
window.addEventListener('localized', this.refresh);
|
|
window.addEventListener('timeformatchange', this.refresh);
|
|
}
|
|
|
|
FormButton.prototype = {
|
|
|
|
/** Remove all event handlers. */
|
|
destroy: function() {
|
|
window.removeEventListener('localized', this.refresh);
|
|
},
|
|
|
|
/**
|
|
* focus Triggers a focus event on the input associated with this
|
|
* FormButton.
|
|
*
|
|
* @param {Object} event an event object.
|
|
*/
|
|
focus: function(event) {
|
|
event.preventDefault();
|
|
setTimeout(this.input.focus.bind(this.input), 10);
|
|
},
|
|
|
|
/**
|
|
* refresh Updates the label text on the button to reflect
|
|
* the current value of the input.
|
|
*
|
|
*/
|
|
refresh: function() {
|
|
var value = this.value;
|
|
|
|
var label = this.formatLabel(value);
|
|
|
|
// In some cases formatLabel returns a Promise that returns l10nId
|
|
var labelPromise = label.then ? label : Promise.resolve(label);
|
|
|
|
return labelPromise.then((l10nId) => {
|
|
if (typeof l10nId === 'string') {
|
|
this.button.setAttribute('data-l10n-id', l10nId);
|
|
} else if (l10nId.raw) {
|
|
this.button.removeAttribute('data-l10n-id');
|
|
this.button.textContent = l10nId.raw;
|
|
} else if (l10nId.id) {
|
|
document.l10n.setAttributes(this.button, l10nId.id, l10nId.args);
|
|
}
|
|
});
|
|
},
|
|
|
|
/**
|
|
* value Returns the current value of the input.
|
|
*
|
|
* @return {String|Object} The value of the input.
|
|
*
|
|
*/
|
|
get value() {
|
|
if (this.isSelect) {
|
|
if (this.input.multiple) {
|
|
var selectedOptions = {};
|
|
var options = this.input.options;
|
|
for (var i = 0; i < options.length; i++) {
|
|
if (options[i].selected) {
|
|
selectedOptions[options[i].value] = true;
|
|
}
|
|
}
|
|
return selectedOptions;
|
|
}
|
|
if (this.input.selectedIndex !== -1) {
|
|
return Utils.getSelectedValueByIndex(this.input);
|
|
}
|
|
return null;
|
|
}
|
|
// input node
|
|
return this.input.value;
|
|
},
|
|
|
|
/**
|
|
* value sets the current value of the input and update's the
|
|
* button text.
|
|
*
|
|
* @param {String|Object} value A string of the current values or an
|
|
* object with properties that map to input options if the input is
|
|
* a multi select.
|
|
*
|
|
*/
|
|
set value(value) {
|
|
if (this.isSelect) {
|
|
if (this.input.multiple) {
|
|
// multi select
|
|
var options = this.input.options;
|
|
for (var i = 0; i < options.length; i++) {
|
|
options[i].selected = value[options[i].value] === true;
|
|
}
|
|
} else {
|
|
// normal select element
|
|
Utils.changeSelectByValue(this.input, value);
|
|
}
|
|
} else {
|
|
// input element
|
|
this.input.value = value;
|
|
}
|
|
// Update the text on the button to reflect the new input value
|
|
this.refresh();
|
|
},
|
|
|
|
/**
|
|
* An overrideable method that is called when updating the textContent
|
|
* of the button.
|
|
*
|
|
* @return {L10nId} The formatted text to display in the label.
|
|
*
|
|
*/
|
|
formatLabel: function(value) {
|
|
return { raw: value };
|
|
},
|
|
|
|
/**
|
|
* tagName The the name of the tag to insert into the document to use
|
|
* as the button element.
|
|
*/
|
|
tagName: 'button',
|
|
|
|
/**
|
|
* class The value to assign to the className property on the
|
|
* generated button element.
|
|
*/
|
|
className: 'icon icon-dialog'
|
|
|
|
};
|
|
|
|
return FormButton;
|
|
|
|
// end outer IIFE
|
|
});
|
|
|
|
|
|
define('text!panels/timer/panel.html',[],function () { return '<div id="timer-dialog" class="panel skin-dark">\n\n <div id="value-selector">\n <!-- Time Picker -->\n\n <div id="time-picker" data-type="picker">\n <div class="picker-container">\n <div class="picker-labels">\n <div class="picker-label" id="hours" data-l10n-id="hours">\n </div>\n <div class="picker-label" id="minutes" data-l10n-id="minutes">\n </div>\n </div>\n <div class="picker-bar-background">\n </div>\n <div class="picker-hours-wrapper" role="spinbutton" aria-labelledby="hours">\n <div class="picker-hours">\n </div>\n </div>\n <div class="picker-minutes-wrapper" role="spinbutton" aria-labelledby="minutes">\n <div class="picker-minutes">\n </div>\n </div>\n <div class="value-indicator">\n <div class="value-indicator-colon" aria-hidden="true" data-l10n-id="hourMinutesSeparator"></div>\n </div>\n </div>\n </div>\n\n\n <ul id="timer-edit" class="compact">\n <li>\n <button id="timer-create" class="recommend" data-l10n-id="start">\n </button>\n </li>\n <li>\n <label data-l10n-id="sound"></label>\n <select id="timer-sound">\n <option value="0" data-l10n-id="noSound"></option>\n <option value="ac_awake.opus" data-l10n-id="ac_awake_opus" selected></option>\n <option value="ac_crystalize.opus" data-l10n-id="ac_crystalize_opus"></option>\n <option value="ac_cycle.opus" data-l10n-id="ac_cycle_opus"></option>\n <option value="ac_digicloud.opus" data-l10n-id="ac_digicloud_opus"></option>\n <option value="ac_humming_waves.opus" data-l10n-id="ac_humming_waves_opus"></option>\n <option value="ac_into_the_void.opus" data-l10n-id="ac_into_the_void_opus"></option>\n <option value="ac_lightly.opus" data-l10n-id="ac_lightly_opus"></option>\n <option value="ac_mobile.opus" data-l10n-id="ac_mobile_opus"></option>\n <option value="ac_pinger.opus" data-l10n-id="ac_pinger_opus"></option>\n <option value="ac_skip.opus" data-l10n-id="ac_skip_opus"></option>\n <option value="ac_tri.opus" data-l10n-id="ac_tri_opus"></option>\n <option value="ac_universal.opus" data-l10n-id="ac_universal_opus"></option>\n </select>\n </li>\n <li>\n <gaia-switch id="timer-vibrate" checked>\n <label data-l10n-id="vibrate"></label>\n </gaia-switch>\n </li>\n </ul>\n </div>\n</div>\n<div id="timer-active">\n <div id="timer-time-display">\n <div id="timer-time"></div>\n <menu id="timer-controls">\n <button id="timer-start" class="edit-button" data-l10n-id="resume">\n </button>\n <button id="timer-pause" class="edit-button" data-l10n-id="pause">\n </button>\n <button id="timer-cancel" class="danger" data-l10n-id="cancel">\n </button>\n </menu>\n </div>\n <div id="timer-plus-time">\n <button id="timer-plus" data-value="60" data-l10n-id="plusMinute">\n </button>\n </div>\n</div>\n';});
|
|
|
|
/* global define */
|
|
define('audio_manager',['require'],function(require) {
|
|
'use strict';
|
|
|
|
/**
|
|
* The Settings App stores volumes in the range [0, 15] inclusive.
|
|
* Whenever we need to play sounds, though, the Audio object
|
|
* requires a float between [0.0, 1.0]. The conversion has to happen
|
|
* somewhere. The AudioManager here draws the line right out of what
|
|
* gets read from mozSettings.
|
|
*
|
|
* In other words, the conversion is not important to clients of
|
|
* this class, who should treat the volume as a float with no
|
|
* conversion. The only weirdness here is that unit tests must be
|
|
* aware of the slight rounding differences when converting from a
|
|
* float to the system level.
|
|
*/
|
|
|
|
////////////////////////////////////////////////////////////////
|
|
// VolumeManager
|
|
|
|
function isValidVolume(volume) {
|
|
return (typeof volume === 'number' &&
|
|
volume <= 1.0 &&
|
|
volume >= 0.0);
|
|
}
|
|
|
|
var VOLUME_SETTING = 'audio.volume.alarm';
|
|
var SYSTEM_VOLUME_MAX = 15;
|
|
function systemVolumeToFloat(volume) {
|
|
return (volume / SYSTEM_VOLUME_MAX);
|
|
}
|
|
|
|
function floatToSystemVolume(volume) {
|
|
return Math.round(volume * SYSTEM_VOLUME_MAX);
|
|
}
|
|
|
|
function requestAlarmSystemVolume() {
|
|
// Asynchronously load the alarm volume from mozSettings.
|
|
return new Promise(function(resolve, reject) {
|
|
var lock = navigator.mozSettings.createLock();
|
|
var req = lock.get(VOLUME_SETTING);
|
|
req.onsuccess = function() {
|
|
var volume = systemVolumeToFloat(req.result[VOLUME_SETTING]);
|
|
if (isValidVolume(volume)) {
|
|
globalVolumeManager._volume = volume;
|
|
resolve(volume);
|
|
}
|
|
};
|
|
|
|
req.onerror = function() {
|
|
var DEFAULT_VOLUME = 1.0;
|
|
resolve(DEFAULT_VOLUME);
|
|
};
|
|
});
|
|
}
|
|
|
|
function VolumeManager() {
|
|
this.VOLUME_KEY = 'defaultAlarmVolume';
|
|
this.DEFAULT_VOLUME = 1.0;
|
|
this._volume = this.DEFAULT_VOLUME;
|
|
|
|
if (navigator.mozSettings) {
|
|
navigator.mozSettings.addObserver(
|
|
VOLUME_SETTING,
|
|
this.onSystemAlarmVolumeChange.bind(this));
|
|
}
|
|
}
|
|
|
|
VolumeManager.prototype = {
|
|
onSystemAlarmVolumeChange: function(e) {
|
|
// don't use the setter here
|
|
this._volume = systemVolumeToFloat(e.settingValue);
|
|
},
|
|
|
|
get volume() {
|
|
return this._volume;
|
|
},
|
|
|
|
set volume(volume) {
|
|
this.setVolume(volume);
|
|
},
|
|
|
|
/** Set the volume with an optional completion callback. */
|
|
setVolume: function(volume, cb) {
|
|
if (isValidVolume(volume)) {
|
|
this._volume = volume;
|
|
|
|
if (navigator.mozSettings) {
|
|
var lock = navigator.mozSettings.createLock();
|
|
|
|
var opts = {};
|
|
opts[VOLUME_SETTING] = floatToSystemVolume(volume);
|
|
var req = lock.set(opts);
|
|
|
|
if (cb) {
|
|
req.onsuccess = cb;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
};
|
|
|
|
////////////////////////////////////////////////////////////////
|
|
// AudioPlayer
|
|
|
|
var globalVolumeManager = new VolumeManager();
|
|
|
|
/**
|
|
* The AudioPlayer class manages the playback of alarm ringtones. It
|
|
* is lazy-loading, so that you can instantiate it immediately;
|
|
* Audio objects are not actually created or loaded until you need
|
|
* to play a sound.
|
|
*/
|
|
function AudioPlayer() {
|
|
this._audio = null;
|
|
}
|
|
|
|
AudioPlayer.prototype = {
|
|
|
|
/**
|
|
* Play a ringtone from the shared/resources/media/alarms
|
|
* directory, using the current global volume settings by default.
|
|
* You can override the volume through opts.volume.
|
|
*
|
|
* @param {string} ringtoneName
|
|
* @param {number} opts.volume Value between 0 and 1
|
|
*/
|
|
playRingtone: function(ringtoneName, opts) {
|
|
var volume = globalVolumeManager.volume;
|
|
if (opts && 'volume' in opts) {
|
|
volume = opts.volume;
|
|
}
|
|
this._prepare(); // Load up the audio element.
|
|
this._audio.pause();
|
|
this._audio.src = 'shared/resources/media/alarms/' + ringtoneName;
|
|
this._audio.load(); // Required per MDN's HTMLMediaElement spec.
|
|
this._audio.volume = volume;
|
|
this._audio.play();
|
|
},
|
|
|
|
/**
|
|
* Pause the currently-playing audio, if possible.
|
|
*/
|
|
pause: function() {
|
|
if (this._audio) {
|
|
this._audio.pause();
|
|
}
|
|
},
|
|
|
|
// Private methods:
|
|
|
|
/**
|
|
* Instantiate the Audio element and prepare it for playback.
|
|
* For internal use only.
|
|
* @private
|
|
*/
|
|
_prepare: function() {
|
|
if (!this._audio) {
|
|
this._audio = new Audio();
|
|
this._audio.mozAudioChannelType = 'alarm';
|
|
this._audio.loop = true;
|
|
}
|
|
}
|
|
};
|
|
|
|
return {
|
|
getAlarmVolume: function() {
|
|
return globalVolumeManager.volume;
|
|
},
|
|
requestAlarmVolume: function() {
|
|
return requestAlarmSystemVolume();
|
|
},
|
|
setAlarmVolume: function(volume, cb) {
|
|
globalVolumeManager.setVolume(volume, cb);
|
|
},
|
|
createAudioPlayer: function() {
|
|
return new AudioPlayer();
|
|
},
|
|
// Exposed for tests:
|
|
systemVolumeToFloat: systemVolumeToFloat,
|
|
floatToSystemVolume: floatToSystemVolume
|
|
};
|
|
});
|
|
|
|
define('panels/timer/main',['require','panel','picker/picker','view','utils','timer','sounds','form_button','text!panels/timer/panel.html','audio_manager','shared/js/async_storage'],function(require) {
|
|
'use strict';
|
|
/* global IntlHelper */
|
|
|
|
var Panel = require('panel');
|
|
var Picker = require('picker/picker');
|
|
var View = require('view');
|
|
|
|
var Utils = require('utils');
|
|
var Timer = require('timer');
|
|
var Sounds = require('sounds');
|
|
var FormButton = require('form_button');
|
|
var html = require('text!panels/timer/panel.html');
|
|
var AudioManager = require('audio_manager');
|
|
var asyncStorage = require('shared/js/async_storage');
|
|
|
|
var priv = new WeakMap();
|
|
|
|
function timeFromPicker(value) {
|
|
var hm, ms;
|
|
hm = value.split(':');
|
|
ms = Utils.dateMath.toMS({
|
|
hours: hm[0],
|
|
minutes: hm[1]
|
|
});
|
|
return ms;
|
|
}
|
|
|
|
IntlHelper.define('timer-hms', 'mozduration', {
|
|
minUnit: 'second',
|
|
maxUnit: 'hour'
|
|
});
|
|
|
|
/**
|
|
* Timer.Panel
|
|
*
|
|
* Construct a UI panel for the Timer panel.
|
|
*
|
|
* @return {Timer.Panel} Timer.Panel object.
|
|
*
|
|
*/
|
|
Timer.Panel = function(element) {
|
|
Panel.apply(this, arguments);
|
|
|
|
element.innerHTML = html;
|
|
this.timer = null;
|
|
this.nodes = {};
|
|
|
|
this.picker = new Picker({
|
|
element: this.element.querySelector('#time-picker'),
|
|
pickers: {
|
|
hours: {
|
|
range: [0, 23],
|
|
l10nId: 'nSpinnerHours'
|
|
},
|
|
minutes: {
|
|
range: [0, 59],
|
|
isPadded: true,
|
|
l10nId: 'nSpinnerMinutes'
|
|
}
|
|
}
|
|
});
|
|
|
|
this.ringtonePlayer = AudioManager.createAudioPlayer();
|
|
|
|
// Gather elements
|
|
[
|
|
'create', 'cancel', 'dialog', 'pause', 'start', 'sound', 'time', 'vibrate',
|
|
'plus'
|
|
].forEach(function(id) {
|
|
this.nodes[id] = this.element.querySelector('#timer-' + id);
|
|
}, this);
|
|
|
|
// Bind click events
|
|
[
|
|
'create', 'cancel', 'pause', 'start', 'plus'
|
|
].forEach(function(action) {
|
|
var element = this.nodes[action];
|
|
|
|
if (priv.has(element)) {
|
|
priv.delete(element);
|
|
}
|
|
|
|
priv.set(element, {
|
|
action: action,
|
|
panel: this
|
|
});
|
|
|
|
element.addEventListener('click', this.onclick.bind(this), false);
|
|
}, this);
|
|
|
|
var sound = this.nodes.sound;
|
|
|
|
sound.addEventListener('blur', this.pauseAlarm.bind(this), false);
|
|
sound.addEventListener('change', this.previewAlarm.bind(this), false);
|
|
|
|
var soundMenuConfig = {
|
|
id: 'timer-sound-menu',
|
|
formatLabel: Sounds.formatLabel
|
|
};
|
|
this.soundButton = new FormButton(sound, soundMenuConfig);
|
|
this.soundButton.refresh();
|
|
|
|
element.addEventListener('panel-visibilitychange',
|
|
this.onvisibilitychange.bind(this));
|
|
|
|
// The start button is disable by default (picker at 00:00 by default)
|
|
this.nodes.create.setAttribute('disabled', 'true');
|
|
|
|
var create = this.nodes.create;
|
|
var picker = this.picker;
|
|
|
|
var enableButton = () => {
|
|
if(timeFromPicker(picker.value) === 0) {
|
|
create.setAttribute('disabled', 'true');
|
|
} else {
|
|
create.removeAttribute('disabled');
|
|
}
|
|
this.saveCurrentSettingsAsDefaults();
|
|
};
|
|
|
|
// The start button is enable if the value of the timer is not 00:00
|
|
picker.nodes.minutes.addEventListener('transitionend', enableButton);
|
|
picker.nodes.hours.addEventListener('transitionend', enableButton);
|
|
|
|
this.restoreDefaults().then(() => {
|
|
enableButton(); // Update the enabled state to match the new defaults.
|
|
});
|
|
|
|
this.nodes.vibrate.addEventListener(
|
|
'change', (e) => this.saveCurrentSettingsAsDefaults());
|
|
this.nodes.sound.addEventListener(
|
|
'change', (e) => this.saveCurrentSettingsAsDefaults());
|
|
|
|
Timer.singleton(function(err, timer) {
|
|
this.timer = timer;
|
|
var onTimerEvent = this.onTimerEvent.bind(this);
|
|
window.addEventListener('timer-start', onTimerEvent);
|
|
window.addEventListener('timer-pause', onTimerEvent);
|
|
window.addEventListener('timer-tick', onTimerEvent);
|
|
window.addEventListener('timer-end', onTimerEvent);
|
|
IntlHelper.observe('timer-hms', this.update.bind(this));
|
|
if (this.visible) {
|
|
// If the timer panel already became visible before we fetched
|
|
// the timer, we must update the display to show the proper
|
|
// timer status.
|
|
this.onvisibilitychange({ detail: { isVisible: true } });
|
|
}
|
|
}.bind(this));
|
|
};
|
|
|
|
Timer.Panel.prototype = Object.create(Panel.prototype);
|
|
|
|
Timer.Panel.prototype.restoreDefaults = function() {
|
|
return new Promise((resolve) => {
|
|
asyncStorage.getItem('timer-defaults', (values) => {
|
|
values = values || {};
|
|
if (values.sound != null) {
|
|
this.soundButton.value = values.sound;
|
|
}
|
|
if (values.time != null) {
|
|
this.picker.value = values.time;
|
|
}
|
|
if (values.vibrate != null) {
|
|
this.nodes.vibrate.checked = values.vibrate;
|
|
}
|
|
resolve();
|
|
});
|
|
});
|
|
};
|
|
|
|
Timer.Panel.prototype.saveCurrentSettingsAsDefaults = function() {
|
|
asyncStorage.setItem('timer-defaults', {
|
|
sound: this.soundButton.value,
|
|
time: this.picker.value,
|
|
vibrate: this.nodes.vibrate.checked
|
|
});
|
|
};
|
|
|
|
Timer.Panel.prototype.onvisibilitychange = function(evt) {
|
|
var isVisible = evt.detail.isVisible;
|
|
var timer = this.timer;
|
|
|
|
if (isVisible) {
|
|
// No active timer, or timer has expired...
|
|
// - Show the new timer dialog
|
|
if (timer === null || timer.state === Timer.INITIAL) {
|
|
this.showDialog();
|
|
} else {
|
|
// Active timer exists, might be started by an external app, so we need
|
|
// to make sure dialog is hidden and buttons/time are on correct state
|
|
this.toggleButtons();
|
|
this.hideDialog();
|
|
this.update();
|
|
}
|
|
} else {
|
|
this.pauseAlarm();
|
|
}
|
|
};
|
|
|
|
Timer.Panel.prototype._toggleDialog = function(isVisible = true) {
|
|
View.instance(this.nodes.dialog).visible = isVisible;
|
|
return this;
|
|
};
|
|
|
|
Timer.Panel.prototype.showDialog = function() {
|
|
return this._toggleDialog(true);
|
|
};
|
|
|
|
Timer.Panel.prototype.hideDialog = function() {
|
|
return this._toggleDialog(false);
|
|
};
|
|
|
|
Timer.Panel.prototype.onTimerEvent = function(event) {
|
|
switch (event.type) {
|
|
case 'timer-start':
|
|
// new timer dialog should always be hidden during timer-start (even
|
|
// if started from external app)
|
|
this.hideDialog();
|
|
/* falls through */
|
|
case 'timer-pause':
|
|
this.toggleButtons();
|
|
/* falls through */
|
|
case 'timer-tick':
|
|
this.update();
|
|
break;
|
|
case 'timer-end':
|
|
this.showDialog();
|
|
break;
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Given milliseconds, render the time as a rounded-to-seconds
|
|
* countdown.
|
|
*/
|
|
Timer.Panel.prototype.update = function() {
|
|
return IntlHelper.get('timer-hms').then(formatter => {
|
|
var remaining = this.timer.remaining;
|
|
var newText = formatter.format(remaining);
|
|
// Use localized caching here to prevent unnecessary DOM repaints.
|
|
if (this._cachedTimerText !== newText) {
|
|
this.nodes.time.textContent = this._cachedTimerText = newText;
|
|
}
|
|
});
|
|
};
|
|
|
|
Timer.Panel.prototype.toggleButtons = function() {
|
|
var nodes = this.nodes;
|
|
var started = this.timer.state === Timer.STARTED;
|
|
|
|
nodes.pause.classList.toggle('hidden', !started);
|
|
nodes.start.classList.toggle('hidden', started);
|
|
};
|
|
|
|
/**
|
|
* previewAlarm Plays the currently selected alarm value on a loop.
|
|
*/
|
|
Timer.Panel.prototype.previewAlarm = function() {
|
|
var ringtoneName = Utils.getSelectedValueByIndex(this.nodes.sound);
|
|
this.ringtonePlayer.playRingtone(ringtoneName);
|
|
};
|
|
|
|
/**
|
|
* pauseAlarm stops the alarm if it is playing
|
|
*/
|
|
Timer.Panel.prototype.pauseAlarm = function() {
|
|
this.ringtonePlayer.pause();
|
|
};
|
|
|
|
/**
|
|
* handleEvent Handler for all panel bound UI events.
|
|
* (`this` context object is not Timer.Panel)
|
|
*
|
|
* @param {Event} event The Event object.
|
|
*/
|
|
Timer.Panel.prototype.onclick = function(event) {
|
|
var meta = priv.get(event.target);
|
|
var panel = meta.panel;
|
|
var nodes = panel.nodes;
|
|
var timer = panel.timer;
|
|
var time;
|
|
|
|
if (meta.action === 'create') {
|
|
time = timeFromPicker(panel.picker.value);
|
|
|
|
if (!time) {
|
|
return;
|
|
}
|
|
|
|
timer.duration = time;
|
|
timer.sound = nodes.sound.value;
|
|
timer.vibrate = nodes.vibrate.checked;
|
|
timer.start();
|
|
|
|
// Hide the new timer dialog
|
|
panel.hideDialog();
|
|
} else if (meta.action === 'plus') {
|
|
timer.plus(+event.target.dataset.value);
|
|
// need to update immediately cause timer might be paused during click
|
|
panel.update();
|
|
} else {
|
|
// meta.action => timer[meta.action]()
|
|
//
|
|
// ie.
|
|
//
|
|
// if start => timer.start()
|
|
// if pause => timer.pause()
|
|
// if cancel => timer.cancel()
|
|
//
|
|
timer[meta.action]();
|
|
}
|
|
|
|
timer.commit();
|
|
};
|
|
|
|
return Timer.Panel;
|
|
});
|
|
|