From 9347c7d5575b9a32f34d9f28b4a068f9bca99a21 Mon Sep 17 00:00:00 2001 From: jcass Date: Mon, 28 Mar 2016 13:57:25 +0200 Subject: [PATCH] Upgrade media progress timer. Modularise progress_timer.js. --- README.rst | 1 + karma.conf.js | 1 - mopidy_musicbox_webclient/static/index.html | 2 +- .../static/js/controls.js | 6 +- .../static/js/functionsvars.js | 3 +- mopidy_musicbox_webclient/static/js/gui.js | 24 +- mopidy_musicbox_webclient/static/js/images.js | 194 +++--- .../static/js/library.js | 639 +++++++++--------- .../static/js/process_ws.js | 4 +- .../static/js/progress_timer.js | 177 ----- .../static/js/synced_timer.js | 218 ++++++ mopidy_musicbox_webclient/static/mb.appcache | 4 +- .../vendors/media_progress_timer/timer.js | 265 ++++---- tests/{ => js}/test_images.js | 94 ++- tests/js/test_library.js | 56 ++ tests/js/test_synced_timer.js | 352 ++++++++++ tests/test_library.js | 54 -- 17 files changed, 1253 insertions(+), 841 deletions(-) delete mode 100644 mopidy_musicbox_webclient/static/js/progress_timer.js create mode 100644 mopidy_musicbox_webclient/static/js/synced_timer.js rename tests/{ => js}/test_images.js (71%) create mode 100644 tests/js/test_library.js create mode 100644 tests/js/test_synced_timer.js delete mode 100644 tests/test_library.js diff --git a/README.rst b/README.rst index 58c12b0..5d3a973 100644 --- a/README.rst +++ b/README.rst @@ -77,6 +77,7 @@ v2.3.0 (UNRELEASED) - Now displays album and artist info when browsing tracks. (Addresses: `#99 `_). - Now remembers which backend was searched previously, and automatically selects that backend as the default search target. (Addresses: `#130 `_). +- Upgrade Media Progress Timer to version 3.0.0. **Fixes** diff --git a/karma.conf.js b/karma.conf.js index 57f7fa3..7f456f9 100644 --- a/karma.conf.js +++ b/karma.conf.js @@ -13,7 +13,6 @@ module.exports = function (config) { // list of files / patterns to load in the browser files: [ 'mopidy_musicbox_webclient/static/vendors/**/*.js', - // TODO: can remove the next line once JavaScript codebase has been modularized. 'mopidy_musicbox_webclient/static/js/**/*.js', 'tests/**/test_*.js' ], diff --git a/mopidy_musicbox_webclient/static/index.html b/mopidy_musicbox_webclient/static/index.html index ee58e9e..4c4b832 100644 --- a/mopidy_musicbox_webclient/static/index.html +++ b/mopidy_musicbox_webclient/static/index.html @@ -486,7 +486,7 @@ - + diff --git a/mopidy_musicbox_webclient/static/js/controls.js b/mopidy_musicbox_webclient/static/js/controls.js index 3db4a6d..b1bcc9b 100644 --- a/mopidy_musicbox_webclient/static/js/controls.js +++ b/mopidy_musicbox_webclient/static/js/controls.js @@ -288,13 +288,13 @@ function setPlayState (nwplay) { $('#btplay >i').removeClass('fa-play').addClass('fa-pause') $('#btplay').attr('title', 'Pause') mopidy.playback.getTimePosition().then(processCurrentposition, console.error) - startProgressTimer() + syncedProgressTimer.start() } else { $('#btplayNowPlaying >i').removeClass('fa-pause').addClass('fa-play') $('#btplayNowPlaying').attr('title', 'Play') $('#btplay >i').removeClass('fa-pause').addClass('fa-play') $('#btplay').attr('title', 'Play') - progressTimer.stop() + syncedProgressTimer.stop() } play = nwplay } @@ -396,7 +396,7 @@ function doSeekPos (value) { function setPosition (pos) { if (!positionChanging && $('#trackslider').val() !== pos) { - setProgressTimer(pos) + syncedProgressTimer.set(pos) } } diff --git a/mopidy_musicbox_webclient/static/js/functionsvars.js b/mopidy_musicbox_webclient/static/js/functionsvars.js index f270607..ae1634a 100644 --- a/mopidy_musicbox_webclient/static/js/functionsvars.js +++ b/mopidy_musicbox_webclient/static/js/functionsvars.js @@ -5,6 +5,7 @@ */ var mopidy +var syncedProgressTimer // values for controls var play = false @@ -268,7 +269,7 @@ function renderSongLiDivider (track, nextTrack, currentIndex, target) { renderSongLiTrackArtists(track) + '

' ) // Retrieve album covers - coverArt.getCover(track.uri, getjQueryID(target + '-cover', track.uri, true), 'small') + coverArt.getCover(track.uri, getjQueryID(target + '-cover', track.uri, true), 'small', mopidy) } else if (currentIndex > 0) { // Small divider $(target).before('
  •  
  • ') diff --git a/mopidy_musicbox_webclient/static/js/gui.js b/mopidy_musicbox_webclient/static/js/gui.js index a1e1be6..932ee1a 100644 --- a/mopidy_musicbox_webclient/static/js/gui.js +++ b/mopidy_musicbox_webclient/static/js/gui.js @@ -128,7 +128,7 @@ function setSongInfo (data) { } if (data.track.album && data.track.album.name) { $('#modalalbum').html('' + data.track.album.name + '') - coverArt.getCover(data.track.uri, '#infocover, #controlspopupimage', 'extralarge') + coverArt.getCover(data.track.uri, '#infocover, #controlspopupimage', 'extralarge', mopidy) } else { $('#modalalbum').html('') $('#infocover').attr('src', 'images/default_cover.png') @@ -139,10 +139,9 @@ function setSongInfo (data) { $('#trackslider').attr('min', 0) $('#trackslider').attr('max', songlength) - resetProgressTimer() - progressTimer.set(0, songlength) + syncedProgressTimer.reset().set(0, songlength) if (play) { - startProgressTimer() + syncedProgressTimer.start() } resizeMb() @@ -239,7 +238,7 @@ function initSocketevents () { showFavourites() }) library.getBrowseDir() - library.getSearchSchemes() + library.getSearchSchemes(searchBlacklist, mopidy) showLoading(false) $(window).hashchange() }) @@ -302,7 +301,7 @@ function initSocketevents () { mopidy.on('event:seeked', function (data) { setPosition(parseInt(data.time_position)) if (play) { - startProgressTimer() + syncedProgressTimer.start() } }) @@ -496,9 +495,7 @@ $(document).ready(function (event) { // initialize events initSocketevents() - progressTimer = new ProgressTimer({ - callback: timerCallback - }) + syncedProgressTimer = new SyncedProgressTimer(8, mopidy) resetSong() @@ -593,13 +590,13 @@ $(document).ready(function (event) { // swipe songinfo and panel $('#normalFooter, #nowPlayingFooter').on('swiperight', doPrevious) $('#normalFooter, #nowPlayingFooter').on('swipeleft', doNext) - $('#nowPlayingpane, .ui-body-c, #header, #panel, .pane').on('swiperight', function () { + $('#nowPlayingpane, .ui-body-c, #header, #panel, .pane').on('swiperight', function (event) { if (!$(event.target).is('#normalFooter') && !$(event.target).is('#nowPlayingFooter')) { $('#panel').panel('open') event.stopImmediatePropagation() } }) - $('#nowPlayingpane, .ui-body-c, #header, #panel, .pane').on('swipeleft', function () { + $('#nowPlayingpane, .ui-body-c, #header, #panel, .pane').on('swipeleft', function (event) { if (!$(event.target).is('#normalFooter') && !$(event.target).is('#nowPlayingFooter')) { $('#panel').panel('close') event.stopImmediatePropagation() @@ -607,12 +604,13 @@ $(document).ready(function (event) { }) $('#trackslider').on('slidestart', function () { - progressTimer.stop() - $('#trackslider').on('change', function () { updatePosition($(this).val()) }) + syncedProgressTimer.stop() + $('#trackslider').on('change', function () { syncedProgressTimer.updatePosition($(this).val()) }) }) $('#trackslider').on('slidestop', function () { $('#trackslider').off('change') + syncedProgressTimer.updatePosition($(this).val()) doSeekPos($(this).val()) }) diff --git a/mopidy_musicbox_webclient/static/js/images.js b/mopidy_musicbox_webclient/static/js/images.js index 7da092b..8bef0c3 100644 --- a/mopidy_musicbox_webclient/static/js/images.js +++ b/mopidy_musicbox_webclient/static/js/images.js @@ -1,110 +1,108 @@ -/** - * @author Wouter van Wijk - */ +(function (root, factory) { + if (typeof define === 'function' && define.amd) { + define([], factory) + } else if (typeof module === 'object' && module.exports) { + module.exports = factory() + } else { + root.coverArt = factory() + } +}(this, function () { + 'use strict' -API_KEY = 'b6d34c3af91d62ab0ae00ab1b6fa8733' -API_SECRET = '2c631802c2285d5d5d1502462fe42a2b' + var API_KEY = 'b6d34c3af91d62ab0ae00ab1b6fa8733' + var API_SECRET = '2c631802c2285d5d5d1502462fe42a2b' -var coverArt = { - fmcache: new LastFMCache(), - lastfm: new LastFM({ - apiKey: API_KEY, - apiSecret: API_SECRET, - cache: this.fmcache - }), + var coverArt = { + lastfm: new LastFM({ + apiKey: API_KEY, + apiSecret: API_SECRET, + cache: new LastFMCache() + }), - getCover: function (uri, images, size) { - var defUrl = 'images/default_cover.png' - $(images).attr('src', defUrl) - if (!uri) { - return - } - mopidy.library.getImages({'uris': [uri]}).then(function (imageResults) { - var uri = Object.keys(imageResults)[0] - if (imageResults[uri].length > 0) { - $(images).attr('src', imageResults[uri][0].uri) - } else { - // Also check deprecated 'album.images' in case backend does not - // implement mopidy.library.getImages yet... - coverArt.getCoverFromAlbum(uri, images, size) - } - }) - }, - - // Note that this approach has been deprecated in Mopidy - // TODO: Remove when Mopidy no longer supports getting images - // with 'album.images'. - getCoverFromAlbum: function (uri, images, size) { - var defUrl = 'images/default_cover.png' - $(images).attr('src', defUrl) - if (!uri) { - return - } - mopidy.library.lookup({'uris': [uri]}).then(function (resultDict) { - var uri = Object.keys(resultDict)[0] - var track = resultDict[uri][0] - if (track && track.album && track.album.images && track.album.images.length > 0) { - $(images).attr('src', track.album.images[0]) - } else if (track && (track.album || track.artist)) { - // Fallback to last.fm - coverArt.getCoverFromLastFm(track, images, size) - } else { + getCover: function (uri, images, size, mopidy) { + var defUrl = 'images/default_cover.png' + $(images).attr('src', defUrl) + if (!uri) { return } - }) - }, - - getCoverFromLastFm: function (track, images, size) { - var defUrl = 'images/default_cover.png' - $(images).attr('src', defUrl) - if (!track || !(track.album || track.artists)) { - return - } - var albumname = (track.album && track.album.name) ? track.album.name : '' - var artistname = '' - if (track.album && track.album.artists && track.album.artists.length > 0) { - // First look for the artist in the album - artistname = track.album.artists[0].name - } else if (track.artists && (track.artists.length > 0)) { - // Fallback to using artists for specific track - artistname = track.artists[0].name - } - - this.lastfm.album.getInfo({artist: artistname, album: albumname}, {success: function (data) { - for (var i = 0; i < data.album.image.length; i++) { - if (data.album.image[i].size === size) { - $(images).attr('src', data.album.image[i]['#text'] || defUrl) + mopidy.library.getImages({'uris': [uri]}).then(function (imageResults) { + var uri = Object.keys(imageResults)[0] + if (imageResults[uri].length > 0) { + $(images).attr('src', imageResults[uri][0].uri) + } else { + // Also check deprecated 'album.images' in case backend does not + // implement mopidy.library.getImages yet... + coverArt.getCoverFromAlbum(uri, images, size, mopidy) } - } - }, error: function (code, message) { - console.error('Error retrieving album info from last.fm', code, message) - }}) - }, + }) + }, - getArtistImage: function (artist, images, size) { - var defUrl = 'images/user_24x32.png' - $(images).attr('src', defUrl) - if (!artist || artist.length === 0) { - return - } - this.lastfm.artist.getInfo({artist: artist}, {success: function (data) { - for (var i = 0; i < data.artist.image.length; i++) { - if (data.artist.image[i].size === size) { - $(images).attr('src', data.artist.image[i]['#text'] || defUrl) + // Note that this approach has been deprecated in Mopidy + // TODO: Remove when Mopidy no longer supports getting images + // with 'album.images'. + getCoverFromAlbum: function (uri, images, size, mopidy) { + var defUrl = 'images/default_cover.png' + $(images).attr('src', defUrl) + if (!uri) { + return + } + mopidy.library.lookup({'uris': [uri]}).then(function (resultDict) { + var uri = Object.keys(resultDict)[0] + var track = resultDict[uri][0] + if (track && track.album && track.album.images && track.album.images.length > 0) { + $(images).attr('src', track.album.images[0]) + } else if (track && (track.album || track.artist)) { + // Fallback to last.fm + coverArt.getCoverFromLastFm(track, images, size) + } else { + return } + }) + }, + + getCoverFromLastFm: function (track, images, size) { + var defUrl = 'images/default_cover.png' + $(images).attr('src', defUrl) + if (!track || !(track.album || track.artists)) { + return + } + var albumname = (track.album && track.album.name) ? track.album.name : '' + var artistname = '' + if (track.album && track.album.artists && track.album.artists.length > 0) { + // First look for the artist in the album + artistname = track.album.artists[0].name + } else if (track.artists && (track.artists.length > 0)) { + // Fallback to using artists for specific track + artistname = track.artists[0].name } - }, error: function (code, message) { - console.error('Error retrieving artist info from last.fm', code, message) - }}) - } -} -$(document).ready(coverArt.init) + this.lastfm.album.getInfo({artist: artistname, album: albumname}, {success: function (data) { + for (var i = 0; i < data.album.image.length; i++) { + if (data.album.image[i].size === size) { + $(images).attr('src', data.album.image[i]['#text'] || defUrl) + } + } + }, error: function (code, message) { + console.error('Error retrieving album info from last.fm', code, message) + }}) + }, -// TODO: Remove this once JavaScript codebase has been completely modularized -// in favour of bundling everything using 'browserify'. -if (typeof exports !== 'undefined') { - if (typeof module !== 'undefined' && module.exports) { - module.exports = coverArt + getArtistImage: function (artist, images, size) { + var defUrl = 'images/user_24x32.png' + $(images).attr('src', defUrl) + if (!artist || artist.length === 0) { + return + } + this.lastfm.artist.getInfo({artist: artist}, {success: function (data) { + for (var i = 0; i < data.artist.image.length; i++) { + if (data.artist.image[i].size === size) { + $(images).attr('src', data.artist.image[i]['#text'] || defUrl) + } + } + }, error: function (code, message) { + console.error('Error retrieving artist info from last.fm', code, message) + }}) + } } -} + return coverArt +})) diff --git a/mopidy_musicbox_webclient/static/js/library.js b/mopidy_musicbox_webclient/static/js/library.js index 7abd995..dad0606 100644 --- a/mopidy_musicbox_webclient/static/js/library.js +++ b/mopidy_musicbox_webclient/static/js/library.js @@ -1,347 +1,352 @@ -var library = { +(function (root, factory) { + if (typeof define === 'function' && define.amd) { + define([], factory) + } else if (typeof module === 'object' && module.exports) { + module.exports = factory() + } else { + root.library = factory() + } +}(this, function () { + 'use strict' -/** ******************************* - * Search - *********************************/ - searchPressed: function (key) { - var value = $('#searchinput').val() - switchContent('search') + var library = { - if (key === 13) { - library.initSearch() - return false - } - return true - }, + /** ******************************* + * Search + *********************************/ + searchPressed: function (key) { + var value = $('#searchinput').val() + switchContent('search') - // init search - initSearch: function () { - var value = $('#searchinput').val() - var searchService = $('#selectSearchService').val() - $.cookie('searchScheme', searchService, { expires: 365 }) + if (key === 13) { + library.initSearch() + return false + } + return true + }, - if ((value.length < 100) && (value.length > 0)) { - showLoading(true) - // hide ios/android keyboard - document.activeElement.blur() - $('input').blur() + // init search + initSearch: function () { + var value = $('#searchinput').val() + var searchService = $('#selectSearchService').val() + $.cookie('searchScheme', searchService, { expires: 365 }) - delete customTracklists[URI_SCHEME + ':allresultscache'] - delete customTracklists[URI_SCHEME + ':artistresultscache'] - delete customTracklists[URI_SCHEME + ':albumresultscache'] - delete customTracklists[URI_SCHEME + ':trackresultscache'] - $('#searchartists').hide() - $('#searchalbums').hide() - $('#searchtracks').hide() + if ((value.length < 100) && (value.length > 0)) { + showLoading(true) + // hide ios/android keyboard + document.activeElement.blur() + $('input').blur() - if (searchService !== 'all') { - mopidy.library.search({'query': {any: [value]}, 'uris': [searchService + ':']}).then(library.processSearchResults, console.error) - } else { - mopidy.getUriSchemes().then(function (schemes) { - var query = {} - var uris = [] + delete customTracklists[URI_SCHEME + ':allresultscache'] + delete customTracklists[URI_SCHEME + ':artistresultscache'] + delete customTracklists[URI_SCHEME + ':albumresultscache'] + delete customTracklists[URI_SCHEME + ':trackresultscache'] + $('#searchartists').hide() + $('#searchalbums').hide() + $('#searchtracks').hide() - var regexp = $.map(schemes, function (scheme) { - return '^' + scheme + ':' - }).join('|') + if (searchService !== 'all') { + mopidy.library.search({'query': {any: [value]}, 'uris': [searchService + ':']}).then(library.processSearchResults, console.error) + } else { + mopidy.getUriSchemes().then(function (schemes) { + var query = {} + var uris = [] - var match = value.match(regexp) - if (match) { - var scheme = match[0] - query = {uri: [value]} - uris = [scheme] - } else { - query = {any: [value]} + var regexp = $.map(schemes, function (scheme) { + return '^' + scheme + ':' + }).join('|') + + var match = value.match(regexp) + if (match) { + var scheme = match[0] + query = {uri: [value]} + uris = [scheme] + } else { + query = {any: [value]} + } + mopidy.library.search({'query': query, 'uris': uris}).then(library.processSearchResults, console.error) + }) + } + } + }, + + /** ****************************************************** + * process results of a search + *********************************************************/ + processSearchResults: function (resultArr) { + $(SEARCH_TRACK_TABLE).empty() + $(SEARCH_ARTIST_TABLE).empty() + $(SEARCH_ALBUM_TABLE).empty() + + // Merge results from different backends. + // TODO should of coures have multiple tables + var results = {'tracks': [], 'artists': [], 'albums': []} + var i, j + var emptyResult = true + + for (i = 0; i < resultArr.length; i++) { + if (resultArr[i].tracks) { + for (j = 0; j < resultArr[i].tracks.length; j++) { + results.tracks.push(resultArr[i].tracks[j]) + emptyResult = false } - mopidy.library.search({'query': query, 'uris': uris}).then(library.processSearchResults, console.error) + } + if (resultArr[i].artists) { + for (j = 0; j < resultArr[i].artists.length; j++) { + results.artists.push(resultArr[i].artists[j]) + emptyResult = false + } + } + if (resultArr[i].albums) { + for (j = 0; j < resultArr[i].albums.length; j++) { + results.albums.push(resultArr[i].albums[j]) + emptyResult = false + } + } + } + + customTracklists[URI_SCHEME + ':trackresultscache'] = results.tracks + + if (emptyResult) { + $('#searchtracks').show() + $(SEARCH_TRACK_TABLE).append( + '
  • No tracks found...

  • ' + ) + toast('No results') + showLoading(false) + return false + } + + if (results.artists.length > 0) { + $('#searchartists').show() + } + + if (results.albums.length > 0) { + $('#searchalbums').show() + } + + if (results.tracks.length > 0) { + $('#searchtracks').show() + } + + // Returns a string where {x} in template is replaced by tokens[x]. + function theme (template, tokens) { + return template.replace(/{[^}]+}/g, function (match) { + return tokens[match.slice(1, -1)] }) } - } - }, -/** ****************************************************** - * process results of a search - *********************************************************/ - processSearchResults: function (resultArr) { - $(SEARCH_TRACK_TABLE).empty() - $(SEARCH_ARTIST_TABLE).empty() - $(SEARCH_ALBUM_TABLE).empty() + // 'Show more' pattern + var showMorePattern = '
  • Show {count} more
  • ' - // Merge results from different backends. - // TODO should of coures have multiple tables - var results = {'tracks': [], 'artists': [], 'albums': []} - var i, j - var emptyResult = true + // Artist results + var child = '' + var pattern = '
  • {name}
  • ' + var tokens - for (i = 0; i < resultArr.length; i++) { - if (resultArr[i].tracks) { - for (j = 0; j < resultArr[i].tracks.length; j++) { - results.tracks.push(resultArr[i].tracks[j]) - emptyResult = false + for (i = 0; i < results.artists.length; i++) { + tokens = { + 'id': results.artists[i].uri, + 'name': results.artists[i].name, + 'class': getMediaClass(results.artists[i].uri) } - } - if (resultArr[i].artists) { - for (j = 0; j < resultArr[i].artists.length; j++) { - results.artists.push(resultArr[i].artists[j]) - emptyResult = false + + // Add 'Show all' item after a certain number of hits. + if (i === 4 && results.artists.length > 5) { + child += theme(showMorePattern, {'count': results.artists.length - i}) + pattern = pattern.replace('
  • ', '
  • ') } + + child += theme(pattern, tokens) } - if (resultArr[i].albums) { - for (j = 0; j < resultArr[i].albums.length; j++) { - results.albums.push(resultArr[i].albums[j]) - emptyResult = false + + // Inject list items, refresh listview and hide superfluous items. + $(SEARCH_ARTIST_TABLE).html(child).listview('refresh').find('.overflow').hide() + + // Album results + child = '' + pattern = '
  • ' + pattern += '
    {albumName}
    ' + pattern += '

    {artistName}

    ' + pattern += '
  • ' + + for (i = 0; i < results.albums.length; i++) { + tokens = { + 'albumId': results.albums[i].uri, + 'albumName': results.albums[i].name, + 'artistName': '', + 'albumYear': results.albums[i].date, + 'class': getMediaClass(results.albums[i].uri) } - } - } - - customTracklists[URI_SCHEME + ':trackresultscache'] = results.tracks - - if (emptyResult) { - $('#searchtracks').show() - $(SEARCH_TRACK_TABLE).append( - '
  • No tracks found...

  • ' - ) - toast('No results') - showLoading(false) - return false - } - - if (results.artists.length > 0) { - $('#searchartists').show() - } - - if (results.albums.length > 0) { - $('#searchalbums').show() - } - - if (results.tracks.length > 0) { - $('#searchtracks').show() - } - - // Returns a string where {x} in template is replaced by tokens[x]. - function theme (template, tokens) { - return template.replace(/{[^}]+}/g, function (match) { - return tokens[match.slice(1, -1)] - }) - } - - // 'Show more' pattern - var showMorePattern = '
  • Show {count} more
  • ' - - // Artist results - var child = '' - var pattern = '
  • {name}
  • ' - var tokens - - for (i = 0; i < results.artists.length; i++) { - tokens = { - 'id': results.artists[i].uri, - 'name': results.artists[i].name, - 'class': getMediaClass(results.artists[i].uri) - } - - // Add 'Show all' item after a certain number of hits. - if (i === 4 && results.artists.length > 5) { - child += theme(showMorePattern, {'count': results.artists.length - i}) - pattern = pattern.replace('
  • ', '
  • ') - } - - child += theme(pattern, tokens) - } - - // Inject list items, refresh listview and hide superfluous items. - $(SEARCH_ARTIST_TABLE).html(child).listview('refresh').find('.overflow').hide() - - // Album results - child = '' - pattern = '
  • ' - pattern += '
    {albumName}
    ' - pattern += '

    {artistName}

    ' - pattern += '
  • ' - - for (i = 0; i < results.albums.length; i++) { - tokens = { - 'albumId': results.albums[i].uri, - 'albumName': results.albums[i].name, - 'artistName': '', - 'albumYear': results.albums[i].date, - 'class': getMediaClass(results.albums[i].uri) - } - if (results.albums[i].artists) { - for (j = 0; j < results.albums[i].artists.length; j++) { - if (results.albums[i].artists[j].name) { - tokens.artistName += results.albums[i].artists[j].name + ' ' + if (results.albums[i].artists) { + for (j = 0; j < results.albums[i].artists.length; j++) { + if (results.albums[i].artists[j].name) { + tokens.artistName += results.albums[i].artists[j].name + ' ' + } } } + if (tokens.albumYear) { + tokens.artistName += '(' + tokens.albumYear + ')' + } + // Add 'Show all' item after a certain number of hits. + if (i === 4 && results.albums.length > 5) { + child += theme(showMorePattern, {'count': results.albums.length - i}) + pattern = pattern.replace('
  • ', '
  • ') + } + + child += theme(pattern, tokens) } - if (tokens.albumYear) { - tokens.artistName += '(' + tokens.albumYear + ')' - } - // Add 'Show all' item after a certain number of hits. - if (i === 4 && results.albums.length > 5) { - child += theme(showMorePattern, {'count': results.albums.length - i}) - pattern = pattern.replace('
  • ', '
  • ') - } + // Inject list items, refresh listview and hide superfluous items. + $(SEARCH_ALBUM_TABLE).html(child).listview('refresh').find('.overflow').hide() - child += theme(pattern, tokens) - } - // Inject list items, refresh listview and hide superfluous items. - $(SEARCH_ALBUM_TABLE).html(child).listview('refresh').find('.overflow').hide() + // Track results + resultsToTables(results.tracks, SEARCH_TRACK_TABLE, URI_SCHEME + ':trackresultscache') - // Track results - resultsToTables(results.tracks, SEARCH_TRACK_TABLE, URI_SCHEME + ':trackresultscache') - - showLoading(false) - }, - -/** ******************************* - * Playlists & Browse - *********************************/ - getPlaylists: function () { - // get playlists without tracks - mopidy.playlists.asList().then(processGetPlaylists, console.error) - }, - - getBrowseDir: function (rootdir) { - // get directory to browse - showLoading(true) - if (!rootdir) { - browseStack.pop() - rootdir = browseStack[browseStack.length - 1] - } else { - browseStack.push(rootdir) - } - if (!rootdir) { - rootdir = null - } - mopidy.library.browse({'uri': rootdir}).then(processBrowseDir, console.error) - }, - - getCurrentPlaylist: function () { - mopidy.tracklist.getTlTracks().then(processCurrentPlaylist, console.error) - }, - -/** ****************************************************** - * Show tracks of playlist - ********************************************************/ - togglePlaylists: function () { - if ($(window).width() <= 960) { - $('#playlisttracksdiv').toggle(); - // Hide other div - ($('#playlisttracksdiv').is(':visible')) ? $('#playlistslistdiv').hide() : $('#playlistslistdiv').show() - } else { - $('#playlisttracksdiv').show() - $('#playlistslistdiv').show() - } - return true - }, - -/** ********** - * Lookups - ************/ - showTracklist: function (uri) { - showLoading(true) - $(PLAYLIST_TABLE).empty() - library.togglePlaylists() - var tracks = getPlaylistTracks(uri).then(function (tracks) { - resultsToTables(tracks, PLAYLIST_TABLE, uri, 'return library.togglePlaylists();', true) showLoading(false) - }) - updatePlayIcons(uri) - $('#playlistslist li a').each(function () { - $(this).removeClass('playlistactive') - if (this.id === uri) { - $(this).addClass('playlistactive') - } - }) - return false - }, + }, - showArtist: function (nwuri) { - $('#popupQueue').popup('close') - $('#popupTracks').popup('close') - $('#controlsmodal').popup('close') - $(ARTIST_TABLE).empty() + /** ******************************* + * Playlists & Browse + *********************************/ + getPlaylists: function () { + // get playlists without tracks + mopidy.playlists.asList().then(processGetPlaylists, console.error) + }, - // TODO cache - $('#h_artistname').html('') - showLoading(true) - mopidy.library.lookup({'uris': [nwuri]}).then(function (resultDict) { - var resultArr = resultDict[nwuri] - resultArr.uri = nwuri - processArtistResults(resultArr) - }, console.error) - switchContent('artists', nwuri) - scrollToTop() - return false - }, - - showAlbum: function (uri) { - $('#popupQueue').popup('close') - $('#popupTracks').popup('close') - $('#controlsmodal').popup('close') - $(ALBUM_TABLE).empty() - // fill from cache - var pl = getTracksFromUri(uri, true) - if (pl.length > 0) { - albumTracksToTable(pl, ALBUM_TABLE, uri) - var albumname = getAlbum(pl) - var artistname = getArtist(pl) - $('#h_albumname').html(albumname) - $('#h_albumartist').html(artistname) - $('#coverpopupalbumname').html(albumname) - $('#coverpopupartist').html(artistname) - showLoading(false) - mopidy.library.lookup({'uris': [uri]}).then(function (resultDict) { - var resultArr = resultDict[uri] - resultArr.uri = uri - processAlbumResults(resultArr) - }, console.error) - } else { + getBrowseDir: function (rootdir) { + // get directory to browse showLoading(true) - $('#h_albumname').html('') - $('#h_albumartist').html('') - mopidy.library.lookup({'uris': [uri]}).then(function (resultDict) { - var resultArr = resultDict[uri] - resultArr.uri = uri - processAlbumResults(resultArr) + if (!rootdir) { + browseStack.pop() + rootdir = browseStack[browseStack.length - 1] + } else { + browseStack.push(rootdir) + } + if (!rootdir) { + rootdir = null + } + mopidy.library.browse({'uri': rootdir}).then(processBrowseDir, console.error) + }, + + getCurrentPlaylist: function () { + mopidy.tracklist.getTlTracks().then(processCurrentPlaylist, console.error) + }, + + /** ****************************************************** + * Show tracks of playlist + ********************************************************/ + togglePlaylists: function () { + if ($(window).width() <= 960) { + $('#playlisttracksdiv').toggle(); + // Hide other div + ($('#playlisttracksdiv').is(':visible')) ? $('#playlistslistdiv').hide() : $('#playlistslistdiv').show() + } else { + $('#playlisttracksdiv').show() + $('#playlistslistdiv').show() + } + return true + }, + + /** ********** + * Lookups + ************/ + showTracklist: function (uri) { + showLoading(true) + $(PLAYLIST_TABLE).empty() + library.togglePlaylists() + var tracks = getPlaylistTracks(uri).then(function (tracks) { + resultsToTables(tracks, PLAYLIST_TABLE, uri, 'return library.togglePlaylists();', true) + showLoading(false) + }) + updatePlayIcons(uri) + $('#playlistslist li a').each(function () { + $(this).removeClass('playlistactive') + if (this.id === uri) { + $(this).addClass('playlistactive') + } + }) + return false + }, + + showArtist: function (nwuri) { + $('#popupQueue').popup('close') + $('#popupTracks').popup('close') + $('#controlsmodal').popup('close') + $(ARTIST_TABLE).empty() + + // TODO cache + $('#h_artistname').html('') + showLoading(true) + mopidy.library.lookup({'uris': [nwuri]}).then(function (resultDict) { + var resultArr = resultDict[nwuri] + resultArr.uri = nwuri + processArtistResults(resultArr) + }, console.error) + switchContent('artists', nwuri) + scrollToTop() + return false + }, + + showAlbum: function (uri) { + $('#popupQueue').popup('close') + $('#popupTracks').popup('close') + $('#controlsmodal').popup('close') + $(ALBUM_TABLE).empty() + // fill from cache + var pl = getTracksFromUri(uri, true) + if (pl.length > 0) { + albumTracksToTable(pl, ALBUM_TABLE, uri) + var albumname = getAlbum(pl) + var artistname = getArtist(pl) + $('#h_albumname').html(albumname) + $('#h_albumartist').html(artistname) + $('#coverpopupalbumname').html(albumname) + $('#coverpopupartist').html(artistname) + showLoading(false) + mopidy.library.lookup({'uris': [uri]}).then(function (resultDict) { + var resultArr = resultDict[uri] + resultArr.uri = uri + processAlbumResults(resultArr) + }, console.error) + } else { + showLoading(true) + $('#h_albumname').html('') + $('#h_albumartist').html('') + mopidy.library.lookup({'uris': [uri]}).then(function (resultDict) { + var resultArr = resultDict[uri] + resultArr.uri = uri + processAlbumResults(resultArr) + }, console.error) + } + // show page + switchContent('albums', uri) + scrollToTop() + return false + }, + + getSearchSchemes: function (searchBlacklist, mopidy) { + var backendName + var searchScheme = $.cookie('searchScheme') + if (searchScheme) { + searchScheme = searchScheme.replace(/"/g, '') + } else { + searchScheme = 'all' + } + $('#selectSearchService').empty() + $('#selectSearchService').append(new Option('All services', 'all')) + mopidy.getUriSchemes().then(function (schemesArray) { + schemesArray = schemesArray.filter(function (el) { + return searchBlacklist.indexOf(el) < 0 + }) + for (var i = 0; i < schemesArray.length; i++) { + backendName = getMediaHuman(schemesArray[i]) + backendName = backendName.charAt(0).toUpperCase() + backendName.slice(1) + $('#selectSearchService').append(new Option(backendName, schemesArray[i])) + } + $('#selectSearchService').val(searchScheme) + $('#selectSearchService').selectmenu('refresh', true) }, console.error) } - // show page - switchContent('albums', uri) - scrollToTop() - return false - }, - - getSearchSchemes: function () { - var backendName - var searchScheme = $.cookie('searchScheme') - if (searchScheme) { - searchScheme = searchScheme.replace(/"/g, '') - } else { - searchScheme = 'all' - } - $('#selectSearchService').empty() - $('#selectSearchService').append(new Option('All services', 'all')) - mopidy.getUriSchemes().then(function (schemesArray) { - schemesArray = schemesArray.filter(function (el) { - return searchBlacklist.indexOf(el) < 0 - }) - for (var i = 0; i < schemesArray.length; i++) { - backendName = getMediaHuman(schemesArray[i]) - backendName = backendName.charAt(0).toUpperCase() + backendName.slice(1) - $('#selectSearchService').append(new Option(backendName, schemesArray[i])) - } - $('#selectSearchService').val(searchScheme) - $('#selectSearchService').selectmenu('refresh', true) - }, console.error) } -} - -// TODO: Remove this once JavaScript codebase has been completely modularized -// in favour of bundling everything using 'browserify'. -if (typeof exports !== 'undefined') { - if (typeof module !== 'undefined' && module.exports) { - module.exports = library - } -} + return library +})) diff --git a/mopidy_musicbox_webclient/static/js/process_ws.js b/mopidy_musicbox_webclient/static/js/process_ws.js index bbbb7d4..a3ccba3 100644 --- a/mopidy_musicbox_webclient/static/js/process_ws.js +++ b/mopidy_musicbox_webclient/static/js/process_ws.js @@ -220,7 +220,7 @@ function processCurrentPlaylist (resultArr) { function processArtistResults (resultArr) { if (!resultArr || (resultArr.length === 0)) { $('#h_artistname').text('Artist not found...') - coverArt.getCover('', '#artistviewimage, #artistpopupimage', 'extralarge') + coverArt.getCover('', '#artistviewimage, #artistpopupimage', 'extralarge', mopidy) showLoading(false) return } @@ -239,7 +239,7 @@ function processArtistResults (resultArr) { function processAlbumResults (resultArr) { if (!resultArr || (resultArr.length === 0)) { $('#h_albumname').text('Album not found...') - coverArt.getCover('', '#albumviewcover, #coverpopupimage', 'extralarge') + coverArt.getCover('', '#albumviewcover, #coverpopupimage', 'extralarge', mopidy) showLoading(false) return } diff --git a/mopidy_musicbox_webclient/static/js/progress_timer.js b/mopidy_musicbox_webclient/static/js/progress_timer.js deleted file mode 100644 index 00ff6e9..0000000 --- a/mopidy_musicbox_webclient/static/js/progress_timer.js +++ /dev/null @@ -1,177 +0,0 @@ -var progressTimer -var positionNode = document.createTextNode('') -var durationNode = document.createTextNode('') - -var START_BEATS = 5 // 0.5 seconds, needs to be less than 1s to prevent unwanted updates. -var RUN_BEATS = 300 // 30 seconds assuming default timer update rate of 100ms -var callbackHeartbeats = 0 // Timer will check syncs on every n-number of calls. -var targetPosition = null - -var MAX_SYNCS = 5 // Maximum number of consecutive successful syncs to perform. -var syncsLeft = MAX_SYNCS -var synced = false -var consecutiveSyncs = 0 - -$(document).ready(function () { - $('#songelapsed').append(positionNode) - $('#songlength').append(durationNode) -}) - -function timerCallback (position, duration, isRunning) { - updateTimers(position, duration, isRunning) - - if (callbackHeartbeats === 0) { - callbackHeartbeats = getHeartbeat() - } - - if (mopidy && position > 0) { - // Mopidy and timer are both initialized. - if (callbackHeartbeats-- === 1) { - // Get time position from Mopidy on every nth callback until - // synced. - mopidy.playback.getTimePosition().then( - function (mopidy_position) { - syncTimer(position, mopidy_position) - } - ) - } - } -} - -function updateTimers (position, duration, isRunning) { - var ready = !(duration === Infinity && position === 0 && !isRunning) // Timer has been properly initialized. - var streaming = (duration === Infinity && position > 0) // Playing a stream. - var ok = synced && isRunning // Normal operation. - var syncing = !synced && isRunning // Busy syncing. - - if (!ready) { - // Make sure that default values are displayed while the timer is being initialized. - positionNode.nodeValue = '' - durationNode.nodeValue = '' - $('#trackslider').val(0).slider('refresh') - } else { - durationNode.nodeValue = format(duration || Infinity) - if (syncing) { - if (!targetPosition) { - // Waiting for Mopidy to provide a target position. - positionNode.nodeValue = '(wait)' - } else { - // Busy seeking to new target position. - positionNode.nodeValue = '(sync)' - } - } else if (synced || streaming) { - positionNode.nodeValue = format(position) - } - } - - if (ok) { - // Don't update the track slider unless it is synced and running. - // (prevents awkward 'jitter' animation). - $('#trackslider').val(position).slider('refresh') - } -} - -function getHeartbeat () { - if (syncsLeft > 0 && callbackHeartbeats === 0) { - // Step back exponentially while increasing heartbeat. - return Math.round(delay_exponential(5, 2, MAX_SYNCS - syncsLeft)) - } else if (syncsLeft === 0 && callbackHeartbeats === 0) { - // Sync completed, keep checking using maximum number of heartbeats. - return RUN_BEATS - } else { - return START_BEATS - } -} - -function syncTimer (current, target) { - if (target) { - var drift = Math.abs(target - current) - if (drift <= 500) { - syncsLeft-- - // Less than 500ms == in sync. - if (++consecutiveSyncs === 2) { - // Need at least two consecutive syncs to know that Mopidy - // is progressing playback and we are in sync. - synced = true - targetPosition = null - consecutiveSyncs = 0 - } - } else { - // Drift is too large, re-sync with Mopidy. - reset() - targetPosition = target - progressTimer.set(targetPosition) - } - } -} - -function toInt (value) { - return value.match(/^\w*\d+\w*$/) ? parseInt(value) : null -} - -function format (milliseconds) { - if (milliseconds === Infinity) { - return '(n/a)' - } else if (milliseconds === 0) { - return '0:00' - } - - var seconds = Math.floor(milliseconds / 1000) - var minutes = Math.floor(seconds / 60) - seconds = seconds % 60 - - seconds = seconds < 10 ? '0' + seconds : seconds - return minutes + ':' + seconds -} - -function delay_exponential (base, growthFactor, attempts) { - /* Calculate number of beats between syncs based on exponential function. - The format is:: - - base * growthFactor ^ (attempts - 1) - - If ``base`` is set to 'rand' then a random number between - 0 and 1 will be used as the base. - Base must be greater than 0. - */ - if (base === 'rand') { - base = Math.random() - } - beats = base * (Math.pow(growthFactor, (attempts - 1))) - return beats -} - -function reset () { - synced = false - consecutiveSyncs = 0 - syncsLeft = MAX_SYNCS - callbackHeartbeats = START_BEATS - targetPosition = null -} - -function setProgressTimer (pos) { - reset() - targetPosition = pos - progressTimer.set(pos) - if (!play) { - // Set lapsed time and slider position directly as timer callback is not currently - // running. - positionNode.nodeValue = format(pos) - $('#trackslider').val(pos).slider('refresh') - } -} - -function updatePosition (pos) { - positionNode.nodeValue = format(pos) -} - -function startProgressTimer () { - reset() - progressTimer.start() -} - -function resetProgressTimer () { - progressTimer.reset() - reset() - targetPosition = 0 -} diff --git a/mopidy_musicbox_webclient/static/js/synced_timer.js b/mopidy_musicbox_webclient/static/js/synced_timer.js new file mode 100644 index 0000000..25b2eb9 --- /dev/null +++ b/mopidy_musicbox_webclient/static/js/synced_timer.js @@ -0,0 +1,218 @@ +(function (root, factory) { + if (typeof define === 'function' && define.amd) { + define([], factory) + } else if (typeof module === 'object' && module.exports) { + module.exports = factory() + } else { + root.SyncedProgressTimer = factory() + } +}(this, function () { + 'use strict' + + function delay_exponential (base, growthFactor, attempts) { + /* Calculate number of beats between syncs based on exponential function. + The format is:: + + base * growthFactor ^ (attempts - 1) + + If ``base`` is set to 'rand' then a random number between + 0 and 1 will be used as the base. + Base must be greater than 0. + */ + if (base === 'rand') { + base = Math.random() + } + // console.log(base + ' * (Math.pow(' + growthFactor + ', (' + attempts + ' - 1)) = ' + base * (Math.pow(growthFactor, (attempts - 1)))) + return base * (Math.pow(growthFactor, (attempts - 1))) + } + + function SyncedProgressTimer (maxAttempts, mopidy) { + if (!(this instanceof SyncedProgressTimer)) { + return new SyncedProgressTimer(maxAttempts, mopidy) + } + + this.positionNode = document.createTextNode('') + this.durationNode = document.createTextNode('') + + $('#songelapsed').append(this.positionNode) + $('#songlength').append(this.durationNode) + + this._progressTimer = new ProgressTimer({ + // Make sure that the timer object's context is available. + callback: $.proxy(this.timerCallback, this) + }) + + this._maxAttempts = maxAttempts + this._mopidy = mopidy + this._isConnected = false + this._mopidy.on('state:online', $.proxy(function () { this._isConnected = true }), this) + this._mopidy.on('state:offline', $.proxy(function () { this._isConnected = false }), this) + this.init() + } + + SyncedProgressTimer.SYNC_STATE = { + NOT_SYNCED: 0, + SYNCING: 1, + SYNCED: 2 + } + + SyncedProgressTimer.format = function (milliseconds) { + if (milliseconds === Infinity) { + return '(n/a)' + } else if (milliseconds === 0) { + return '0:00' + } + + var seconds = Math.floor(milliseconds / 1000) + var minutes = Math.floor(seconds / 60) + seconds = seconds % 60 + + seconds = seconds < 10 ? '0' + seconds : 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) { + this._update(position, duration) + + if (this._isConnected && this._isSyncScheduled()) { + this._doSync(position, duration) + } + } + + SyncedProgressTimer.prototype._update = function (position, duration) { + if (!(duration === Infinity && position === 0)) { + // Timer has been properly initialized. + this.durationNode.nodeValue = SyncedProgressTimer.format(duration || Infinity) + switch (this.syncState) { + case SyncedProgressTimer.SYNC_STATE.NOT_SYNCED: + // Waiting for Mopidy to provide a target position. + this.positionNode.nodeValue = '(wait)' + break + case SyncedProgressTimer.SYNC_STATE.SYNCING: + // Busy seeking to new target position. + this.positionNode.nodeValue = '(sync)' + break + case SyncedProgressTimer.SYNC_STATE.SYNCED: + this._previousSyncPosition = position + 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 () { + return this._scheduledSyncTime !== null && this._scheduledSyncTime <= new Date().getTime() + } + + SyncedProgressTimer.prototype._doSync = function (position, duration) { + var ready = !(duration === Infinity && position === 0) // Timer has been properly initialized. + if (!ready) { + // Don't try to sync if progress timer has not been initialized yet. + return + } + var _this = this + _this._mopidy.playback.getTimePosition().then(function (targetPosition) { + if (Math.abs(targetPosition - position) <= 500) { + // Less than 500ms == in sync. + _this.syncState = SyncedProgressTimer.SYNC_STATE.SYNCING + _this._syncAttemptsRemaining = Math.max(_this._syncAttemptsRemaining - 1, 0) + if (_this._syncAttemptsRemaining < _this._maxAttempts - 1 && _this._previousSyncPosition !== targetPosition) { + // Need at least two consecutive syncs to know that Mopidy + // is progressing playback and we are in sync. + _this.syncState = SyncedProgressTimer.SYNC_STATE.SYNCED + } + _this._previousSyncPosition = targetPosition + // Step back exponentially while increasing number of callbacks. + _this._scheduledSyncTime = new Date().getTime() + + delay_exponential(0.25, 2, _this._maxAttempts - _this._syncAttemptsRemaining) * 1000 + } else { + // 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._progressTimer.set(targetPosition) + } + }) + } + + SyncedProgressTimer.prototype.set = function (position, duration) { + if (arguments.length === 0) { + throw new Error('"SyncedProgressTimer.set" requires the "position" argument.') + } + // Workaround for https://github.com/adamcik/media-progress-timer/issues/3 + // This causes the timer to die unexpectedly if the position exceeds + // the duration slightly. + if (this._duration && this._duration < position) { + position = this._duration - 1 + } + this.init() + if (arguments.length === 1) { + this._progressTimer.set(position) + } else { + this._duration = duration + this._progressTimer.set(position, duration) + } + + if (!this._isSyncScheduled()) { + // Set lapsed time and slider position directly as timer callback is not currently + // running. + this.positionNode.nodeValue = SyncedProgressTimer.format(position) + if (arguments.length === 2) { + this.durationNode.nodeValue = SyncedProgressTimer.format(duration) + } + $('#trackslider').val(position).slider('refresh') + } + + return this + } + + SyncedProgressTimer.prototype.start = function () { + this.syncState = SyncedProgressTimer.SYNC_STATE.NOT_SYNCED + this._scheduledSyncTime = new Date().getTime() + this._progressTimer.start() + return this + } + + SyncedProgressTimer.prototype.stop = function () { + this._progressTimer.stop() + this._scheduledSyncTime = null + this.updatePosition(this._previousSyncPosition) + return this + } + + SyncedProgressTimer.prototype.reset = function () { + this._progressTimer.reset() + this.stop() + this.init() + this.set(0, Infinity) + + return this + } + + SyncedProgressTimer.prototype.updatePosition = function (position) { + this.positionNode.nodeValue = SyncedProgressTimer.format(position) + } + + return SyncedProgressTimer +})) diff --git a/mopidy_musicbox_webclient/static/mb.appcache b/mopidy_musicbox_webclient/static/mb.appcache index c825720..246f924 100644 --- a/mopidy_musicbox_webclient/static/mb.appcache +++ b/mopidy_musicbox_webclient/static/mb.appcache @@ -1,6 +1,6 @@ CACHE MANIFEST -# 2016-03-28:v1 +# 2016-04-03:v1 NETWORK: * @@ -26,7 +26,7 @@ js/gui.js js/images.js js/library.js js/process_ws.js -js/progress_timer.js +js/synced_timer.js mb.appcache system.html vendors/font_awesome/css/font-awesome.css diff --git a/mopidy_musicbox_webclient/static/vendors/media_progress_timer/timer.js b/mopidy_musicbox_webclient/static/vendors/media_progress_timer/timer.js index 138605f..4592fc0 100644 --- a/mopidy_musicbox_webclient/static/vendors/media_progress_timer/timer.js +++ b/mopidy_musicbox_webclient/static/vendors/media_progress_timer/timer.js @@ -1,128 +1,159 @@ -/*! timer.js v2.0.2 +/*! timer.js v3.0.0 * https://github.com/adamcik/media-progress-timer - * Copyright (c) 2015 Thomas Adamcik + * Copyright (c) 2015-2016 Thomas Adamcik * Licensed under the Apache License, Version 2.0 */ -(function() { - -'use strict'; - -var now = typeof window.performance !== 'undefined' && - typeof window.performance.now !== 'undefined' && - window.performance.now.bind(window.performance) || Date.now || - function() { return new Date().getTime(); }; - -function ProgressTimer(options) { - if (!(this instanceof ProgressTimer)) { - return new ProgressTimer(options); - } else if (typeof options === 'function') { - options = {'callback': options}; - } else if (typeof options !== 'object') { - throw 'ProgressTimer must be called with a callback or options.'; - } else if (typeof options.callback !== 'function') { - throw 'ProgressTimer needs a callback to operate.'; - } - - this._running = false; - this._updateRate = Math.max(options.updateRate || 100, 10); - this._callback = options.callback; - this._fallback = typeof window.requestAnimationFrame === 'undefined' || - options.disableRequestAnimationFrame|| false; - - if (!this._fallback) { - this._callUpdate = this._scheduleAnimationFrame; - this._scheduleUpdate = this._scheduleAnimationFrame; - } - - this._boundCallUpdate = this._callUpdate.bind(this); - this._boundUpdate = this._update.bind(this); - - this.reset(); -} - -ProgressTimer.prototype.set = function(position, duration) { - if (arguments.length === 0) { - throw 'set requires at least a position argument.'; - } else if (arguments.length === 1) { - duration = this._state.duration; +(function (root, factory) { + if (typeof define === 'function' && define.amd) { + define([], factory); + } else if (typeof module === 'object' && module.exports) { + module.exports = factory(); } else { - duration = Math.floor( - Math.max(duration === null ? Infinity : duration || 0, 0)); + root.ProgressTimer = factory(); } - position = Math.floor(Math.min(Math.max(position || 0, 0), duration)); +}(this, function () { + 'use strict'; - this._state = { - initialTimestamp: null, - initialPosition: position, - previousPosition: position, - duration: duration + // Helper function to provide a reference time in milliseconds. + var now = typeof window.performance !== 'undefined' && + typeof window.performance.now !== 'undefined' && + window.performance.now.bind(window.performance) || Date.now || + function() { return new Date().getTime(); }; + + // Helper to warn library users about deprecated features etc. + function warn(msg) { + window.setTimeout(function() { throw msg; }, 0); + } + + // Creates a new timer object, works with both 'new ProgressTimer(options)' + // and just 'ProgressTimer(options). Optionally the timer can also be + // called with only the callback instead of options. + function ProgressTimer(options) { + if (!(this instanceof ProgressTimer)) { + return new ProgressTimer(options); + } else if (typeof options === 'function') { + options = {'callback': options}; + } else if (typeof options !== 'object') { + throw '"ProgressTimer" must be called with a callback or options.'; + } else if (typeof options['callback'] !== 'function') { + throw '"ProgressTimer" needs a callback to operate.'; + } + + this._userCallback = options['callback']; + this._updateId = null; + this._state = null; // Gets initialized by the set() call. + + var frameDuration = 1000 / (options['fallbackTargetFrameRate'] || 30); + // TODO: Remove this legacy code path at some point. + if (options['updateRate'] && !options['fallbackTargetFrameRate']) { + warn('"ProgressTimer" no longer supports the updateRate option.'); + frameDuration = Math.max(options['updateRate'], 1000 / 60); + } + + var useFallback = ( + typeof window.requestAnimationFrame === 'undefined' || + typeof window.cancelAnimationFrame === 'undefined' || + options['disableRequestAnimationFrame'] || false); + + // Make sure this works in _update. + var update = this._update.bind(this); + + if (useFallback) { + this._schedule = function(timestamp) { + var timeout = Math.max(timestamp + frameDuration - now(), 0); + return window.setTimeout(update, Math.floor(timeout)); + }; + this._cancel = window.clearTimeout.bind(window); + } else { + this._schedule = window.requestAnimationFrame.bind(window, update); + this._cancel = window.cancelAnimationFrame.bind(window); + } + + this.reset(); // Reuse reset code to ensure we start in the same state. + } + + // If called with one argument the previous duration is preserved. Note + // that the position can be changed while the timer is running. + ProgressTimer.prototype.set = function(position, duration) { + if (arguments.length === 0) { + throw '"ProgressTimer.set" requires the "position" arugment.'; + } else if (arguments.length === 1) { + // Fallback to previous duration, whatever that was. + duration = this._state.duration; + } else { + // Round down and make sure zero and null are treated as inf. + duration = Math.floor(Math.max( + duration === null ? Infinity : duration || Infinity, 0)); + } + // Make sure '0 <= position <= duration' always holds. + position = Math.floor(Math.min(Math.max(position || 0, 0), duration)); + + this._state = { + initialTimestamp: null, + initialPosition: position, + position: position, + duration: duration + }; + + // Update right away if we don't have anything running. + if (this._updateId === null) { + // TODO: Consider wrapping this in a try/catch? + this._userCallback(position, duration); + } + return this; }; - this._callback(position, duration, this._running); - return this; -}; - -ProgressTimer.prototype.start = function() { - this._running = true; - this._callUpdate(); - return this; -}; - -ProgressTimer.prototype.stop = function() { - this._running = false; - var state = this._state; - return this.set(state.previousPosition, state.duration); -}; - -ProgressTimer.prototype.reset = function() { - this._running = false; - return this.set(0, Infinity); -}; - -ProgressTimer.prototype._callUpdate = function() { - this._update(now()); -}; - -ProgressTimer.prototype._scheduleUpdate = function(timestamp) { - var adjustedTimeout = timestamp + this._updateRate - now(); - setTimeout(this._boundCallUpdate, adjustedTimeout); -}; - -ProgressTimer.prototype._scheduleAnimationFrame = function() { - window.requestAnimationFrame(this._boundUpdate); -}; - -ProgressTimer.prototype._update = function(timestamp) { - if (!this._running) { - return; - } - - var state = this._state; - state.initialTimestamp = state.initialTimestamp || timestamp; - - var position = state.initialPosition + timestamp - state.initialTimestamp; - var duration = state.duration; - - if (position < duration || duration === null) { - var delta = position - state.previousPosition; - if (delta >= this._updateRate || this._fallback) { - this._callback(Math.floor(position), duration, this._running); - state.previousPosition = position; + // Start the timer if it is not already running. + ProgressTimer.prototype.start = function() { + if (this._updateId === null) { + this._updateId = this._schedule(0); } - } else { - // Workaround for https://github.com/adamcik/media-progress-timer/issues/3 - // This causes the timer to die unexpectedly if the position goes - // over the duration slightly. - // this._running = false; - this._callback(duration, duration, this._running); - } - this._scheduleUpdate(timestamp); -}; + return this; + }; -if(typeof module !== 'undefined') { - module.exports = ProgressTimer; -} else { - window.ProgressTimer = ProgressTimer; -} + // Cancel the timer if it us currently tracking progress. + ProgressTimer.prototype.stop = function() { + if (this._updateId !== null) { + this._cancel(this._updateId); -}()); \ No newline at end of file + // Ensure we correctly reset the initial position and timestamp. + this.set(this._state.position, this._state.duration); + this._updateId = null; // Last step to avoid callback in set() + } + return this; + }; + + // Marks the timer as stopped, sets position to zero and duration to inf. + ProgressTimer.prototype.reset = function() { + return this.stop().set(0, Infinity); + }; + + // Calls the user callback with the current position/duration and then + // schedules the next update run via _schedule if we haven't finished. + ProgressTimer.prototype._update = function(timestamp) { + var state = this._state; // We refer a lot to state, this is shorter. + + // Make sure setTimeout has a timestamp and store first reference time. + timestamp = timestamp || now(); + state.initialTimestamp = state.initialTimestamp || timestamp; + + // Recalculate position according to start location and reference. + state.position = ( + state.initialPosition + timestamp - state.initialTimestamp); + + // Ensure callback gets an integer and that 'position <= duration'. + var userPosisition = Math.min( + Math.floor(state.position), state.duration); + + // TODO: Consider wrapping this in a try/catch? + this._userCallback(userPosisition, state.duration); + + if (state.position < state.duration) { + this._updateId = this._schedule(timestamp); // Schedule update. + } else { + this._updateId = null; // Unset since we didn't reschedule. + } + }; + + return ProgressTimer; +})); \ No newline at end of file diff --git a/tests/test_images.js b/tests/js/test_images.js similarity index 71% rename from tests/test_images.js rename to tests/js/test_images.js index 0d8769f..4cd6ab8 100644 --- a/tests/test_images.js +++ b/tests/js/test_images.js @@ -1,5 +1,4 @@ var chai = require('chai') -var should = chai.should() var expect = chai.expect var assert = chai.assert chai.use(require('chai-string')) @@ -7,24 +6,20 @@ chai.use(require('chai-jquery')) var sinon = require('sinon') -var coverArt = require('../mopidy_musicbox_webclient/static/js/images.js') - -var images - -before(function () { - mopidy = sinon.stub(new Mopidy({callingConvention: 'by-position-or-by-name'})) - images = $('') -}) +var coverArt = require('../../mopidy_musicbox_webclient/static/js/images.js') describe('CoverArt', function () { + var mopidy + var images + beforeEach(function () { + mopidy = sinon.stub(new Mopidy({callingConvention: 'by-position-or-by-name'})) + images = $('') + $(images).removeAttr('src') + }) describe('#getCover()', function () { - beforeEach(function () { - $(images).removeAttr('src') - }) - it('should use default image if no track URI is provided', function () { - coverArt.getCover('', images, '') - $(images).prop('src').should.endWith('images/default_cover.png') + coverArt.getCover('', images, '', mopidy) + expect($(images).prop('src')).to.endWith('images/default_cover.png') }) it('should get image from Mopidy, if available', function () { @@ -33,10 +28,10 @@ describe('CoverArt', function () { mopidy.library = library var getImagesSpy = sinon.spy(mopidy.library, 'getImages') - coverArt.getCover('mock:track:uri', images, '') + coverArt.getCover('mock:track:uri', images, '', mopidy) - assert(getImagesSpy.calledOnce) - $(images).prop('src').should.endWith('mockImageUri') + assert.isTrue(getImagesSpy.calledOnce) + expect($(images).prop('src')).to.endWith('mockImageUri') }) it('should fall back to retrieving image from deprecated track.album.images', function () { @@ -51,21 +46,17 @@ describe('CoverArt', function () { var getImagesSpy = sinon.spy(mopidy.library, 'getImages') var getCoverFromAlbumSpy = sinon.spy(coverArt, 'getCoverFromAlbum') - coverArt.getCover('mock:track:uri', images, '') + coverArt.getCover('mock:track:uri', images, '', mopidy) - assert(getImagesSpy.calledOnce) - assert(getCoverFromAlbumSpy.calledOnce) + assert.isTrue(getImagesSpy.calledOnce) + assert.isTrue(getCoverFromAlbumSpy.calledOnce) }) }) describe('#getCoverFromAlbum()', function () { - beforeEach(function () { - $(images).removeAttr('src') - }) - it('should use default image if no track URI is provided', function () { - coverArt.getCoverFromAlbum('', images, '') - $(images).prop('src').should.endWith('images/default_cover.png') + coverArt.getCoverFromAlbum('', images, '', mopidy) + expect($(images).prop('src')).to.endWith('images/default_cover.png') }) it('should get image from Mopidy track.album.images, if available', function () { @@ -76,10 +67,10 @@ describe('CoverArt', function () { mopidy.library = library var lookupSpy = sinon.spy(mopidy.library, 'lookup') - coverArt.getCoverFromAlbum('mock:track:uri', images, '') + coverArt.getCoverFromAlbum('mock:track:uri', images, '', mopidy) - assert(lookupSpy.calledOnce) - $(images).prop('src').should.endWith('mockAlbumImageUri') + assert.isTrue(lookupSpy.calledOnce) + expect($(images).prop('src')).to.endWith('mockAlbumImageUri') }) it('should use default image if track.album or track.artist is not available', function () { @@ -90,10 +81,10 @@ describe('CoverArt', function () { mopidy.library = library var lookupSpy = sinon.spy(mopidy.library, 'lookup') - coverArt.getCoverFromAlbum('mock:track:uri', images, '') + coverArt.getCoverFromAlbum('mock:track:uri', images, '', mopidy) - assert(lookupSpy.calledOnce) - $(images).prop('src').should.endWith('images/default_cover.png') + assert.isTrue(lookupSpy.calledOnce) + expect($(images).prop('src')).to.endWith('images/default_cover.png') }) it('should fall back to retrieving image from last.fm if none provided by Mopidy', function () { @@ -103,21 +94,18 @@ describe('CoverArt', function () { } mopidy.library = library - var getCoverFromLastFmSpy = sinon.spy(coverArt, 'getCoverFromLastFm') - coverArt.getCoverFromAlbum('mock:track:uri', images, '') + var getCoverFromLastFmStub = sinon.stub(coverArt, 'getCoverFromLastFm') + coverArt.getCoverFromAlbum('mock:track:uri', images, '', mopidy) - assert(getCoverFromLastFmSpy.calledOnce) + assert.isTrue(getCoverFromLastFmStub.calledOnce) + getCoverFromLastFmStub.restore() }) }) describe('#getCoverFromLastFm()', function () { - beforeEach(function () { - $(images).removeAttr('src') - }) - it('should use default image if no track is provided', function () { coverArt.getCoverFromLastFm(undefined, images, '') - $(images).prop('src').should.endWith('images/default_cover.png') + expect($(images).prop('src')).to.endWith('images/default_cover.png') }) it('should fall back to using track artist if album artist is not available', function () { @@ -129,7 +117,7 @@ describe('CoverArt', function () { coverArt.getCoverFromLastFm(track, images, '') var args = getInfoStub.args - assert(args[0][0].artist === 'artistMock') + assert.equal(args[0][0].artist, 'artistMock') getInfoStub.restore() }) @@ -141,7 +129,7 @@ describe('CoverArt', function () { getInfoStub.yieldsTo('success', getInfoResultMock) coverArt.getCoverFromLastFm(track, images, 'small') - $(images).prop('src').should.endWith('mockAlbumImageUri') + expect($(images).prop('src')).to.endWith('mockAlbumImageUri') getInfoStub.restore() }) @@ -150,23 +138,19 @@ describe('CoverArt', function () { var getInfoStub = sinon.stub(coverArt.lastfm.album, 'getInfo') getInfoStub.yieldsTo('error', 'code', 'message') - var consoleSpy = sinon.spy(console, 'error') + var consoleStub = sinon.stub(console, 'error') coverArt.getCoverFromLastFm(track, images, '') - assert(consoleSpy.calledOnce) + assert.isTrue(consoleStub.calledOnce) getInfoStub.restore() - consoleSpy.restore() + consoleStub.restore() }) }) describe('#getArtistImage()', function () { - beforeEach(function () { - $(images).removeAttr('src') - }) - it('should use default image if no artist is provided', function () { coverArt.getArtistImage('', images, '') - $(images).prop('src').should.endWith('images/user_24x32.png') + expect($(images).prop('src')).to.endWith('images/user_24x32.png') }) it('should get artist info from last.fm', function () { @@ -176,7 +160,7 @@ describe('CoverArt', function () { getInfoStub.yieldsTo('success', getInfoResultMock) coverArt.getArtistImage('mockArtist', images, 'small') - $(images).prop('src').should.endWith('mockArtistImageUri') + expect($(images).prop('src')).to.endWith('mockArtistImageUri') getInfoStub.restore() }) @@ -184,12 +168,12 @@ describe('CoverArt', function () { var getInfoStub = sinon.stub(coverArt.lastfm.artist, 'getInfo') getInfoStub.yieldsTo('error', 'code', 'message') - var consoleSpy = sinon.spy(console, 'error') + var consoleStub = sinon.stub(console, 'error') coverArt.getArtistImage('mockArtist', images, 'small') - assert(consoleSpy.calledOnce) + assert.isTrue(consoleStub.calledOnce) getInfoStub.restore() - consoleSpy.restore() + consoleStub.restore() }) }) }) diff --git a/tests/js/test_library.js b/tests/js/test_library.js new file mode 100644 index 0000000..47c2637 --- /dev/null +++ b/tests/js/test_library.js @@ -0,0 +1,56 @@ +var chai = require('chai') +var expect = chai.expect +var assert = chai.assert +chai.use(require('chai-string')) +chai.use(require('chai-jquery')) + +var sinon = require('sinon') + +var library = require('../../mopidy_musicbox_webclient/static/js/library.js') + +describe('Library', function () { + var selectID = '#selectSearchService' + var schemesArray = ['mockScheme1', 'mockScheme2', 'mockScheme3'] + var mopidy = { getUriSchemes: function () { return $.when(schemesArray) } } + + before(function () { + $(document.body).append('') + $('#selectSearchService').selectmenu() + }) + describe('#getSearchSchemes()', function () { + beforeEach(function () { + $(selectID).empty() + }) + + it('should add human-readable options for backend schemes', function () { + uriHumanList = [['mockScheme2', 'mockUriHuman2']] + + library.getSearchSchemes([], mopidy) + assert.equal($(selectID).children().length, schemesArray.length + 1) + expect($(selectID).children(':eq(2)')).to.have.text('MockUriHuman2') + }) + + it('should get default value from cookie', function () { + $.cookie('searchScheme', 'mockScheme3') + library.getSearchSchemes([], mopidy) + expect($(selectID + ' option:selected')).to.have.value('mockScheme3') + }) + + it('should default to "all" backends if no cookie is available', function () { + $.removeCookie('searchScheme') + library.getSearchSchemes([], mopidy) + expect($(selectID + ' option:selected')).to.have.value('all') + }) + + it('should capitalize first character of backend schema', function () { + library.getSearchSchemes([], mopidy) + expect($(selectID).children(':eq(1)')).to.have.text('MockScheme1') + }) + + it('should blacklist services that should not be searched', function () { + library.getSearchSchemes(['mockScheme2'], mopidy) + assert.equal($(selectID).children().length, schemesArray.length) + expect($(selectID).children()).not.to.contain('mockScheme2') + }) + }) +}) diff --git a/tests/js/test_synced_timer.js b/tests/js/test_synced_timer.js new file mode 100644 index 0000000..458c294 --- /dev/null +++ b/tests/js/test_synced_timer.js @@ -0,0 +1,352 @@ +var chai = require('chai') +var expect = chai.expect +var assert = chai.assert +chai.use(require('chai-string')) +chai.use(require('chai-jquery')) + +var sinon = require('sinon') + +var SyncedProgressTimer = require('../../mopidy_musicbox_webclient/static/js/synced_timer.js') + +describe('SyncedTimer', function () { + var MAX_ATTEMPTS = 8 + var syncedProgressTimer + var mopidy + var playback + var getTimePositionStub + before(function () { + $(document.body).append( + '
    ' + + '' + + '' + + '' + + '' + + '
    ' + ) + $('#trackslider').slider() // Initialize slider + }) + beforeEach(function () { + playback = { + getTimePosition: function () { return $.when(1000) }, + getState: function () { return $.when('stopped') } + } + mopidy = new Mopidy({callingConvention: 'by-position-or-by-name'}) + mopidy.playback = playback + getTimePositionStub = sinon.stub(playback, 'getTimePosition') + // Simulate Mopidy's track position advancing 10ms between each call. + for (var i = 0; i < MAX_ATTEMPTS; i++) { + getTimePositionStub.onCall(i).returns($.when(1000 + i * 10)) + } + mopidy = sinon.stub(mopidy) + syncedProgressTimer = new SyncedProgressTimer(MAX_ATTEMPTS, mopidy) + syncedProgressTimer._isConnected = true + }) + afterEach(function () { + getTimePositionStub.restore() + }) + describe('#SyncedTimer()', function () { + it('should add text nodes to DOM for position and duration indicators', function () { + expect($('#songelapsed')).to.have.text('') + expect($('#songlength')).to.have.text('') + }) + + it('should start out in unsynced state', function () { + assert.equal(syncedProgressTimer.syncState, SyncedProgressTimer.SYNC_STATE.NOT_SYNCED) + }) + }) + + describe('#format()', function () { + it('should set value of text node', function () { + assert.equal(SyncedProgressTimer.format(1000), '0:01') + }) + + it('should handle Infinity', function () { + assert.equal(SyncedProgressTimer.format(Infinity), '(n/a)') + }) + + it('should handle zero', function () { + assert.equal(SyncedProgressTimer.format(0), '0:00') + }) + }) + + describe('#timerCallback()', function () { + var clock + beforeEach(function () { + 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 + }) + }) + afterEach(function () { + clock.restore() + }) + + it('should not try to sync unless connected to mopidy', function () { + syncedProgressTimer._isConnected = false + var _syncScheduledStub = sinon.stub(syncedProgressTimer, '_isSyncScheduled') + assert.isFalse(_syncScheduledStub.called, '_syncScheduledStub called') + _syncScheduledStub.restore() + }) + + it('should update text nodes', function () { + var updateStub = sinon.stub(syncedProgressTimer, '_update') + syncedProgressTimer.set(0, 1000).start() + assert.isTrue(updateStub.called, '_update not called') + updateStub.restore() + }) + + it('should check if a sync is scheduled', function () { + var scheduleStub = sinon.stub(syncedProgressTimer, '_isSyncScheduled').returns(true) + syncedProgressTimer.set(0, 1000).start() + assert.isTrue(scheduleStub.called, '_isSyncScheduled not called') + scheduleStub.restore() + }) + + it('should attempt to perform a sync when scheduled', function () { + var syncStub = sinon.stub(syncedProgressTimer, '_doSync') + syncedProgressTimer.set(0, 1000).start() + clock.tick(250) + assert.isTrue(syncStub.called, '_doSync not called') + syncStub.restore() + }) + + it('should perform sync', function () { + // Simulate runtime on a 5-second track + assert.equal(syncedProgressTimer.syncState, SyncedProgressTimer.SYNC_STATE.NOT_SYNCED, 'Timer was initialized in incorrect state') + syncedProgressTimer.set(0, 5000).start() + var wasSyncing = false + for (var i = 0; i < MAX_ATTEMPTS; i++) { + clock.tick(250) // 250ms * MAX_ATTEMPTS is only 2 seconds, but we'll be synced after only two attempts + wasSyncing = wasSyncing || syncedProgressTimer.syncState === SyncedProgressTimer.SYNC_STATE.SYNCING + } + assert.isTrue(wasSyncing, 'Timer never entered the "syncing" state') + assert.equal(syncedProgressTimer.syncState, SyncedProgressTimer.SYNC_STATE.SYNCED, 'Timer failed to sync') + }) + }) + + describe('#_update()', function () { + it('should clear timers and reset slider to zero while not ready', 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) + assert.equal(syncedProgressTimer.durationNode.nodeValue, '(n/a)') + }) + + it('should show "(wait)" while waiting for Mopidy to supply a position', function () { + syncedProgressTimer._update(1000, 2000) + assert.equal(syncedProgressTimer.positionNode.nodeValue, '(wait)') + }) + + it('should show "(sync)" while trying to sync up with Mopidy', function () { + syncedProgressTimer.syncState = SyncedProgressTimer.SYNC_STATE.SYNCING + syncedProgressTimer._update(1000, 2000) + assert.equal(syncedProgressTimer.positionNode.nodeValue, '(sync)') + }) + + it('should update position text and position track slider when synced', function () { + syncedProgressTimer.syncState = SyncedProgressTimer.SYNC_STATE.SYNCED + syncedProgressTimer._update(1000, 2000) + assert.equal(syncedProgressTimer.positionNode.nodeValue, '0:01') + assert.equal($('#trackslider').val(), 1000) + }) + }) + + describe('#_isSyncScheduled()', function () { + var scheduleSpy + var clock + before(function () { + scheduleSpy = sinon.spy(syncedProgressTimer, '_isSyncScheduled') + clock = sinon.useFakeTimers() + }) + after(function () { + scheduleSpy.restore() + clock.restore() + }) + it('should schedule sync when scheduled time arrives', function () { + syncedProgressTimer._scheduledSyncTime = new Date().getTime() + 1000 + assert.isFalse(syncedProgressTimer._isSyncScheduled()) + clock.tick(1000) + assert.isTrue(syncedProgressTimer._isSyncScheduled()) + }) + }) + + describe('#_doSync', function () { + var clock + beforeEach(function () { + clock = sinon.useFakeTimers() + }) + afterEach(function () { + clock.restore() + }) + it('should not try to sync until timer has been set', function () { + syncedProgressTimer._doSync(0, Infinity) + assert.isFalse(getTimePositionStub.called, 'getTimePosition called even though timer has not been set') + }) + it('should request position from Mopidy', function () { + syncedProgressTimer._doSync(1000, 2000) + assert.isTrue(getTimePositionStub.called, 'getTimePosition not called') + }) + + 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) + clock.tick(10) + syncedProgressTimer._doSync(1010, 2000) + assert.equal(syncedProgressTimer.syncState, SyncedProgressTimer.SYNC_STATE.SYNCING) + clock.tick(10) + syncedProgressTimer._doSync(1020, 2000) + 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 () { + syncedProgressTimer._doSync(1, 2000) + assert.equal(syncedProgressTimer.syncState, SyncedProgressTimer.SYNC_STATE.SYNCING) + assert.equal(syncedProgressTimer._syncAttemptsRemaining, syncedProgressTimer._maxAttempts) + }) + + it('should step back exponentially while syncing', function () { + for (var i = 0; i < syncedProgressTimer._maxAttempts; i++) { + syncedProgressTimer._doSync(1000 + (i + 1) * 10, 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 sync attempts remaining') + assert.equal(syncedProgressTimer._scheduledSyncTime, (0.25 * (Math.pow(2, i)) * 1000), 'Incorrect sync time scheduled') + } + }) + + it('should check sync every 32s once synced', function () { + syncedProgressTimer._syncAttemptsRemaining = 0 + syncedProgressTimer._doSync(1000, 2000) + assert.equal(syncedProgressTimer._scheduledSyncTime, 32000) + }) + + it('should not sync unless track playback is progressing', function () { + 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) + clock.tick(10) + syncedProgressTimer._doSync(1010, 2000) + assert.equal(syncedProgressTimer.syncState, SyncedProgressTimer.SYNC_STATE.SYNCING) + clock.tick(10) + syncedProgressTimer._doSync(1010, 2000) + assert.equal(syncedProgressTimer.syncState, SyncedProgressTimer.SYNC_STATE.SYNCING) + }) + }) + + describe('#set()', function () { + it('should throw exception if no arguments are provided', function () { + assert.throw(function () { syncedProgressTimer.set() }, Error) + }) + + it('should set position if only one argument is provided', function () { + syncedProgressTimer.set(1000) + assert.equal(syncedProgressTimer._progressTimer._state.position, 1000) + }) + + it('should update track slider if no sync is scheduled', function () { + syncedProgressTimer.stop() + syncedProgressTimer.set(1000, 2000) + expect($('#songelapsed').text()).to.endWith('0:01') + assert.equal($('#trackslider').val(), 1000) + }) + + it('should implement workaround for https://github.com/adamcik/media-progress-timer/issues/3', function () { + syncedProgressTimer.set(1000, 2000).start() + assert.equal(syncedProgressTimer._duration, 2000) + syncedProgressTimer.set(3000) + assert.equal(syncedProgressTimer._progressTimer._state.position, 1999) + }) + }) + + describe('#start()', function () { + it('should start timer', function () { + var startStub = sinon.stub(syncedProgressTimer._progressTimer, 'start') + syncedProgressTimer.start() + assert(startStub.called) + startStub.restore() + }) + + it('should always start in unsynced state', function () { + syncedProgressTimer.syncState = SyncedProgressTimer.SYNC_STATE.SYNCED + syncedProgressTimer.start() + assert.equal(syncedProgressTimer.syncState, SyncedProgressTimer.SYNC_STATE.NOT_SYNCED) + }) + + it('should schedule a sync immediately', function () { + var clock = sinon.useFakeTimers() + syncedProgressTimer._scheduledSyncTime = new Date().getTime() + 5000 + expect(syncedProgressTimer._scheduledSyncTime).to.be.above(new Date().getTime()) + syncedProgressTimer.start() + clock.tick(1000) + expect(syncedProgressTimer._scheduledSyncTime).to.be.below(new Date().getTime()) + clock.restore() + }) + }) + + describe('#stop()', function () { + it('should stop timer', function () { + var stopStub = sinon.stub(syncedProgressTimer._progressTimer, 'stop') + syncedProgressTimer.stop() + assert(stopStub.called) + stopStub.restore() + }) + + it('should show position when stopped', function () { + syncedProgressTimer.syncState = SyncedProgressTimer.SYNC_STATE.SYNCED + syncedProgressTimer._update(1000, 5000) + syncedProgressTimer.syncState = SyncedProgressTimer.SYNC_STATE.SYNCING + syncedProgressTimer._update(2000, 5000) + assert.equal(syncedProgressTimer.positionNode.nodeValue, '(sync)') + syncedProgressTimer.stop() + assert.equal(syncedProgressTimer.positionNode.nodeValue, '0:01') + }) + + it('should cancel any scheduled syncs', function () { + syncedProgressTimer._scheduledSyncTime = 5000 + syncedProgressTimer.stop() + expect(syncedProgressTimer._scheduledSyncTime).to.be.null + }) + }) + + describe('#reset()', function () { + it('should reset timer to 0:00 - (n/a) ', function () { + var resetStub = sinon.stub(syncedProgressTimer._progressTimer, 'reset') + var initStub = sinon.stub(syncedProgressTimer, 'init') + var stopStub = sinon.stub(syncedProgressTimer, 'stop') + + syncedProgressTimer.reset() + + assert(resetStub.called) + assert(initStub.called) + assert(stopStub.called) + + assert.equal(syncedProgressTimer.positionNode.nodeValue, '0:00') + assert.equal(syncedProgressTimer.durationNode.nodeValue, '(n/a)') + + resetStub.restore() + initStub.restore() + stopStub.restore() + }) + }) + + describe('#updatePosition()', function () { + it('should format and set position node', function () { + var formatSpy = sinon.spy(SyncedProgressTimer, 'format') + assert.equal(syncedProgressTimer.positionNode.nodeValue, '') + syncedProgressTimer.updatePosition(1000) + + assert.isTrue(formatSpy.called) + expect(syncedProgressTimer.positionNode.nodeValue).to.endWith('0:01') + formatSpy.restore() + }) + }) +}) diff --git a/tests/test_library.js b/tests/test_library.js deleted file mode 100644 index 1d3b19f..0000000 --- a/tests/test_library.js +++ /dev/null @@ -1,54 +0,0 @@ -var chai = require('chai') -var should = chai.should() -var expect = chai.expect -var assert = chai.assert -chai.use(require('chai-string')) -chai.use(require('chai-jquery')) - -var sinon = require('sinon') - -var library = require('../mopidy_musicbox_webclient/static/js/library.js') - -var selectID = '#selectSearchService' -var schemesArray - -before(function () { - $(document.body).append('') - $('#selectSearchService').selectmenu() -}) -describe('Library', function () { - describe('#getSearchSchemes()', function () { - beforeEach(function () { - schemesArray = ['mockScheme1', 'mockScheme2', 'mockScheme3'] - mopidy = { - getUriSchemes: function () { return $.when(schemesArray) } - } - $(selectID).empty() - }) - - it('should add human-readable options for backend schemes', function () { - uriHumanList = [['mockScheme2', 'mockUriHuman2']] - - library.getSearchSchemes() - assert($(selectID).children().length === schemesArray.length + 1) - $(selectID).children(':eq(2)').should.have.text('MockUriHuman2') - }) - - it('should get default value from cookie', function () { - $.cookie('searchScheme', 'mockScheme3') - library.getSearchSchemes() - $(selectID + ' option:selected').should.have.value('mockScheme3') - }) - - it('should default to "all" backends if no cookie is available', function () { - $.removeCookie('searchScheme') - library.getSearchSchemes() - $(selectID + ' option:selected').should.have.value('all') - }) - - it('should capitalize first character of backend schema', function () { - library.getSearchSchemes() - $(selectID).children(':eq(1)').should.have.text('MockScheme1') - }) - }) -})