Optimise progress timer callback.

This commit is contained in:
jcass 2016-04-04 05:14:45 +02:00
parent 1345357a5e
commit f43a9a7afa
4 changed files with 329 additions and 172 deletions

View File

@ -34,8 +34,8 @@
this.positionNode = document.createTextNode('') this.positionNode = document.createTextNode('')
this.durationNode = document.createTextNode('') this.durationNode = document.createTextNode('')
$('#songelapsed').append(this.positionNode) $('#songelapsed').empty().append(this.positionNode)
$('#songlength').append(this.durationNode) $('#songlength').empty().append(this.durationNode)
this._progressTimer = new ProgressTimer({ this._progressTimer = new ProgressTimer({
// Make sure that the timer object's context is available. // Make sure that the timer object's context is available.
@ -47,7 +47,12 @@
this._isConnected = false this._isConnected = false
this._mopidy.on('state:online', $.proxy(function () { this._isConnected = true }), this) this._mopidy.on('state:online', $.proxy(function () { this._isConnected = true }), this)
this._mopidy.on('state:offline', $.proxy(function () { this._isConnected = false }), this) this._mopidy.on('state:offline', $.proxy(function () { this._isConnected = false }), this)
this.init() this.syncState = SyncedProgressTimer.SYNC_STATE.NOT_SYNCED
this._isSyncScheduled = false
this._scheduleID = null
this._syncAttemptsRemaining = this._maxAttempts
this._previousSyncPosition = null
this._duration = null
} }
SyncedProgressTimer.SYNC_STATE = { SyncedProgressTimer.SYNC_STATE = {
@ -58,7 +63,7 @@
SyncedProgressTimer.format = function (milliseconds) { SyncedProgressTimer.format = function (milliseconds) {
if (milliseconds === Infinity) { if (milliseconds === Infinity) {
return '(n/a)' return ''
} else if (milliseconds === 0) { } else if (milliseconds === 0) {
return '0:00' return '0:00'
} }
@ -71,57 +76,37 @@
return minutes + ':' + seconds return minutes + ':' + seconds
} }
SyncedProgressTimer.prototype.init = function () {
this.syncState = SyncedProgressTimer.SYNC_STATE.NOT_SYNCED
this._syncAttemptsRemaining = this._maxAttempts
this.positionNode.nodeValue = ''
this.durationNode.nodeValue = ''
this._scheduledSyncTime = null
this._previousSyncPosition = null
this._duration = null
return this
}
SyncedProgressTimer.prototype.timerCallback = function (position, duration) { SyncedProgressTimer.prototype.timerCallback = function (position, duration) {
this._update(position, duration) this._update(position, duration)
if (this._isSyncScheduled && this._isConnected) {
if (this._isConnected && this._isSyncScheduled()) {
this._doSync(position, duration) this._doSync(position, duration)
} }
} }
SyncedProgressTimer.prototype._update = function (position, duration) { SyncedProgressTimer.prototype._update = function (position, duration) {
if (!(duration === Infinity && position === 0)) { switch (this.syncState) {
// Timer has been properly initialized. case SyncedProgressTimer.SYNC_STATE.NOT_SYNCED:
this.durationNode.nodeValue = SyncedProgressTimer.format(duration || Infinity) // Waiting for Mopidy to provide a target position.
switch (this.syncState) { this.positionNode.nodeValue = '(wait)'
case SyncedProgressTimer.SYNC_STATE.NOT_SYNCED: break
// Waiting for Mopidy to provide a target position. case SyncedProgressTimer.SYNC_STATE.SYNCING:
this.positionNode.nodeValue = '(wait)' // Busy seeking to new target position.
break this.positionNode.nodeValue = '(sync)'
case SyncedProgressTimer.SYNC_STATE.SYNCING: break
// Busy seeking to new target position. case SyncedProgressTimer.SYNC_STATE.SYNCED:
this.positionNode.nodeValue = '(sync)' this._previousSyncPosition = position
break this.positionNode.nodeValue = SyncedProgressTimer.format(position)
case SyncedProgressTimer.SYNC_STATE.SYNCED: $('#trackslider').val(position).slider('refresh')
this._previousSyncPosition = position break
this.positionNode.nodeValue = SyncedProgressTimer.format(position)
$('#trackslider').val(position).slider('refresh')
break
}
} else {
// Make sure that default values are displayed while the timer is being initialized.
this.positionNode.nodeValue = ''
this.durationNode.nodeValue = ''
$('#trackslider').val(0).slider('refresh')
} }
} }
SyncedProgressTimer.prototype._isSyncScheduled = function () { SyncedProgressTimer.prototype._scheduleSync = function (milliseconds) {
return this._scheduledSyncTime !== null && this._scheduledSyncTime <= new Date().getTime() // Use an anonymous callback to set a boolean value, which should be faster to
// check in the timeout callback than doing another function call.
clearTimeout(this._scheduleID)
this._isSyncScheduled = false
this._scheduleID = setTimeout($.proxy(function () { this._isSyncScheduled = true }, this), milliseconds)
} }
SyncedProgressTimer.prototype._doSync = function (position, duration) { SyncedProgressTimer.prototype._doSync = function (position, duration) {
@ -130,11 +115,14 @@
// Don't try to sync if progress timer has not been initialized yet. // Don't try to sync if progress timer has not been initialized yet.
return return
} }
var _this = this var _this = this
_this._mopidy.playback.getTimePosition().then(function (targetPosition) { _this._mopidy.playback.getTimePosition().then(function (targetPosition) {
if (_this.syncState === SyncedProgressTimer.SYNC_STATE.NOT_SYNCED) {
_this.syncState = SyncedProgressTimer.SYNC_STATE.SYNCING
}
if (Math.abs(targetPosition - position) <= 500) { if (Math.abs(targetPosition - position) <= 500) {
// Less than 500ms == in sync. // Less than 500ms == in sync.
_this.syncState = SyncedProgressTimer.SYNC_STATE.SYNCING
_this._syncAttemptsRemaining = Math.max(_this._syncAttemptsRemaining - 1, 0) _this._syncAttemptsRemaining = Math.max(_this._syncAttemptsRemaining - 1, 0)
if (_this._syncAttemptsRemaining < _this._maxAttempts - 1 && _this._previousSyncPosition !== targetPosition) { if (_this._syncAttemptsRemaining < _this._maxAttempts - 1 && _this._previousSyncPosition !== targetPosition) {
// Need at least two consecutive syncs to know that Mopidy // Need at least two consecutive syncs to know that Mopidy
@ -143,14 +131,13 @@
} }
_this._previousSyncPosition = targetPosition _this._previousSyncPosition = targetPosition
// Step back exponentially while increasing number of callbacks. // Step back exponentially while increasing number of callbacks.
_this._scheduledSyncTime = new Date().getTime() + _this._scheduleSync(delay_exponential(0.25, 2, _this._maxAttempts - _this._syncAttemptsRemaining) * 1000)
delay_exponential(0.25, 2, _this._maxAttempts - _this._syncAttemptsRemaining) * 1000
} else { } else {
// Drift is too large, re-sync with Mopidy. // Drift is too large, re-sync with Mopidy.
_this._syncAttemptsRemaining = _this._maxAttempts
_this._scheduledSyncTime = new Date().getTime() + 100
_this._previousSyncPosition = null
_this.syncState = SyncedProgressTimer.SYNC_STATE.SYNCING _this.syncState = SyncedProgressTimer.SYNC_STATE.SYNCING
_this._syncAttemptsRemaining = _this._maxAttempts
_this._previousSyncPosition = null
_this._scheduleSync(1000)
_this._progressTimer.set(targetPosition) _this._progressTimer.set(targetPosition)
} }
}) })
@ -160,58 +147,61 @@
if (arguments.length === 0) { if (arguments.length === 0) {
throw new Error('"SyncedProgressTimer.set" requires the "position" argument.') throw new Error('"SyncedProgressTimer.set" requires the "position" argument.')
} }
this.syncState = SyncedProgressTimer.SYNC_STATE.NOT_SYNCED
this._syncAttemptsRemaining = this._maxAttempts
// Workaround for https://github.com/adamcik/media-progress-timer/issues/3 // Workaround for https://github.com/adamcik/media-progress-timer/issues/3
// This causes the timer to die unexpectedly if the position exceeds // This causes the timer to die unexpectedly if the position exceeds
// the duration slightly. // the duration slightly.
if (this._duration && this._duration < position) { if (this._duration && this._duration < position) {
position = this._duration - 1 position = this._duration - 1
} }
this.init()
if (arguments.length === 1) { if (arguments.length === 1) {
this._progressTimer.set(position) this._progressTimer.set(position)
} else { } else {
this._duration = duration this._duration = duration
this._progressTimer.set(position, duration) this._progressTimer.set(position, duration)
this.durationNode.nodeValue = SyncedProgressTimer.format(duration)
} }
if (!this._isSyncScheduled()) { this.updatePosition(position, duration)
// Set lapsed time and slider position directly as timer callback is not currently $('#trackslider').val(position).slider('refresh')
// running.
this.positionNode.nodeValue = SyncedProgressTimer.format(position)
if (arguments.length === 2) {
this.durationNode.nodeValue = SyncedProgressTimer.format(duration)
}
$('#trackslider').val(position).slider('refresh')
}
return this return this
} }
SyncedProgressTimer.prototype.start = function () { SyncedProgressTimer.prototype.start = function () {
this.syncState = SyncedProgressTimer.SYNC_STATE.NOT_SYNCED this.syncState = SyncedProgressTimer.SYNC_STATE.NOT_SYNCED
this._scheduledSyncTime = new Date().getTime() this._scheduleSync(0)
this._progressTimer.start() this._progressTimer.start()
return this return this
} }
SyncedProgressTimer.prototype.stop = function () { SyncedProgressTimer.prototype.stop = function () {
this._progressTimer.stop() this._progressTimer.stop()
this._scheduledSyncTime = null clearTimeout(this._scheduleID)
this.updatePosition(this._previousSyncPosition) this._isSyncScheduled = false
if (this.syncState !== SyncedProgressTimer.SYNC_STATE.SYNCED && this._previousSyncPosition) {
// Timer was busy trying to sync when it was stopped, fallback to displaying the last synced position on screen.
this.positionNode.nodeValue = SyncedProgressTimer.format(this._previousSyncPosition)
}
return this return this
} }
SyncedProgressTimer.prototype.reset = function () { SyncedProgressTimer.prototype.reset = function () {
this._progressTimer.reset() // this._progressTimer.reset()
this.stop() this.stop()
this.init()
this.set(0, Infinity) this.set(0, Infinity)
return this return this
} }
SyncedProgressTimer.prototype.updatePosition = function (position) { SyncedProgressTimer.prototype.updatePosition = function (position) {
this.positionNode.nodeValue = SyncedProgressTimer.format(position) if (!(this._duration === Infinity && position === 0)) {
this.positionNode.nodeValue = SyncedProgressTimer.format(position)
} else {
this.positionNode.nodeValue = ''
}
} }
return SyncedProgressTimer return SyncedProgressTimer

View File

@ -1,6 +1,6 @@
CACHE MANIFEST CACHE MANIFEST
# 2016-04-03:v2 # 2016-04-06:v1
NETWORK: NETWORK:
* *

View File

@ -15,9 +15,15 @@
'use strict'; 'use strict';
// Helper function to provide a reference time in milliseconds. // Helper function to provide a reference time in milliseconds.
var now = typeof window.performance !== 'undefined' && var now = /* Sinon does not currently support faking `window.performance`
typeof window.performance.now !== 'undefined' && (see https://github.com/sinonjs/sinon/issues/803).
window.performance.now.bind(window.performance) || Date.now || Changing this to only rely on `new Date().getTime()
in the interim in order to allow testing of the
progress timer from MMW.
typeof window.performance !== 'undefined' &&
typeof window.performance.now !== 'undefined' &&
window.performance.now.bind(window.performance) || Date.now ||*/
function() { return new Date().getTime(); }; function() { return new Date().getTime(); };
// Helper to warn library users about deprecated features etc. // Helper to warn library users about deprecated features etc.

View File

@ -14,6 +14,20 @@ describe('SyncedTimer', function () {
var mopidy var mopidy
var playback var playback
var getTimePositionStub var getTimePositionStub
var clock
function setFakeTimers () {
clock = sinon.useFakeTimers()
syncedProgressTimer._progressTimer = new ProgressTimer({
callback: $.proxy(syncedProgressTimer.timerCallback, syncedProgressTimer),
disableRequestAnimationFrame: true // No window available during testing - use fallback mechanism to schedule updates
})
}
function restoreFakeTimers () {
clock.restore()
}
before(function () { before(function () {
$(document.body).append( $(document.body).append(
'<div id="slidercontainer"><!-- slider for track position -->' + '<div id="slidercontainer"><!-- slider for track position -->' +
@ -24,26 +38,40 @@ describe('SyncedTimer', function () {
'</div>' '</div>'
) )
$('#trackslider').slider() // Initialize slider $('#trackslider').slider() // Initialize slider
}) $('#trackslider').on('slidestart', function () {
beforeEach(function () { syncedProgressTimer.stop()
$('#trackslider').on('change', function () { syncedProgressTimer.updatePosition($(this).val()) })
})
$('#trackslider').on('slidestop', function () {
$('#trackslider').off('change')
syncedProgressTimer.updatePosition($(this).val())
// Simulate doSeekPos($(this).val())
syncedProgressTimer.set($(this).val())
})
playback = { playback = {
getTimePosition: function () { return $.when(1000) }, getTimePosition: function () { return $.when(1000) },
getState: function () { return $.when('stopped') } getState: function () { return $.when('stopped') }
} }
mopidy = new Mopidy({callingConvention: 'by-position-or-by-name'})
mopidy.playback = playback
getTimePositionStub = sinon.stub(playback, 'getTimePosition') getTimePositionStub = sinon.stub(playback, 'getTimePosition')
// Simulate Mopidy's track position advancing 10ms between each call. // Simulate Mopidy's track position advancing 250ms between each call for 0:01 to 0:10
for (var i = 0; i < MAX_ATTEMPTS; i++) { for (var i = 0; i < 10000 / 250; i++) {
getTimePositionStub.onCall(i).returns($.when(1000 + i * 10)) getTimePositionStub.onCall(i).returns($.when((i + 1) * 250))
} }
mopidy = sinon.stub(mopidy) mopidy = sinon.stub(new Mopidy({callingConvention: 'by-position-or-by-name'}))
mopidy.playback = playback
})
beforeEach(function () {
syncedProgressTimer = new SyncedProgressTimer(MAX_ATTEMPTS, mopidy) syncedProgressTimer = new SyncedProgressTimer(MAX_ATTEMPTS, mopidy)
syncedProgressTimer._isConnected = true syncedProgressTimer._isConnected = true
}) })
afterEach(function () { afterEach(function () {
getTimePositionStub.restore() getTimePositionStub.reset()
}) })
describe('#SyncedTimer()', function () { describe('#SyncedTimer()', function () {
it('should add text nodes to DOM for position and duration indicators', function () { it('should add text nodes to DOM for position and duration indicators', function () {
expect($('#songelapsed')).to.have.text('') expect($('#songelapsed')).to.have.text('')
@ -61,7 +89,7 @@ describe('SyncedTimer', function () {
}) })
it('should handle Infinity', function () { it('should handle Infinity', function () {
assert.equal(SyncedProgressTimer.format(Infinity), '(n/a)') assert.equal(SyncedProgressTimer.format(Infinity), '')
}) })
it('should handle zero', function () { it('should handle zero', function () {
@ -70,44 +98,57 @@ describe('SyncedTimer', function () {
}) })
describe('#timerCallback()', function () { describe('#timerCallback()', function () {
var clock
beforeEach(function () { beforeEach(function () {
clock = sinon.useFakeTimers() setFakeTimers()
syncedProgressTimer._progressTimer = new ProgressTimer({
callback: $.proxy(syncedProgressTimer.timerCallback, syncedProgressTimer),
disableRequestAnimationFrame: true // No window available during testing - use fallback mechanism to schedule updates
})
}) })
afterEach(function () { afterEach(function () {
clock.restore() restoreFakeTimers()
}) })
it('should not try to sync unless connected to mopidy', function () { it('should not try to sync unless connected to mopidy', function () {
var _doSyncStub = sinon.stub(syncedProgressTimer, '_doSync')
syncedProgressTimer._isConnected = false syncedProgressTimer._isConnected = false
var _syncScheduledStub = sinon.stub(syncedProgressTimer, '_isSyncScheduled') syncedProgressTimer.set(0, 1000).start()
assert.isFalse(_syncScheduledStub.called, '_syncScheduledStub called') clock.tick(1000)
_syncScheduledStub.restore()
assert.isFalse(_doSyncStub.called, '_doSync called')
syncedProgressTimer.stop()
_doSyncStub.restore()
}) })
it('should update text nodes', function () { it('should update text nodes', function () {
var updateStub = sinon.stub(syncedProgressTimer, '_update') var updateStub = sinon.stub(syncedProgressTimer, '_update')
syncedProgressTimer.set(0, 1000).start() syncedProgressTimer.set(0, 1000).start()
assert.isTrue(updateStub.called, '_update not called') assert.isTrue(updateStub.called, '_update not called')
syncedProgressTimer.stop()
updateStub.restore() updateStub.restore()
}) })
it('should check if a sync is scheduled', function () { it('should attempt to perform a sync as soon as timer is started', function () {
var scheduleStub = sinon.stub(syncedProgressTimer, '_isSyncScheduled').returns(true) var syncStub = sinon.stub(syncedProgressTimer, '_doSync')
syncedProgressTimer.set(0, 1000).start()
assert.isTrue(scheduleStub.called, '_isSyncScheduled not called') syncedProgressTimer.set(0, 1000).start() // 'start' will immediately schedule a sync.
scheduleStub.restore() clock.tick(250)
assert.isTrue(syncStub.called, '_doSync not called')
syncedProgressTimer.stop()
syncStub.restore()
}) })
it('should attempt to perform a sync when scheduled', function () { it('should not attempt to perform a sync untill scheduled', function () {
var syncStub = sinon.stub(syncedProgressTimer, '_doSync') var syncStub = sinon.stub(syncedProgressTimer, '_doSync')
syncedProgressTimer.set(0, 1000).start()
syncedProgressTimer.set(0, 5000).start()
syncedProgressTimer._scheduleSync(500)
clock.tick(250) clock.tick(250)
assert.isTrue(syncStub.called, '_doSync not called') assert.isFalse(syncStub.called, 'next _doSync should only have been called after 500ms')
syncStub.reset()
clock.tick(500)
assert.isTrue(syncStub.called, 'next _doSync not called after 500ms')
syncedProgressTimer.stop()
syncStub.restore() syncStub.restore()
}) })
@ -115,34 +156,27 @@ describe('SyncedTimer', function () {
// Simulate runtime on a 5-second track // Simulate runtime on a 5-second track
assert.equal(syncedProgressTimer.syncState, SyncedProgressTimer.SYNC_STATE.NOT_SYNCED, 'Timer was initialized in incorrect state') assert.equal(syncedProgressTimer.syncState, SyncedProgressTimer.SYNC_STATE.NOT_SYNCED, 'Timer was initialized in incorrect state')
syncedProgressTimer.set(0, 5000).start() syncedProgressTimer.set(0, 5000).start()
var wasSyncing = false var wasSyncing = false
for (var i = 0; i < MAX_ATTEMPTS; i++) { for (var i = 0; i < 4; i++) {
clock.tick(250) // 250ms * MAX_ATTEMPTS is only 2 seconds, but we'll be synced after only two attempts clock.tick(250)
wasSyncing = wasSyncing || syncedProgressTimer.syncState === SyncedProgressTimer.SYNC_STATE.SYNCING wasSyncing = wasSyncing || syncedProgressTimer.syncState === SyncedProgressTimer.SYNC_STATE.SYNCING
} }
syncedProgressTimer.stop()
assert.isTrue(wasSyncing, 'Timer never entered the "syncing" state') assert.isTrue(wasSyncing, 'Timer never entered the "syncing" state')
assert.equal(syncedProgressTimer.syncState, SyncedProgressTimer.SYNC_STATE.SYNCED, 'Timer failed to sync') assert.equal(syncedProgressTimer.syncState, SyncedProgressTimer.SYNC_STATE.SYNCED, 'Timer failed to sync')
syncedProgressTimer.stop()
}) })
}) })
describe('#_update()', function () { describe('#_update()', function () {
it('should clear timers and reset slider to zero while not ready', function () { it('should set duration to "" for tracks with infinite duration (e.g. streams)', function () {
syncedProgressTimer.positionNode.nodeValue = '1:00'
syncedProgressTimer.durationNode.nodeValue = '2:00'
$('#trackslider').val(100).slider('refresh')
syncedProgressTimer._update(0, Infinity)
assert.equal(syncedProgressTimer.positionNode.nodeValue, '')
assert.equal(syncedProgressTimer.durationNode.nodeValue, '')
assert.equal($('#trackslider').val(), 0)
})
it('should set duration to "(n/a)" for tracks with infinite duration (e.g. streams)', function () {
syncedProgressTimer._update(1000, Infinity) syncedProgressTimer._update(1000, Infinity)
assert.equal(syncedProgressTimer.durationNode.nodeValue, '(n/a)') assert.equal(syncedProgressTimer.durationNode.nodeValue, '')
}) })
it('should show "(wait)" while waiting for Mopidy to supply a position', function () { it('should show "(wait)" while untill syncing starts', function () {
syncedProgressTimer.syncState = SyncedProgressTimer.SYNC_STATE.NOT_SYNCED
syncedProgressTimer._update(1000, 2000) syncedProgressTimer._update(1000, 2000)
assert.equal(syncedProgressTimer.positionNode.nodeValue, '(wait)') assert.equal(syncedProgressTimer.positionNode.nodeValue, '(wait)')
}) })
@ -161,84 +195,122 @@ describe('SyncedTimer', function () {
}) })
}) })
describe('#_isSyncScheduled()', function () { describe('#scheduleSync', function () {
var scheduleSpy beforeEach(function () {
var clock setFakeTimers()
before(function () {
scheduleSpy = sinon.spy(syncedProgressTimer, '_isSyncScheduled')
clock = sinon.useFakeTimers()
}) })
after(function () { afterEach(function () {
scheduleSpy.restore() restoreFakeTimers()
clock.restore()
}) })
it('should schedule sync when scheduled time arrives', function () { it('should schedule sync when scheduled time arrives', function () {
syncedProgressTimer._scheduledSyncTime = new Date().getTime() + 1000 clock.tick(0)
assert.isFalse(syncedProgressTimer._isSyncScheduled()) syncedProgressTimer._scheduleSync(1000)
clock.tick(1000) assert.isFalse(syncedProgressTimer._isSyncScheduled)
assert.isTrue(syncedProgressTimer._isSyncScheduled()) clock.tick(1001)
assert.isTrue(syncedProgressTimer._isSyncScheduled)
})
it('should clear schedule on each call', function () {
var clearSpy = sinon.spy(window, 'clearTimeout')
clock.tick(0)
syncedProgressTimer._isSyncScheduled = true
syncedProgressTimer._scheduleSync(1000)
assert.isFalse(syncedProgressTimer._isSyncScheduled)
var scheduleID = syncedProgressTimer._scheduleID
clock.tick(1001)
syncedProgressTimer._scheduleSync(1000)
assert(clearSpy.calledWith(scheduleID))
clearSpy.restore()
}) })
}) })
describe('#_doSync', function () { describe('#_doSync', function () {
var clock
beforeEach(function () { beforeEach(function () {
clock = sinon.useFakeTimers() setFakeTimers()
}) })
afterEach(function () { afterEach(function () {
clock.restore() restoreFakeTimers()
}) })
it('should not try to sync until timer has been set', function () { it('should not try to sync until timer has been set', function () {
syncedProgressTimer._doSync(0, Infinity) syncedProgressTimer._doSync(0, Infinity)
assert.isFalse(getTimePositionStub.called, 'getTimePosition called even though timer has not been set') assert.isFalse(getTimePositionStub.called, 'tried to do sync even though the timer has not been set')
}) })
it('should request position from Mopidy', function () { it('should request position from Mopidy', function () {
syncedProgressTimer._doSync(1000, 2000) syncedProgressTimer._doSync(1000, 2000)
assert.isTrue(getTimePositionStub.called, 'getTimePosition not called') assert.isTrue(getTimePositionStub.called, 'getTimePosition not called')
}) })
it('should set state to "SYNCING" as soon as the first sync attempt is made', function () {
syncedProgressTimer.syncState = SyncedProgressTimer.SYNC_STATE.NOT_SYNCED
syncedProgressTimer._doSync(100, 2000)
assert.equal(syncedProgressTimer.syncState, SyncedProgressTimer.SYNC_STATE.SYNCING)
})
it('should set state to synced after two consecutive successful syncs (i.e. time drift < 500ms)', function () { it('should set state to synced after two consecutive successful syncs (i.e. time drift < 500ms)', function () {
assert.equal(syncedProgressTimer.syncState, SyncedProgressTimer.SYNC_STATE.NOT_SYNCED) assert.equal(syncedProgressTimer.syncState, SyncedProgressTimer.SYNC_STATE.NOT_SYNCED)
clock.tick(10) clock.tick(250)
syncedProgressTimer._doSync(1010, 2000) syncedProgressTimer._doSync(250, 2000)
assert.equal(syncedProgressTimer.syncState, SyncedProgressTimer.SYNC_STATE.SYNCING) assert.equal(syncedProgressTimer.syncState, SyncedProgressTimer.SYNC_STATE.SYNCING)
clock.tick(10) clock.tick(250)
syncedProgressTimer._doSync(1020, 2000) syncedProgressTimer._doSync(500, 2000)
assert.equal(syncedProgressTimer.syncState, SyncedProgressTimer.SYNC_STATE.SYNCED) assert.equal(syncedProgressTimer.syncState, SyncedProgressTimer.SYNC_STATE.SYNCED)
}) })
it('should re-initialize and set state to syncing if time drift is more than 500ms', function () { it('should re-initialize and set state to syncing if time drift is more than 500ms', function () {
syncedProgressTimer._doSync(1, 2000) var scheduleStub = sinon.stub(syncedProgressTimer, '_scheduleSync')
syncedProgressTimer._doSync(1000, 2000)
assert.equal(syncedProgressTimer.syncState, SyncedProgressTimer.SYNC_STATE.SYNCING) assert.equal(syncedProgressTimer.syncState, SyncedProgressTimer.SYNC_STATE.SYNCING)
assert.equal(syncedProgressTimer._syncAttemptsRemaining, syncedProgressTimer._maxAttempts) assert.equal(syncedProgressTimer._syncAttemptsRemaining, syncedProgressTimer._maxAttempts)
assert.isNull(syncedProgressTimer._previousSyncPosition)
assert(scheduleStub.calledWith(1000), 'Expected next sync to be scheduled 1s from now')
scheduleStub.restore()
}) })
it('should step back exponentially while syncing', function () { it('should step back exponentially while syncing', function () {
var scheduleStub = sinon.stub(syncedProgressTimer, '_scheduleSync')
for (var i = 0; i < syncedProgressTimer._maxAttempts; i++) { for (var i = 0; i < syncedProgressTimer._maxAttempts; i++) {
syncedProgressTimer._doSync(1000 + (i + 1) * 10, 2000) syncedProgressTimer._doSync(i * 250, 2000)
// If we don't advance the clock then '_syncAttemptsRemaining' should just contain the step-back in seconds assert.equal(syncedProgressTimer._syncAttemptsRemaining, syncedProgressTimer._maxAttempts - i - 1, 'Incorrect number of sync attempts remaining')
assert.equal(syncedProgressTimer._syncAttemptsRemaining, syncedProgressTimer._maxAttempts - i - 1, 'Incorrect sync attempts remaining') assert(scheduleStub.calledWith(0.25 * (Math.pow(2, i)) * 1000), 'Incorrect sync time scheduled: ' + scheduleStub.getCall(i))
assert.equal(syncedProgressTimer._scheduledSyncTime, (0.25 * (Math.pow(2, i)) * 1000), 'Incorrect sync time scheduled') scheduleStub.reset()
} }
scheduleStub.restore()
}) })
it('should check sync every 32s once synced', function () { it('should check sync every 32s once synced', function () {
var scheduleStub = sinon.stub(syncedProgressTimer, '_scheduleSync')
syncedProgressTimer._syncAttemptsRemaining = 0 syncedProgressTimer._syncAttemptsRemaining = 0
syncedProgressTimer._doSync(1000, 2000) syncedProgressTimer._doSync(250, 2000)
assert.equal(syncedProgressTimer._scheduledSyncTime, 32000) assert(scheduleStub.calledWith(32000))
scheduleStub.restore()
}) })
it('should not sync unless track playback is progressing', function () { it('should not sync unless track playback is progressing', function () {
getTimePositionStub.restore() getTimePositionStub.restore()
getTimePositionStub = sinon.stub(playback, 'getTimePosition')
getTimePositionStub.returns($.when(1000)) // Simulate playback 'stuck' at 1000ms.
assert.equal(syncedProgressTimer.syncState, SyncedProgressTimer.SYNC_STATE.NOT_SYNCED) assert.equal(syncedProgressTimer.syncState, SyncedProgressTimer.SYNC_STATE.NOT_SYNCED)
clock.tick(10) clock.tick(250)
syncedProgressTimer._doSync(1010, 2000) syncedProgressTimer._doSync(250, 2000)
assert.equal(syncedProgressTimer.syncState, SyncedProgressTimer.SYNC_STATE.SYNCING) assert.equal(syncedProgressTimer.syncState, SyncedProgressTimer.SYNC_STATE.SYNCING)
clock.tick(10) clock.tick(250)
syncedProgressTimer._doSync(1010, 2000) syncedProgressTimer._doSync(250, 2000)
assert.equal(syncedProgressTimer.syncState, SyncedProgressTimer.SYNC_STATE.SYNCING) assert.equal(syncedProgressTimer.syncState, SyncedProgressTimer.SYNC_STATE.SYNCING)
// Restore getTimePositionStub to previous state
getTimePositionStub = sinon.stub(playback, 'getTimePosition')
// Simulate Mopidy's track position advancing 250ms between each call for 0:01 to 0:10
for (var i = 0; i < 10000 / 250; i++) {
getTimePositionStub.onCall(i).returns($.when((i + 1) * 250))
}
}) })
}) })
@ -252,18 +324,21 @@ describe('SyncedTimer', function () {
assert.equal(syncedProgressTimer._progressTimer._state.position, 1000) assert.equal(syncedProgressTimer._progressTimer._state.position, 1000)
}) })
it('should update track slider if no sync is scheduled', function () { it('should update position and track slider immediately', function () {
syncedProgressTimer.stop() syncedProgressTimer.stop()
syncedProgressTimer.set(1000, 2000) syncedProgressTimer.set(1000, 2000)
expect($('#songelapsed').text()).to.endWith('0:01')
expect($('#songelapsed').text()).to.equal('0:01')
assert.equal($('#trackslider').val(), 1000) assert.equal($('#trackslider').val(), 1000)
}) })
it('should implement workaround for https://github.com/adamcik/media-progress-timer/issues/3', function () { it('should implement workaround for https://github.com/adamcik/media-progress-timer/issues/3', function () {
syncedProgressTimer.set(1000, 2000).start() syncedProgressTimer.set(1000, 2000).start()
assert.equal(syncedProgressTimer._duration, 2000) assert.equal(syncedProgressTimer._duration, 2000)
syncedProgressTimer.set(3000) syncedProgressTimer.set(3000)
assert.equal(syncedProgressTimer._progressTimer._state.position, 1999) assert.equal(syncedProgressTimer._progressTimer._state.position, 1999, 'Expected position to be less than duration')
syncedProgressTimer.stop()
}) })
}) })
@ -272,6 +347,7 @@ describe('SyncedTimer', function () {
var startStub = sinon.stub(syncedProgressTimer._progressTimer, 'start') var startStub = sinon.stub(syncedProgressTimer._progressTimer, 'start')
syncedProgressTimer.start() syncedProgressTimer.start()
assert(startStub.called) assert(startStub.called)
syncedProgressTimer.stop()
startStub.restore() startStub.restore()
}) })
@ -279,16 +355,19 @@ describe('SyncedTimer', function () {
syncedProgressTimer.syncState = SyncedProgressTimer.SYNC_STATE.SYNCED syncedProgressTimer.syncState = SyncedProgressTimer.SYNC_STATE.SYNCED
syncedProgressTimer.start() syncedProgressTimer.start()
assert.equal(syncedProgressTimer.syncState, SyncedProgressTimer.SYNC_STATE.NOT_SYNCED) assert.equal(syncedProgressTimer.syncState, SyncedProgressTimer.SYNC_STATE.NOT_SYNCED)
syncedProgressTimer.stop()
}) })
it('should schedule a sync immediately', function () { it('should schedule a sync immediately', function () {
var clock = sinon.useFakeTimers() var scheduleSpy = sinon.spy(syncedProgressTimer, '_scheduleSync')
syncedProgressTimer._scheduledSyncTime = new Date().getTime() + 5000
expect(syncedProgressTimer._scheduledSyncTime).to.be.above(new Date().getTime()) syncedProgressTimer.set(0, 1000)
syncedProgressTimer._isSyncScheduled = false
syncedProgressTimer.start() syncedProgressTimer.start()
clock.tick(1000)
expect(syncedProgressTimer._scheduledSyncTime).to.be.below(new Date().getTime()) assert(scheduleSpy.calledWith(0))
clock.restore() syncedProgressTimer.stop()
scheduleSpy.restore()
}) })
}) })
@ -296,45 +375,51 @@ describe('SyncedTimer', function () {
it('should stop timer', function () { it('should stop timer', function () {
var stopStub = sinon.stub(syncedProgressTimer._progressTimer, 'stop') var stopStub = sinon.stub(syncedProgressTimer._progressTimer, 'stop')
syncedProgressTimer.stop() syncedProgressTimer.stop()
assert(stopStub.called) assert(stopStub.called)
syncedProgressTimer.stop()
stopStub.restore() stopStub.restore()
}) })
it('should show position when stopped', function () { it('should show last synced position if stopped while busy syncing', function () {
syncedProgressTimer.set(1000, 5000)
syncedProgressTimer.syncState = SyncedProgressTimer.SYNC_STATE.SYNCED syncedProgressTimer.syncState = SyncedProgressTimer.SYNC_STATE.SYNCED
syncedProgressTimer._update(1000, 5000) syncedProgressTimer._previousSyncPosition = 1000
syncedProgressTimer.syncState = SyncedProgressTimer.SYNC_STATE.SYNCING syncedProgressTimer.syncState = SyncedProgressTimer.SYNC_STATE.SYNCING
syncedProgressTimer._update(2000, 5000) syncedProgressTimer._update(2000, 5000)
assert.equal(syncedProgressTimer.positionNode.nodeValue, '(sync)') assert.equal(syncedProgressTimer.positionNode.nodeValue, '(sync)')
syncedProgressTimer.stop() syncedProgressTimer.stop()
assert.equal(syncedProgressTimer.positionNode.nodeValue, '0:01') assert.equal(syncedProgressTimer.positionNode.nodeValue, '0:01')
expect($('#songelapsed').text()).to.equal('0:01')
}) })
it('should cancel any scheduled syncs', function () { it('should cancel any scheduled syncs', function () {
syncedProgressTimer._scheduledSyncTime = 5000 var cancelSpy = sinon.spy(window, 'clearTimeout')
syncedProgressTimer._isSyncScheduled = true
syncedProgressTimer.stop() syncedProgressTimer.stop()
expect(syncedProgressTimer._scheduledSyncTime).to.be.null
assert.isFalse(syncedProgressTimer._isSyncScheduled)
assert(cancelSpy.calledWith(syncedProgressTimer._scheduleID))
cancelSpy.restore()
}) })
}) })
describe('#reset()', function () { describe('#reset()', function () {
it('should reset timer to 0:00 - (n/a) ', function () { it('should reset timer to "" - "" ', function () {
var resetStub = sinon.stub(syncedProgressTimer._progressTimer, 'reset')
var initStub = sinon.stub(syncedProgressTimer, 'init')
var stopStub = sinon.stub(syncedProgressTimer, 'stop') var stopStub = sinon.stub(syncedProgressTimer, 'stop')
var setStub = sinon.stub(syncedProgressTimer, 'set')
syncedProgressTimer.reset() syncedProgressTimer.reset()
assert(resetStub.called)
assert(initStub.called)
assert(stopStub.called) assert(stopStub.called)
assert(setStub.called)
assert.equal(syncedProgressTimer.positionNode.nodeValue, '0:00') assert.equal(syncedProgressTimer.positionNode.nodeValue, '', 'Position node was not reset')
assert.equal(syncedProgressTimer.durationNode.nodeValue, '(n/a)') assert.equal(syncedProgressTimer.durationNode.nodeValue, '', 'Duration node was not reset')
resetStub.restore()
initStub.restore()
stopStub.restore() stopStub.restore()
setStub.restore()
}) })
}) })
@ -345,8 +430,84 @@ describe('SyncedTimer', function () {
syncedProgressTimer.updatePosition(1000) syncedProgressTimer.updatePosition(1000)
assert.isTrue(formatSpy.called) assert.isTrue(formatSpy.called)
expect(syncedProgressTimer.positionNode.nodeValue).to.endWith('0:01') expect(syncedProgressTimer.positionNode.nodeValue).to.equal('0:01')
formatSpy.restore() formatSpy.restore()
}) })
it('should set position to "" if timer has not been initialized', function () {
syncedProgressTimer.set(1000, 2000)
expect(syncedProgressTimer.positionNode.nodeValue).to.equal('0:01')
syncedProgressTimer.updatePosition(0)
assert.equal(syncedProgressTimer.positionNode.nodeValue, '0:00', 'Position node was not reset')
syncedProgressTimer.reset()
syncedProgressTimer.updatePosition(0)
assert.equal(syncedProgressTimer.positionNode.nodeValue, '', 'Position node was not reset')
})
})
describe('integration tests', function () {
beforeEach(function () {
setFakeTimers()
})
afterEach(function () {
restoreFakeTimers()
})
it('simulate 30-second test run, ', function () {
// Initialize
syncedProgressTimer.reset()
expect($('#songelapsed').text()).to.equal('')
expect($('#songlength').text()).to.equal('')
assert.equal($('#trackslider').val(), 0)
// Set song info
syncedProgressTimer.set(0, 30000)
expect($('#songelapsed').text()).to.equal('0:00')
expect($('#songlength').text()).to.equal('0:30')
assert.equal($('#trackslider').val(), 0)
// Start
syncedProgressTimer.start()
clock.tick(40)
expect($('#songelapsed').text()).to.equal('(wait)')
expect($('#songlength').text()).to.equal('0:30')
assert.equal($('#trackslider').val(), 0)
// Syncing
clock.tick(250)
expect($('#songelapsed').text()).to.equal('(sync)')
expect($('#songlength').text()).to.equal('0:30')
assert.equal($('#trackslider').val(), 0)
// Synced
clock.tick(1000)
expect($('#songelapsed').text()).to.equal('0:01')
expect($('#songlength').text()).to.equal('0:30')
assert.isAtLeast($('#trackslider').val(), 1000)
// Move slider
$('#trackslider').trigger('slidestart')
clock.tick(250)
$('#trackslider').val(5000).slider('refresh')
$('#trackslider').trigger('change')
clock.tick(250)
$('#trackslider').trigger('slidestop')
clock.tick(1000) // Position should remain '0:05' as the timer should not be running after a slider change
expect($('#songelapsed').text()).to.equal('0:05')
// Start -> Sync -> Stop
syncedProgressTimer.start()
clock.tick(40)
expect($('#songelapsed').text()).to.equal('(sync)')
syncedProgressTimer._previousSyncPosition = 1000
syncedProgressTimer.stop()
expect($('#songelapsed').text()).to.equal('0:01')
expect($('#songlength').text()).to.equal('0:30')
syncedProgressTimer.stop()
})
}) })
}) })