Adds back scrobbling and loading a track and the queue from localStorage.

- Splits loadTrackPosition into two functions : one for the track (which isn't finished), one for the queue.
- Adds main-controller.js' first unit tests for both these functions.
- Adds scrobbling functionnality. It is now a part of the Subsonic service, since we it's Subsonic that ultimately does the scroblling.
- Adds unit tests for both the service and the directive. The test for updatetime was crazy hard to do because I had to find a way to trigger my own fake event and it wasn't permitted by jplayer.
- Adds the load function to the player, it is used only when loading a song from localStorage.
- Removes ng-click from play/pause in the player template. jPlayer adds its own handlers on them, no need to do it twice.
This commit is contained in:
Hyzual 2014-12-21 20:25:54 +01:00
parent 59762ae423
commit 834e67946c
10 changed files with 447 additions and 187 deletions

View file

@ -443,27 +443,33 @@
return $sce.trustAsHtml(html);
};
function loadTrackPosition() {
//TODO: HYZ: Unit test
$scope.loadTrackPosition = function () {
if (utils.browserStorageCheck()) {
// Load Saved Song
var song = angular.fromJson(localStorage.getItem('CurrentSong'));
if (song) {
player.load(song);
// Load Saved Queue
var items = angular.fromJson(localStorage.getItem('CurrentQueue'));
if (items) {
player.queue = items;
}
} else {
if (globals.settings.Debug) { console.log('HTML5::loadStorage not supported on your browser'); }
}
};
$scope.loadQueue = function () {
if(utils.browserStorageCheck()) {
// load Saved queue
var queue = angular.fromJson(localStorage.getItem('CurrentQueue'));
if (queue) {
player.queue = queue;
if (player.queue.length > 0) {
notifications.updateMessage(player.queue.length + ' Saved Song(s)', true);
}
if (globals.settings.Debug) { console.log('Play Queue Loaded From localStorage: ' + player.queue.length + ' song(s)'); }
}
}
} else {
if (globals.settings.Debug) { console.log('HTML5::loadStorage not supported on your browser'); }
}
}
};
/* Launch on Startup */
$scope.loadSettings();
@ -476,7 +482,8 @@
if ($scope.loggedIn()) {
//$scope.ping();
if (globals.settings.SaveTrackPosition) {
loadTrackPosition();
$scope.loadQueue();
$scope.loadTrackPosition();
//FIXME: HYZ: player.startSaveTrackPosition();
}
}

View file

@ -1,30 +1,132 @@
describe("Main controller", function() {
'use strict';
describe("updateFavorite -", function() {
var scope, $rootScope, utils, globals, model, notifications, player;
beforeEach(function() {
module('JamStash');
it("when starring a song, it notifies the user that the star was saved", function() {
inject(function ($controller, _$rootScope_, _$document_, _$window_, _$location_, _$cookieStore_, _utils_, _globals_, _model_, _notifications_, _player_) {
$rootScope = _$rootScope_;
scope = $rootScope.$new();
utils = _utils_;
globals = _globals_;
model = _model_;
notifications = _notifications_;
player = _player_;
$controller('AppController', {
$scope: scope,
$rootScope: $rootScope,
$document: _$document_,
$window: _$window_,
$location: _$location_,
$cookieStore: _$cookieStore_,
utils: utils,
globals: globals,
model: model,
notifications: notifications,
player: player
});
});
});
xdescribe("updateFavorite -", function() {
xit("when starring a song, it notifies the user that the star was saved", function() {
});
it("when starring an album, it notifies the user that the star was saved", function() {
xit("when starring an album, it notifies the user that the star was saved", function() {
});
it("when starring an artist, it notifies the user that the star was saved", function() {
xit("when starring an artist, it notifies the user that the star was saved", function() {
});
it("given that the Subsonic server returns an error, when starring something, it notifies the user with the error message", function() {
xit("given that the Subsonic server returns an error, when starring something, it notifies the user with the error message", function() {
//TODO: move to higher level
});
it("given that the Subsonic server is unreachable, when starring something, it notifies the user with the HTTP error code", function() {
xit("given that the Subsonic server is unreachable, when starring something, it notifies the user with the HTTP error code", function() {
//TODO: move to higher level
});
});
describe("toggleSetting -", function() {
xdescribe("toggleSetting -", function() {
});
describe("load from localStorage -", function() {
var fakeStorage;
beforeEach(function() {
fakeStorage = {};
spyOn(localStorage, "getItem").and.callFake(function(key) {
return fakeStorage[key];
});
spyOn(utils, "browserStorageCheck").and.returnValue(true);
});
describe("loadTrackPosition -", function() {
beforeEach(function() {
spyOn(player, "load");
});
it("Given that we previously saved the current track's position in local Storage, it loads the song we saved into the player", function() {
var song = {
id: 8626,
name: 'Pectinatodenticulate',
artist: 'Isiah Hosfield',
album: 'Tammanyize'
};
fakeStorage = {
'CurrentSong': song
};
scope.loadTrackPosition();
expect(localStorage.getItem).toHaveBeenCalledWith('CurrentSong');
expect(player.load).toHaveBeenCalledWith(song);
});
it("Given that we didn't save anything in local Storage, it doesn't load anything", function() {
scope.loadTrackPosition();
expect(localStorage.getItem).toHaveBeenCalledWith('CurrentSong');
expect(player.load).not.toHaveBeenCalled();
});
});
describe("loadQueue -", function() {
beforeEach(function() {
spyOn(notifications, "updateMessage");
player.queue = [];
});
it("Given that we previously saved the playing queue in local Storage, it fills the player's queue with what we saved and notifies the user", function() {
var queue = [
{ id: 8705 },
{ id: 1617 },
{ id: 9812 }
];
fakeStorage = {
'CurrentQueue': queue
};
scope.loadQueue();
expect(localStorage.getItem).toHaveBeenCalledWith('CurrentQueue');
expect(player.queue).toEqual(queue);
expect(notifications.updateMessage).toHaveBeenCalledWith('3 Saved Song(s)', true);
});
it("Given that we didn't save anything in local Storage, it doesn't load anything", function() {
scope.loadQueue();
expect(localStorage.getItem).toHaveBeenCalledWith('CurrentQueue');
expect(player.queue).toEqual([]);
expect(notifications.updateMessage).not.toHaveBeenCalled();
});
});
});
});

View file

@ -1,14 +1,19 @@
angular.module('jamstash.player.directive', ['jamstash.player.service', 'jamstash.settings'])
/**
* jamstash.player.directive module
*
* Encapsulates the jPlayer plugin. It watches the player service for the song to play, load or restart.
* It also enables jPlayer to attach event handlers to our UI through css Selectors.
*/
angular.module('jamstash.player.directive', ['jamstash.player.service', 'jamstash.settings', 'jamstash.subsonic.service'])
.directive('jplayer', ['player', 'globals', function(playerService, globals) {
.directive('jplayer', ['player', 'globals', 'subsonic', function(playerService, globals, subsonic) {
'use strict';
return {
restrict: 'EA',
template: '<div></div>',
link: function(scope, element, attrs) {
link: function(scope, element) {
var $player = element.children('div');
console.log($player);
var audioSolution = 'html,flash';
if (globals.settings.ForceFlash) {
audioSolution = 'flash,html';
@ -17,7 +22,7 @@ angular.module('jamstash.player.directive', ['jamstash.player.service', 'jamstas
var updatePlayer = function() {
$player.jPlayer({
// Flash fallback for outdated browser not supporting HTML5 audio/video tags
// http://jplayer.org/download/
// TODO: Hyz: Replace in Grunt !
swfPath: 'bower_components/jplayer/dist/jplayer/jquery.jplayer.swf',
wmode: 'window',
solution: audioSolution,
@ -37,8 +42,8 @@ angular.module('jamstash.player.directive', ['jamstash.player.service', 'jamstas
},
play: function() {
console.log('jplayer play');
$('#playermiddle').css('visibility', 'visible');
$('#songdetails').css('visibility', 'visible');
scope.revealControls();
scope.scrobbled = false;
},
pause: function() {
console.log('jplayer pause');
@ -47,18 +52,37 @@ angular.module('jamstash.player.directive', ['jamstash.player.service', 'jamstas
console.log('jplayer ended');
playerService.nextTrack();
scope.$apply();
},
timeupdate: function (event) {
// Scrobble song once percentage is reached
var p = event.jPlayer.status.currentPercentAbsolute;
if (!scope.scrobbled && p > 30) {
if (globals.settings.Debug) { console.log('LAST.FM SCROBBLE - Percent Played: ' + p); }
subsonic.scrobble(scope.currentSong);
scope.scrobbled = true;
}
}
});
};
updatePlayer();
scope.currentSong = {};
scope.scrobbled = false;
scope.$watch(function () {
return playerService.getPlayingSong();
}, function (newVal) {
console.log('playingSong changed !');
$player.jPlayer('setMedia', {'mp3': newVal.url})
.jPlayer('play');
scope.currentSong = newVal;
$player.jPlayer('setMedia', {'mp3': newVal.url});
if(playerService.loadSong === true) {
// Do not play, only load
playerService.loadSong = false;
scope.revealControls();
} else {
$player.jPlayer('play');
}
});
scope.$watch(function () {
@ -70,6 +94,12 @@ angular.module('jamstash.player.directive', ['jamstash.player.service', 'jamstas
playerService.restartSong = false;
}
});
scope.revealControls = function () {
$('#playermiddle').css('visibility', 'visible');
$('#songdetails').css('visibility', 'visible');
};
} //end link
};
}]);

View file

@ -1,27 +1,26 @@
describe("jplayer directive", function() {
'use strict';
var element, scope, playerService, globalsService, $player, playingSong;
function mockGetPlayingSong() {
return playingSong;
}
var element, scope, playerService, globals, subsonic, $player, playingSong;
beforeEach(function() {
playingSong = {};
module('jamstash.player.directive', function($provide) {
// Mock the player service
$provide.decorator('player', function($delegate) {
$delegate.getPlayingSong = jasmine.createSpy('getPlayingSong').and.callFake(mockGetPlayingSong);
$delegate.getPlayingSong = jasmine.createSpy('getPlayingSong').and.callFake(function() {
return playingSong;
});
$delegate.nextTrack = jasmine.createSpy('nextTrack');
return $delegate;
});
});
inject(function($rootScope, $compile, _player_, _globals_) {
inject(function($rootScope, $compile, _player_, _globals_, _subsonic_) {
playerService = _player_;
globalsService = _globals_;
globals = _globals_;
subsonic = _subsonic_;
// Compile the directive
scope = $rootScope.$new();
element = '<div id="playdeck_1" jplayer></div>';
@ -31,14 +30,38 @@ describe("jplayer directive", function() {
$player = element.children('div');
});
it("When the player service's current playing song changes, it sets jplayer's media and plays the song", function() {
spyOn($.fn, "jPlayer").and.returnValue($.fn);
describe("When the player service's current song changes,", function() {
beforeEach(function() {
spyOn($.fn, "jPlayer").and.returnValue($.fn);
playingSong = {url: 'https://gantry.com/antemarital/vigorless?a=oropharyngeal&b=killcrop#eviscerate'};
});
it("it sets jPlayer's media and stores the song for future scrobbling", function() {
scope.$apply();
expect($player.jPlayer).toHaveBeenCalledWith('setMedia', {'mp3': 'https://gantry.com/antemarital/vigorless?a=oropharyngeal&b=killcrop#eviscerate'});
expect(scope.currentSong).toEqual(playingSong);
});
it("if the player service's loadSong flag is true, it does not play the song and it displays the player controls", function() {
spyOn(scope, "revealControls");
playerService.loadSong = true;
scope.$apply();
expect($player.jPlayer).not.toHaveBeenCalledWith('play');
expect(playerService.loadSong).toBeFalsy();
expect(scope.revealControls).toHaveBeenCalled();
});
it("otherwise, it plays it", function() {
playerService.loadSong = false;
scope.$apply();
expect($player.jPlayer).toHaveBeenCalledWith('play');
expect(playerService.loadSong).toBeFalsy();
});
});
it("When the player service's restartSong flag is true, it restarts the current song and resets the flag to false", function() {
@ -51,7 +74,7 @@ describe("jplayer directive", function() {
expect(playerService.restartSong).toBeFalsy();
});
it("When jplayer has finished the current song, it plays the next track using the", function() {
it("When jplayer has finished the current song, it asks the player service for the next track", function() {
var e = $.jPlayer.event.ended;
$player.trigger(e);
@ -59,12 +82,67 @@ describe("jplayer directive", function() {
});
it("When jPlayer starts to play the current song, it displays the player controls", function() {
affix('#playermiddle').css('visibility', 'hidden');
affix('#songdetails').css('visibility', 'hidden');
spyOn(scope, "revealControls");
var e = $.jPlayer.event.play;
$player.trigger(e);
expect(scope.revealControls).toHaveBeenCalled();
});
it("When jPlayer starts to play the current song, it resets the scrobbled flag to false", function() {
scope.scrobbled = true;
var e = $.jPlayer.event.play;
$player.trigger(e);
expect(scope.scrobbled).toBeFalsy();
});
it("revealControls - it displays the song details and the player controls", function() {
affix('#playermiddle').css('visibility', 'hidden');
affix('#songdetails').css('visibility', 'hidden');
scope.revealControls();
expect($('#playermiddle').css('visibility')).toEqual('visible');
expect($('#songdetails').css('visibility')).toEqual('visible');
});
describe("Scrobbling -", function() {
var fakejPlayer, timeUpdate;
beforeEach(function() {
spyOn(subsonic, "scrobble");
scope.currentSong = {
id: 5375
};
// Fake jPlayer's internal _trigger event because I can't trigger a manual timeupdate
fakejPlayer = {
element: $player,
status: { currentPercentAbsolute: 31 }
};
timeUpdate = $.jPlayer.event.timeupdate;
});
it("Given a song that hasn't been scrobbled yet, When jPlayer reaches 30 percent of it, it scrobbles to last.fm using the subsonic service and sets the flag to true", function() {
scope.scrobbled = false;
// Trigger our fake timeupdate
$.jPlayer.prototype._trigger.call(fakejPlayer, timeUpdate);
expect(subsonic.scrobble).toHaveBeenCalledWith(scope.currentSong);
expect(scope.scrobbled).toBeTruthy();
});
it("Given a song that has already been scrobbled, when jPlayer reaches 30 percent of it, it does not scrobble again and leaves the flag to true", function() {
scope.scrobbled = true;
// Trigger our fake timeupdate
$.jPlayer.prototype._trigger.call(fakejPlayer, timeUpdate);
expect(subsonic.scrobble).not.toHaveBeenCalled();
expect(scope.scrobbled).toBeTruthy();
});
});
});

View file

@ -1,7 +1,7 @@
/**
* jamstash.player.service Module
*
* Enables app-wide control of the behavior of the player directive.
* Manages the player and playing queue. Use it to play a song, go to next track or add songs to the queue.
*/
angular.module('jamstash.player.service', ['jamstash.settings', 'angular-underscore/utils'])
@ -13,9 +13,9 @@ angular.module('jamstash.player.service', ['jamstash.settings', 'angular-undersc
playingIndex: -1,
playingSong: {},
restartSong: false,
loadSong: false,
play: function(song) {
console.log('player service - play()', song);
var songIndexInQueue;
// Find the song's index in the queue, if it's in there
songIndexInQueue = player.queue.indexOf(song);
@ -31,22 +31,20 @@ angular.module('jamstash.player.service', ['jamstash.settings', 'angular-undersc
},
playFirstSong: function() {
console.log('player service - playFirstSong()');
player.playingIndex = 0;
player.play(player.queue[0]);
},
load: function(song) {
console.log('player service - load()');
player.loadSong = true;
player.play(song);
},
restart: function() {
console.log('player service - restart()');
player.restartSong = true;
},
nextTrack: function() {
console.log('player service - nextTrack()');
if((player.playingIndex + 1) < player.queue.length) {
var nextTrack = player.queue[player.playingIndex + 1];
player.playingIndex++;
@ -55,7 +53,6 @@ angular.module('jamstash.player.service', ['jamstash.settings', 'angular-undersc
},
previousTrack: function() {
console.log(('player service - previousTrack()'));
if((player.playingIndex - 1) > 0) {
var previousTrack = player.queue[player.playingIndex - 1];
player.playingIndex--;
@ -66,22 +63,18 @@ angular.module('jamstash.player.service', ['jamstash.settings', 'angular-undersc
},
emptyQueue: function() {
console.log('player service - emptyQueue()');
player.queue = [];
},
shuffleQueue: function() {
console.log('player service - shuffleQueue()');
player.queue = _.shuffle(player.queue);
},
addSong: function(song) {
console.log('player service - addSong()');
player.queue.push(song);
},
removeSong: function(song) {
console.log('player service - removeSong()');
var index = player.queue.indexOf(song);
player.queue.splice(index, 1);
},

View file

@ -180,9 +180,18 @@ describe("Player service -", function() {
player.restart();
expect(player.restartSong).toBeTruthy();
});
it("When I load the song, the flag for the directive is set", function() {
spyOn(player, "play");
player.load(song);
expect(player.loadSong).toBeTruthy();
expect(player.play).toHaveBeenCalledWith(song);
});
});
describe("Given that there is no song in my playing queue", function() {
describe("Given that my playing queue is empty", function() {
beforeEach(function() {
player.queue = [];

View file

@ -5,10 +5,10 @@
<a class="hover" id="PreviousTrack" title="Previous Track" ng-click="player.previousTrack()">
<img src="images/first_alt_24x24.png" height="24" width="24" alt="Previous track" />
</a>
<a class="hover PlayTrack" title="Play/Pause" ng-click="player.play()">
<a class="hover PlayTrack" title="Play/Pause">
<img src="images/play_alt_24x24.png" height="24" width="24" alt="Play" />
</a>
<a class="hover PauseTrack" title="Play/Pause" ng-click="player.pause()" style="display: none;">
<a class="hover PauseTrack" title="Play/Pause" style="display: none;">
<img src="images/pause_alt_24x24.png" height="24" width="24" alt="Pause" />
</a>
<a class="hover" id="NextTrack" title="Next Track" ng-click="player.nextTrack()">

View file

@ -8,7 +8,6 @@ angular.module('jamstash.queue.controller', ['jamstash.player.service'])
$scope.itemType = 'pl'; // TODO: Hyz: What is this ?
$scope.playSong = function (song) {
console.log('Queue Controller - playSong()', song);
player.play(song);
};

View file

@ -793,7 +793,35 @@ angular.module('jamstash.subsonic.service', ['jamstash.settings', 'jamstash.util
}
});
return deferred.promise;
},
scrobble: function (song) {
var id = song.id;
//TODO: Hyz: Refactor all this boilerplate into an http interceptor ? or something higher level than this
var exception = {reason: 'Error when contacting the Subsonic server.'};
var deferred = $q.defer();
var httpPromise;
if (globals.settings.Debug) { console.log('Scrobble Song: ' + id); }
if(globals.settings.Protocol === 'jsonp') {
httpPromise = $http.jsonp(globals.BaseURL() + '/scrobble.view?callback=JSON_CALLBACK&' + globals.BaseParams() + '&id=' + id + '&submission=true',
{
timeout: globals.settings.Timeout
});
} else {
httpPromise = $http.get(globals.BaseURL() + '/scrobble.view?' + globals.BaseParams() + '&id=' + id + '&submission=true',
{
timeout: globals.settings.Timeout
});
}
httpPromise.success(function (data, status) {
console.log(data);
if(globals.settings.Debug) { console.log('Successfully scrobbled song: ' + id); }
}).error(function(data, status) {
exception.httpError = status;
deferred.reject(exception);
});
return deferred.promise;
}
// End subsonic
};
}]);

View file

@ -2,10 +2,6 @@ describe("Subsonic service -", function() {
'use strict';
var subsonic, mockBackend, mockGlobals, response;
var url = 'http://demo.subsonic.com/rest/getStarred.view?'+
'callback=JSON_CALLBACK&u=Hyzual&p=enc:cGFzc3dvcmQ=&v=1.10.2&c=Jamstash&f=jsonp';
beforeEach(function() {
// We redefine it because in some tests we need to alter the settings
mockGlobals = {
@ -37,7 +33,22 @@ describe("Subsonic service -", function() {
mockBackend.verifyNoOutstandingRequest();
});
it("scrobble - Given a song, when I scrobble it, it returns true if there was no error", function() {
var song = { id: 45872 };
var url = 'http://demo.subsonic.com/rest/scrobble.view?' +
'callback=JSON_CALLBACK&u=Hyzual&p=enc:cGFzc3dvcmQ=&v=1.10.2&c=Jamstash&f=jsonp&id=45872&submission=true';
mockBackend.whenJSONP(url).respond(200, JSON.stringify(response));
var promise = subsonic.scrobble(song);
mockBackend.flush();
expect(promise).toBeResolved();
});
describe("getStarred -", function() {
var url = 'http://demo.subsonic.com/rest/getStarred.view?'+
'callback=JSON_CALLBACK&u=Hyzual&p=enc:cGFzc3dvcmQ=&v=1.10.2&c=Jamstash&f=jsonp';
it("Given that I have 2 starred albums, 1 starred artist and 3 starred songs in my library, when getting everything starred, it returns them all", function() {
response["subsonic-response"].starred = {artist: [{id: 2245}], album: [{id: 1799},{id: 20987}], song: [{id: 2478},{id: 14726},{id: 742}]};
@ -83,7 +94,7 @@ describe("Subsonic service -", function() {
expect(promise).toBeRejectedWith({reason: 'Nothing is starred on the Subsonic server.'});
});
// TODO: Hyz: Those tests should be at a higher level, we are repeating them for everything...
it("Given that the Subsonic server is not responding, when getting everything starred, it returns an error object with a message", function() {
mockBackend.whenJSONP(url).respond(503, 'Service Unavailable');
@ -112,6 +123,9 @@ describe("Subsonic service -", function() {
}); //end getStarred
describe("getRandomStarredSongs -", function() {
var url = 'http://demo.subsonic.com/rest/getStarred.view?'+
'callback=JSON_CALLBACK&u=Hyzual&p=enc:cGFzc3dvcmQ=&v=1.10.2&c=Jamstash&f=jsonp';
describe("Given that the global setting AutoPlaylist Size is 3", function() {
it("and given that I have more than 3 starred songs in my library, when getting random starred songs, the result should be limited to 3 starred songs", function() {