Merge branch 'rewrite_player' into develop

This commit is contained in:
Hyzual 2015-01-17 19:42:30 +01:00
commit b51e033c25
40 changed files with 2770 additions and 1350 deletions

View file

@ -18,6 +18,7 @@
"smarttabs": true,
"globals": {
"_": false,
"affix": false,
"after": false,
"afterEach": false,
"angular": false,
@ -32,6 +33,6 @@
"spyOn": false
},
"browser": true,
"node": true,
"jquery": true
"jquery": true,
"node": true
}

View file

@ -1,54 +1,51 @@
/* Declare app level module */
angular.module('JamStash', ['ngCookies', 'ngRoute', 'ngSanitize',
'jamstash.subsonic.ctrl', 'jamstash.archive.ctrl'])
.config(['$routeProvider',function($routeProvider) {
'use strict';
$routeProvider
.when('/index', { redirectTo: '/library' })
.when('/settings', { templateUrl: 'settings/settings.html', controller: 'SettingsCtrl' })
.when('/queue', { templateUrl: 'queue/queue.html', controller: 'QueueCtrl' })
.when('/library', { templateUrl: 'subsonic/subsonic.html', controller: 'SubsonicCtrl' })
.when('/library/:artistId', { templateUrl: 'subsonic/subsonic.html', controller: 'SubsonicCtrl', reloadOnSearch: false })
.when('/library/:artistId/:albumId', { templateUrl: 'subsonic/subsonic.html', controller: 'SubsonicCtrl', reloadOnSearch: false })
.when('/podcasts', { templateUrl: 'podcasts/podcasts.html', controller: 'PodcastCtrl' })
.when('/archive', { templateUrl: 'archive/archive.html', controller: 'ArchiveCtrl' })
.when('/archive/:artist', { templateUrl: 'archive/archive.html', controller: 'ArchiveCtrl' })
.when('/archive/:artist/:album', { templateUrl: 'archive/archive.html', controller: 'ArchiveCtrl' })
.otherwise({ redirectTo: '/index' });
}])
.config(['$httpProvider',function($httpProvider) {
'use strict';
$httpProvider.interceptors.push(['$rootScope', '$location', '$q', 'globals', function ($rootScope, $location, $q, globals) {
return {
'request': function (request) {
// if we're not logged-in to the AngularJS app, redirect to login page
//$rootScope.loggedIn = $rootScope.loggedIn || globals.settings.Username;
$rootScope.loggedIn = false;
if (globals.settings.Username != "" && globals.settings.Password != "" && globals.settings.Server != "") {
$rootScope.loggedIn = true;
}
var path = '';
path = $location.path();
if (globals.settings.Debug) { console.log('Logged In: ' + $rootScope.loggedIn); }
if (globals.settings.Debug) { console.log('Current Path: ' + path); }
if (!$rootScope.loggedIn && path != '/settings' && path.search('archive') < 0) {
$location.path('/settings');
}
return request;
},
'responseError': function (rejection) {
// if we're not logged-in to the web service, redirect to login page
if (rejection.status === 401 && $location.path() != '/settings') {
$rootScope.loggedIn = false;
$location.path('/settings');
}
return $q.reject(rejection);
}
};
}]);
}]);
'use strict';
/* Declare app level module */
angular.module('JamStash', ['ngCookies', 'ngRoute', 'ngSanitize',
'jamstash.subsonic.controller', 'jamstash.archive.controller', 'jamstash.player.controller', 'jamstash.queue.controller', 'jamstash.persistence'])
.config(['$routeProvider',function($routeProvider) {
$routeProvider
.when('/index', { redirectTo: '/library' })
.when('/settings', { templateUrl: 'settings/settings.html', controller: 'SettingsController' })
.when('/queue', { templateUrl: 'queue/queue.html', controller: 'QueueController' })
.when('/library', { templateUrl: 'subsonic/subsonic.html', controller: 'SubsonicController' })
.when('/library/:artistId', { templateUrl: 'subsonic/subsonic.html', controller: 'SubsonicController', reloadOnSearch: false })
.when('/library/:artistId/:albumId', { templateUrl: 'subsonic/subsonic.html', controller: 'SubsonicController', reloadOnSearch: false })
.when('/podcasts', { templateUrl: 'podcasts/podcasts.html', controller: 'PodcastController' })
.when('/archive', { templateUrl: 'archive/archive.html', controller: 'ArchiveController' })
.when('/archive/:artist', { templateUrl: 'archive/archive.html', controller: 'ArchiveController' })
.when('/archive/:artist/:album', { templateUrl: 'archive/archive.html', controller: 'ArchiveController' })
.otherwise({ redirectTo: '/index' });
}])
.config(['$httpProvider',function($httpProvider) {
$httpProvider.interceptors.push(['$rootScope', '$location', '$q', 'globals', function ($rootScope, $location, $q, globals) {
return {
'request': function (request) {
// if we're not logged-in to the AngularJS app, redirect to login page
//$rootScope.loggedIn = $rootScope.loggedIn || globals.settings.Username;
$rootScope.loggedIn = false;
if (globals.settings.Username != "" && globals.settings.Password != "" && globals.settings.Server != "") {
$rootScope.loggedIn = true;
}
var path = '';
path = $location.path();
if (globals.settings.Debug) { console.log('Logged In: ' + $rootScope.loggedIn); }
if (globals.settings.Debug) { console.log('Current Path: ' + path); }
if (!$rootScope.loggedIn && path != '/settings' && path.search('archive') < 0) {
$location.path('/settings');
}
return request;
},
'responseError': function (rejection) {
// if we're not logged-in to the web service, redirect to login page
if (rejection.status === 401 && $location.path() != '/settings') {
$rootScope.loggedIn = false;
$location.path('/settings');
}
return $q.reject(rejection);
}
};
}]);
}]);

View file

@ -3,10 +3,11 @@
*
* Access Archive.org
*/
angular.module('jamstash.archive.service', ['jamstash.settings', 'jamstash.model', 'jamstash.notifications'])
angular.module('jamstash.archive.service', ['jamstash.settings', 'jamstash.model', 'jamstash.notifications',
'jamstash.player.service'])
.factory('archive', ['$rootScope', '$http', '$q', '$sce', 'globals', 'model', 'utils', 'map', 'notifications',
function($rootScope, $http, $q, $sce, globals, model, utils, map, notifications) {
.factory('archive', ['$rootScope', '$http', '$q', '$sce', 'globals', 'model', 'utils', 'map', 'notifications', 'player',
function($rootScope, $http, $q, $sce, globals, model, utils, map, notifications, player) {
'use strict';
var index = { shortcuts: [], artists: [] };
@ -179,20 +180,20 @@ angular.module('jamstash.archive.service', ['jamstash.settings', 'jamstash.model
angular.forEach(items, function (item, key) {
var song = mapSong(key, item, server, dir, identifier, coverart);
if (song) {
$rootScope.queue.push(song);
player.queue.push(song);
}
});
notifications.updateMessage(Object.keys(items).length + ' Song(s) Added to Queue', true);
} else if (action == 'play') {
$rootScope.queue = [];
player.queue = [];
angular.forEach(items, function (item, key) {
var song = mapSong(key, item, server, dir, identifier, coverart);
if (song) {
$rootScope.queue.push(song);
player.queue.push(song);
}
});
var next = $rootScope.queue[0];
$rootScope.playSong(false, next);
var next = player.queue[0];
player.play(next);
notifications.updateMessage(Object.keys(items).length + ' Song(s) Added to Queue', true);
} else {
content.album = [];
@ -213,4 +214,4 @@ angular.module('jamstash.archive.service', ['jamstash.settings', 'jamstash.model
return deferred.promise;
}
};
}]);
}]);

View file

@ -1,11 +1,11 @@
/**
* jamstash.archive.ctrl Module
/**
* jamstash.archive.controller Module
*
* Access Archive.org
*/
angular.module('jamstash.archive.ctrl', ['jamstash.archive.service'])
angular.module('jamstash.archive.controller', ['jamstash.archive.service'])
.controller('ArchiveCtrl', ['$scope', '$rootScope', '$location', '$routeParams', '$http', '$timeout', 'utils', 'globals', 'model', 'notifications', 'player', 'archive', 'json',
.controller('ArchiveController', ['$scope', '$rootScope', '$location', '$routeParams', '$http', '$timeout', 'utils', 'globals', 'model', 'notifications', 'player', 'archive', 'json',
function($scope, $rootScope, $location, $routeParams, $http, $timeout, utils, globals, model, notifications, player, archive, json){
'use strict';
@ -179,4 +179,4 @@ angular.module('jamstash.archive.ctrl', ['jamstash.archive.service'])
$scope.addSavedCollection($routeParams.artist);
}
/* End Startup */
}]);
}]);

View file

@ -1,18 +1,18 @@
angular.module('JamStash')
.controller('AppCtrl', ['$scope', '$rootScope', '$document', '$window', '$location', '$cookieStore', '$http', 'utils', 'globals', 'model', 'notifications', 'player',
function($scope, $rootScope, $document, $window, $location, $cookieStore, $http, utils, globals, model, notifications, player) {
angular.module('JamStash')
.controller('AppController', ['$scope', '$rootScope', '$document', '$window', '$location', '$cookieStore', '$http', 'utils', 'globals', 'model', 'notifications', 'player', 'persistence', 'Page',
function($scope, $rootScope, $document, $window, $location, $cookieStore, $http, utils, globals, model, notifications, player, persistence, Page) {
'use strict';
$rootScope.settings = globals.settings;
$rootScope.song = [];
$rootScope.queue = [];
$rootScope.playingSong = null;
$rootScope.MusicFolders = [];
$rootScope.Genres = [];
$rootScope.Messages = [];
$rootScope.SelectedMusicFolder = "";
$rootScope.unity = null;
$scope.Page = Page;
$rootScope.loggedIn = function () {
if (globals.settings.Server !== '' && globals.settings.Username !== '' && globals.settings.Password !== '') {
return true;
@ -27,13 +27,6 @@
$rootScope.go = function (path) {
$location.path(path);
};
/*
$scope.playSong = function (loadonly, data) {
$scope.$apply(function () {
$rootScope.playSong(loadonly, data);
});
}
*/
// Reads cookies and sets globals.settings values
$scope.loadSettings = function () {
@ -109,18 +102,14 @@
}
};
$scope.$watchCollection('queue', function(newItem, oldItem) {
if (oldItem.length != newItem.length
&& globals.settings.ShowQueue) {
$rootScope.showQueue();
$scope.$watchCollection(function () {
return player.queue;
}, function(newQueue) {
if (newQueue !== undefined && newQueue.length > 0 && globals.settings.ShowQueue) {
$scope.showQueue();
}
/*
for (var index in newCol) {
var item = newCol[index];
item.order = parseInt(index) + 1;
}
*/
});
$rootScope.showQueue = function () {
$('#SideBar').css('display', 'block');
$('#right-component').removeClass('lgcolumn_expanded');
@ -130,14 +119,7 @@
$('#right-component').addClass('lgcolumn_expanded');
};
$scope.toggleQueue = function () {
if ($('#SideBar').css('display') == 'none') {
$rootScope.showQueue();
} else {
$rootScope.hideQueue();
}
};
$scope.toggleQueue = function () {
if ($('#SideBar').css('display') == 'none') {
if ($('#SideBar').css('display') === 'none') {
$rootScope.showQueue();
} else {
$rootScope.hideQueue();
@ -160,16 +142,22 @@
};
$scope.fancyboxOpenImage = function (url) {
utils.fancyboxOpenImage(url);
$.fancybox.open({
helpers : {
overlay : {
css : {
'background' : 'rgba(0, 0, 0, 0.15)'
}
}
},
hideOnContentClick: true,
type: 'image',
openEffect: 'none',
closeEffect: 'none',
href: url
});
};
$('#audiocontainer .scrubber').mouseover(function (e) {
$('.audiojs .scrubber').stop().animate({ height: '8px' });
});
$('#audiocontainer .scrubber').mouseout(function (e) {
$('.audiojs .scrubber').stop().animate({ height: '4px' });
});
$(document).on("click", ".message", function(){
$(this).remove();
});
@ -177,23 +165,13 @@
// Global Functions
window.onbeforeunload = function () {
if (!globals.settings.Debug) {
if ($rootScope.queue.length > 0) {
if (player.queue.length > 0) {
return "You're about to end your session, are you sure?";
}
}
};
$rootScope.showIndex = false;
$scope.dragStart = function (e, ui) {
ui.item.data('start', ui.item.index());
};
$scope.dragEnd = function (e, ui) {
var start = ui.item.data('start'),
end = ui.item.index();
$rootScope.queue.splice(end, 0,
$rootScope.queue.splice(start, 1)[0]);
$scope.$apply();
};
$(document).on( 'click', 'message', function() {
$(document).on( 'click', 'message', function() {
$(this).fadeOut(function () { $(this).remove(); });
return false;
})
@ -226,9 +204,9 @@
$('#left-component').stop().scrollTo(el, 400);
}
} else if (unicode == 39 || unicode == 176) { // right arrow
$rootScope.nextTrack();
player.nextTrack();
} else if (unicode == 37 || unicode == 177) { // back arrow
$rootScope.previousTrack();
player.previousTrack();
} else if (unicode == 32 || unicode == 179 || unicode.toString() == '0179') { // spacebar
player.playPauseSong();
return false;
@ -282,13 +260,15 @@
});
};
$rootScope.playAll = function (songs) {
$rootScope.queue = [];
// TODO: Hyz: Replace
player.queue = [];
$rootScope.selectAll(songs);
$rootScope.addSongsToQueue();
var next = $rootScope.queue[0];
$rootScope.playSong(false, next);
var next = player.queue[0];
player.play(next);
};
$rootScope.playFrom = function (index, songs) {
// TODO: Hyz: Replace
var from = songs.slice(index,songs.length);
$scope.selectedSongs = [];
angular.forEach(from, function (item, key) {
@ -296,33 +276,28 @@
item.selected = true;
});
if ($scope.selectedSongs.length > 0) {
$rootScope.queue = [];
player.queue = [];
$rootScope.addSongsToQueue();
var next = $rootScope.queue[0];
$rootScope.playSong(false, next);
var next = player.queue[0];
player.play(next);
}
};
$rootScope.addSongsToQueue = function () {
// TODO: Hyz: Replace
if ($scope.selectedSongs.length !== 0) {
angular.forEach($scope.selectedSongs, function (item, key) {
$rootScope.queue.push(item);
player.queue.push(item);
item.selected = false;
});
notifications.updateMessage($scope.selectedSongs.length + ' Song(s) Added to Queue', true);
$scope.selectedSongs.length = 0;
}
};
$scope.addSongToQueue = function (data) {
$rootScope.queue.push(data);
};
$rootScope.removeSong = function (item, songs) {
// TODO: Hyz: Replace
var index = songs.indexOf(item);
songs.splice(index, 1);
};
$scope.removeSongFromQueue = function (item) {
var index = $rootScope.queue.indexOf(item)
$rootScope.queue.splice(index, 1);
};
$scope.isActive = function (route) {
return route === $location.path();
};
@ -351,33 +326,6 @@
}
});
};
$scope.queueRemoveSelected = function (data, event) {
angular.forEach($scope.selectedSongs, function (item, key) {
var index = $rootScope.queue.indexOf(item);
if (index > -1) {
$rootScope.queue.splice(index, 1);
}
});
};
$scope.queueEmpty = function () {
//self.selectedSongs([]);
$rootScope.queue = [];
$.fancybox.close();
};
$scope.queueTotal = function () {
var total = 0;
utils.arrayForEach(self.queue(), function (item) {
total += parseInt(item.duration());
});
if (self.queue().length > 0) {
return self.queue().length + ' song(s), ' + utils.secondsToTime(total) + ' total time';
} else {
return '0 song(s), 00:00:00 total time';
}
};
$scope.queueShuffle = function () {
$rootScope.queue.sort(function () { return 0.5 - Math.random(); });
};
$scope.selectedSongs = [];
$scope.selectSong = function (data) {
var i = $scope.selectedSongs.indexOf(data);
@ -417,6 +365,7 @@
}
});
};
$scope.updateFavorite = function (item) {
var id = item.id;
var starred = item.starred;
@ -441,7 +390,6 @@
$scope.toTrusted = function (html) {
return $sce.trustAsHtml(html);
};
/* Launch on Startup */
$scope.loadSettings();
@ -454,8 +402,8 @@
if ($scope.loggedIn()) {
//$scope.ping();
if (globals.settings.SaveTrackPosition) {
player.loadTrackPosition();
player.startSaveTrackPosition();
persistence.loadQueue();
persistence.loadTrackPosition();
}
}
/* End Startup */

View file

@ -0,0 +1,81 @@
describe("Main controller", function() {
'use strict';
var scope, mockGlobals, player, utils;
beforeEach(function() {
mockGlobals = {
settings: {
ShowQueue: false,
Debug: true
}
};
module('JamStash', function($provide) {
$provide.value('globals', mockGlobals);
});
inject(function ($controller, $rootScope, _$document_, _$window_, _$location_, _$cookieStore_, _utils_, globals, _model_, _notifications_, _player_, _locker_, _Page_) {
scope = $rootScope.$new();
player = _player_;
utils = _utils_;
spyOn(utils, "switchTheme");
$controller('AppController', {
$scope: scope,
$rootScope: $rootScope,
$document: _$document_,
$window: _$window_,
$location: _$location_,
$cookieStore: _$cookieStore_,
utils: utils,
globals: globals,
model: _model_,
notifications: _notifications_,
player: player,
locker: _locker_,
Page: _Page_
});
});
player.queue = [];
});
xdescribe("updateFavorite -", function() {
xit("when starring a song, 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() {
});
xit("when starring an artist, it notifies the user that the star was saved", 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
});
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
});
});
xdescribe("toggleSetting -", function() {
});
it("given that the global setting 'ShowQueue' is true, when the playing queue's length changes and is not empty, it shows the queue", function() {
mockGlobals.settings.ShowQueue = true;
player.queue = [{
id: 684
}];
spyOn(scope, "showQueue");
scope.$apply();
expect(scope.showQueue).toHaveBeenCalled();
});
});

View file

@ -3,12 +3,12 @@
*
* Provides access to the notification UI.
*/
angular.module('jamstash.notifications', [])
angular.module('jamstash.notifications', ['jamstash.player.service', 'jamstash.utils'])
.service('notifications', ['$rootScope', 'globals', function($rootScope, globals) {
.service('notifications', ['$rootScope', '$window', '$interval', 'globals', 'player', 'utils',
function($rootScope, $window, $interval, globals, player, utils) {
'use strict';
var msgIndex = 1;
this.updateMessage = function (msg, autohide) {
if (msg !== '') {
var id = $rootScope.Messages.push(msg) - 1;
@ -21,47 +21,34 @@ angular.module('jamstash.notifications', [])
}
};
this.requestPermissionIfRequired = function () {
if (window.Notify.isSupported() && window.Notify.needsPermission()) {
if (this.isSupported() && !this.hasPermission()) {
window.Notify.requestPermission();
}
};
this.hasNotificationPermission = function () {
return (window.Notify.needsPermission() === false);
this.hasPermission = function () {
return !$window.Notify.needsPermission();
};
this.hasNotificationSupport = function () {
this.isSupported = function () {
return window.Notify.isSupported();
};
var notifications = [];
this.showNotification = function (pic, title, text, type, bind) {
if (this.hasNotificationPermission()) {
//closeAllNotifications()
var settings = {};
if (bind = '#NextTrack') {
settings.notifyClick = function () {
$rootScope.nextTrack();
this.showNotification = function (song) {
if (this.hasPermission()) {
var notification = new Notify(utils.toHTML.un(song.name), {
body: utils.toHTML.un(song.artist + " - " + song.album),
icon: song.coverartthumb,
notifyClick: function () {
player.nextTrack();
this.close();
};
}
if (type === 'text') {
settings.body = text;
settings.icon = pic;
} else if (type === 'html') {
settings.body = text;
}
var notification = new Notify(title, settings);
notifications.push(notification);
setTimeout(function (notWin) {
notWin.close();
}, globals.settings.Timeout, notification);
$rootScope.$apply();
}
});
$interval(function() {
notification.close();
}, globals.settings.Timeout);
notification.show();
} else {
console.log("showNotification: No Permission");
}
};
this.closeAllNotifications = function () {
for (var notification in notifications) {
notifications[notification].close();
}
};
}]);
}]);

View file

@ -0,0 +1,108 @@
describe("Notifications service - ", function() {
'use strict';
var notifications, $window, $interval, player, utils, mockGlobals,
NotificationObj;
beforeEach(function() {
mockGlobals = {
settings: {
Timeout: 30000
}
};
module('jamstash.notifications', function ($provide) {
$provide.value('globals', mockGlobals);
});
inject(function (_notifications_, _$window_, _$interval_, _player_, _utils_) {
notifications = _notifications_;
$window = _$window_;
player = _player_;
utils = _utils_;
$interval = _$interval_;
});
spyOn(player, "nextTrack");
spyOn(utils.toHTML, "un").and.callFake(function (arg) { return arg; });
// Mock the Notify object
$window.Notify = jasmine.createSpyObj("Notify", ["isSupported", "needsPermission", "requestPermission"]);
NotificationObj = jasmine.createSpyObj("Notification", ["show", "close"]);
spyOn($window, "Notify").and.callFake(function (title, settings) {
NotificationObj.simulateClick = settings.notifyClick;
return NotificationObj;
});
});
it("can check whether we have the permission to display notifications in the current browser", function() {
$window.Notify.needsPermission.and.returnValue(false);
expect(notifications.hasPermission()).toBeTruthy();
expect($window.Notify.needsPermission).toHaveBeenCalled();
});
it("can check whether the current browser supports notifications", function() {
$window.Notify.isSupported.and.returnValue(true);
expect(notifications.isSupported()).toBeTruthy();
expect($window.Notify.isSupported).toHaveBeenCalled();
});
it("can request Notification permission for the current browser", function() {
spyOn(notifications, "isSupported").and.returnValue(true);
spyOn(notifications, "hasPermission").and.returnValue(false);
notifications.requestPermissionIfRequired();
expect($window.Notify.requestPermission).toHaveBeenCalled();
});
describe("When I show a notification, given a song,", function() {
var song;
beforeEach(function() {
song = {
coverartthumb: "https://backjaw.com/overquantity/outpitch?a=redredge&b=omnivoracious#promotement",
name: "Unhorny",
artist: "Saturnina Koster",
album: "Trepidate"
};
spyOn(notifications, "hasPermission").and.returnValue(true);
});
it("it checks the permissions, displays the title, the artist's name and the album picture in a notification", function() {
notifications.showNotification(song);
expect(notifications.hasPermission).toHaveBeenCalled();
expect($window.Notify).toHaveBeenCalledWith(song.name, {
body: song.artist + " - " + song.album,
icon: song.coverartthumb,
notifyClick: jasmine.any(Function)
});
expect(NotificationObj.show).toHaveBeenCalled();
});
it("when I click on it, it plays the next track of the queue", function() {
notifications.showNotification(song);
NotificationObj.simulateClick();
expect(player.nextTrack).toHaveBeenCalled();
expect(NotificationObj.close).toHaveBeenCalled();
});
it("given that the global Timeout setting is set to 10 seconds, it closes itself after 10 seconds", function() {
mockGlobals.settings.Timeout = 10000;
notifications.showNotification(song);
$interval.flush(10001);
expect(NotificationObj.close).toHaveBeenCalled();
});
it("if we don't have the permission to display notifications, nothing happens", function() {
notifications.hasPermission.and.returnValue(false);
notifications.showNotification(song);
expect(NotificationObj.show).not.toHaveBeenCalled();
});
});
});

View file

@ -0,0 +1,64 @@
/**
* jamstash.page Module
*
* Set the page's title from anywhere, the angular way
*/
angular.module('jamstash.page', ['jamstash.settings', 'jamstash.utils'])
.factory('Page', ['$interval', 'globals', 'utils', function($interval, globals, utils){
'use strict';
var title = 'Jamstash';
var timer;
return {
title: function() { return title; },
setTitle: function(newTitle) {
title = newTitle;
return this;
},
setTitleSong: function(song) {
if (song.artist !== undefined && song.name !== undefined) {
title = utils.toHTML.un(song.artist) + " - " + utils.toHTML.un(song.name);
} else {
title = 'Jamstash';
}
if (globals.settings.ScrollTitle) {
this.scrollTitle();
}
return this;
},
scrollTitle: function() {
var shift = {
"left": function (a) {
a.push(a.shift());
},
"right": function (a) {
a.unshift(a.pop());
}
};
var opts = {
text: title,
dir: "left",
speed: 1200
};
var t = (opts.text).split("");
if (!t) {
return;
}
t.push(" ");
if (timer !== undefined) {
$interval.cancel(timer);
}
timer = $interval(function () {
var f = shift[opts.dir];
if (f) {
f(t);
title = t.join("");
}
}, opts.speed);
return this;
}
};
}]);

View file

@ -0,0 +1,54 @@
describe("Page service", function() {
'use strict';
var mockGlobals, Page, utils, $interval;
beforeEach(function() {
mockGlobals = {
settings: {
ScrollTitle: false
}
};
module('jamstash.page', function ($provide) {
$provide.value('globals', mockGlobals);
});
inject(function (_Page_, _utils_, _$interval_) {
Page = _Page_;
utils = _utils_;
$interval = _$interval_;
});
spyOn(utils.toHTML, "un").and.callFake(function (arg) { return arg; });
});
describe("Given a song,", function() {
var song;
beforeEach(function() {
song = {
artist: 'Merlyn Nurse',
name: 'Exsiccator tumble'
};
});
it("it displays its artist and its name as the page's title", function() {
Page.setTitleSong(song);
expect(Page.title()).toBe('Merlyn Nurse - Exsiccator tumble');
});
it("if the global setting 'ScrollTitle' is true, it scrolls the page title", function() {
spyOn(Page, "scrollTitle");
mockGlobals.settings.ScrollTitle = true;
Page.setTitleSong(song);
expect(Page.scrollTitle).toHaveBeenCalled();
});
});
it("Given a title, it can scroll it", function() {
Page.setTitle('unbeloved omnificent supergravitate').scrollTitle();
$interval.flush(1201);
expect(Page.title()).toBe('nbeloved omnificent supergravitate u');
});
});

View file

@ -0,0 +1,58 @@
'use strict';
/**
* jamstash.persistence Module
*
* Provides load, save and delete operations for the current song and queue.
* Data storage provided by HTML5 localStorage.
*/
angular.module('jamstash.persistence', ['jamstash.settings', 'jamstash.player.service', 'jamstash.notifications', 'angular-locker'])
.config(['lockerProvider', function (lockerProvider) {
lockerProvider.setDefaultDriver('local')
.setDefaultNamespace('jamstash')
.setEventsEnabled(false);
}])
.service('persistence', ['globals', 'player', 'notifications', 'locker', function(globals, player, notifications, locker){
this.loadTrackPosition = function () {
// Load Saved Song
var song = locker.get('CurrentSong');
if (song) {
player.load(song);
}
if (globals.settings.Debug) { console.log('Current Position Loaded from localStorage: ', song); }
};
this.saveTrackPosition = function (song) {
locker.put('CurrentSong', song);
if (globals.settings.Debug) { console.log('Saving Current Position: ', song); }
};
this.deleteTrackPosition = function () {
locker.forget('CurrentSong');
if (globals.settings.Debug) { console.log('Removing Current Position from localStorage'); }
};
this.loadQueue = function () {
// load Saved queue
var queue = locker.get('CurrentQueue');
if (queue) {
player.addSongs(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)'); }
}
};
this.saveQueue = function () {
locker.put('CurrentQueue', player.queue);
if (globals.settings.Debug) { console.log('Saving Queue: ' + player.queue.length + ' songs'); }
};
this.deleteQueue = function () {
locker.forget('CurrentQueue');
if (globals.settings.Debug) { console.log('Removing Play Queue from localStorage'); }
};
}]);

View file

@ -0,0 +1,126 @@
describe("Persistence service", function() {
'use strict';
var persistence, player, notifications, locker;
var song;
beforeEach(function() {
module('jamstash.persistence');
inject(function (_persistence_, _player_, _notifications_, _locker_) {
persistence = _persistence_;
player = _player_;
notifications = _notifications_;
locker = _locker_;
});
song = {
id: 8626,
name: 'Pectinatodenticulate',
artist: 'Isiah Hosfield',
album: 'Tammanyize'
};
player.queue = [];
});
describe("load from localStorage -", function() {
var fakeStorage;
beforeEach(function() {
fakeStorage = {};
spyOn(locker, "get").and.callFake(function(key) {
return fakeStorage[key];
});
});
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() {
fakeStorage = { 'CurrentSong': song };
persistence.loadTrackPosition();
expect(locker.get).toHaveBeenCalledWith('CurrentSong');
expect(player.load).toHaveBeenCalledWith(song);
});
it("Given that we didn't save anything in local Storage, it doesn't load anything", function() {
persistence.loadTrackPosition();
expect(locker.get).toHaveBeenCalledWith('CurrentSong');
expect(player.load).not.toHaveBeenCalled();
});
});
describe("loadQueue -", function() {
beforeEach(function() {
spyOn(notifications, "updateMessage");
spyOn(player, "addSongs").and.callFake(function (songs) {
// Update the queue length so that notifications work
player.queue.length += songs.length;
});
});
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 };
persistence.loadQueue();
expect(locker.get).toHaveBeenCalledWith('CurrentQueue');
expect(player.addSongs).toHaveBeenCalledWith(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() {
persistence.loadQueue();
expect(locker.get).toHaveBeenCalledWith('CurrentQueue');
expect(player.addSongs).not.toHaveBeenCalled();
expect(notifications.updateMessage).not.toHaveBeenCalled();
});
});
});
describe("save from localStorage -", function() {
beforeEach(function() {
spyOn(locker, "put");
});
it("it saves the current track's position in local Storage", function() {
persistence.saveTrackPosition(song);
expect(locker.put).toHaveBeenCalledWith('CurrentSong', song);
});
it("it saves the playing queue in local Storage", function() {
player.queue = [
{ id: 1245 },
{ id: 7465 },
{ id: 948 }
];
persistence.saveQueue();
expect(locker.put).toHaveBeenCalledWith('CurrentQueue', player.queue);
});
});
describe("remove from localStorage -", function() {
beforeEach(function() {
spyOn(locker, "forget");
});
it("it deletes the current track from local Storage", function() {
persistence.deleteTrackPosition();
expect(locker.forget).toHaveBeenCalledWith('CurrentSong');
});
it("it deletes the saved playing queue from local Storage", function() {
persistence.deleteQueue();
expect(locker.forget).toHaveBeenCalledWith('CurrentQueue');
});
});
});

View file

@ -1,415 +0,0 @@
angular.module('JamStash')
.service('player', ['$rootScope', '$window', 'utils', 'globals', 'model', 'notifications',
function ($rootScope, $window, utils, globals, model, notifications) {
'use strict';
var player1 = globals.Player1;
var player2 = '#playdeck_2';
var scrobbled = false;
var timerid = 0;
$rootScope.defaultPlay = function (data, event) {
if (typeof $(player1).data("jPlayer") == 'undefined') {
$rootScope.nextTrack();
}
if (typeof $(player1).data("jPlayer") != 'undefined' && globals.settings.Jukebox) {
if ($(player1).data("jPlayer").status.paused) {
$rootScope.sendToJukebox('start');
} else {
$rootScope.sendToJukebox('stop');
}
}
};
$rootScope.nextTrack = function () {
var next = getNextSong();
if (next) {
$rootScope.playSong(false, next);
}
};
$rootScope.previousTrack = function () {
var next = getNextSong(true);
if (next) {
$rootScope.playSong(false, next);
} else {
$rootScope.restartSong();
}
};
function getNextSong (previous) {
var song;
if (globals.settings.Debug) { console.log('Getting Next Song > ' + 'Queue length: ' + $rootScope.queue.length); }
if ($rootScope.queue.length > 0) {
angular.forEach($rootScope.queue, function (item, key) {
if (item.playing === true) {
song = item;
} else {
item.playing = false;
}
});
var index = $rootScope.queue.indexOf(song);
var next;
if (previous) {
next = $rootScope.queue[index - 1];
} else {
next = $rootScope.queue[index + 1];
}
if (typeof next != 'undefined') {
if (globals.settings.Debug) { console.log('Next Song: ' + next.id); }
return next;
} else {
return false;
}
} else {
return false;
}
}
function internalScrobbleSong(submission) {
if ($rootScope.loggedIn && submission) {
var id = $rootScope.playingSong.id;
if (globals.settings.Debug) { console.log('Scrobble Song: ' + id); }
$.ajax({
url: globals.BaseURL() + '/scrobble.view?' + globals.BaseParams() + '&id=' + id + "&submission=" + submission,
method: 'GET',
dataType: globals.settings.Protocol,
timeout: 10000,
success: function () {
scrobbled = true;
}
});
}
}
this.startSaveTrackPosition = function () {
if (globals.settings.SaveTrackPosition) {
if (timerid !== 0) {
clearInterval(timerid);
}
timerid = $window.setInterval(function () {
if (globals.settings.SaveTrackPosition) {
this.saveTrackPosition();
}
}, 30000);
}
};
this.toggleMute = function () {
//var audio = typeof $(player1).data("jPlayer") != 'undefined' ? true : false;
var audio = $(player1).data("jPlayer");
if (typeof audio != 'undefined') {
if (audio.options.muted) {
audio.options.muted = false;
} else {
audio.options.muted = true;
}
}
}
this.saveTrackPosition = function () {
//var audio = typeof $(player1).data("jPlayer") != 'undefined' ? true : false;
var audio = $(player1).data("jPlayer");
if (typeof audio != 'undefined') {
if (audio.status.currentTime > 0 && audio.status.paused === false) {
var song;
angular.forEach($rootScope.queue, function (item, key) {
if (item.playing === true) {
song = item;
}
});
if (song) {
var position = audio.status.currentTime;
if (position !== null) {
$('#action_SaveProgress').fadeTo("slow", 0).delay(500).fadeTo("slow", 1).delay(500).fadeTo("slow", 0).delay(500).fadeTo("slow", 1);
song.position = position;
// Save Queue
if (utils.browserStorageCheck()) {
try {
var songStr = angular.toJson(song);
localStorage.setItem('CurrentSong', songStr);
if (globals.settings.Debug) { console.log('Saving Current Position: ' + songStr); }
var html = localStorage.getItem('CurrentQueue');
if ($rootScope.queue.length > 0) {
var current = $rootScope.queue;
if (current != html) {
localStorage.setItem('CurrentQueue', angular.toJson(current));
if (globals.settings.Debug) { console.log('Saving Queue: ' + current.length + ' characters'); }
}
}
} catch (e) {
if (e == QUOTA_EXCEEDED_ERR) {
alert('Quota exceeded!');
}
}
} else {
if (globals.settings.Debug) { console.log('HTML5::loadStorage not supported on your browser'); }
}
}
}
}
} else {
if (globals.settings.Debug) { console.log('Saving Queue: No Audio Loaded'); }
}
};
this.loadTrackPosition = function () {
if (utils.browserStorageCheck()) {
// Load Saved Song
var song = angular.fromJson(localStorage.getItem('CurrentSong'));
if (song) {
$rootScope.playSong(true, song);
// Load Saved Queue
var items = angular.fromJson(localStorage.getItem('CurrentQueue'));
if (items) {
//$rootScope.queue = [];
$rootScope.queue = items;
if ($rootScope.queue.length > 0) {
notifications.updateMessage($rootScope.queue.length + ' Saved Song(s)', true);
}
if (globals.settings.Debug) { console.log('Play Queue Loaded From localStorage: ' + $rootScope.queue.length + ' song(s)'); }
}
}
} else {
if (globals.settings.Debug) { console.log('HTML5::loadStorage not supported on your browser'); }
}
};
this.deleteCurrentQueue = function (data) {
if (utils.browserStorageCheck()) {
localStorage.removeItem('CurrentQueue');
utils.setValue('CurrentSong', null, false);
if (globals.settings.Debug) { console.log('Removing Play Queue'); }
} else {
if (globals.settings.Debug) { console.log('HTML5::loadStorage not supported on your browser, ' + html.length + ' characters'); }
}
};
$rootScope.restartSong = function (loadonly, data) {
var audio = $(player1).data("jPlayer");
audio.play(0);
};
$rootScope.playSong = function (loadonly, data) {
if (globals.settings.Debug) { console.log('Play: ' + JSON.stringify(data, null, 2)); }
angular.forEach($rootScope.queue, function(item, key) {
item.playing = false;
});
data.playing = true;
data.selected = false;
if ($rootScope.playingSong != null && data.id != $rootScope.playingSong.id && $.fancybox.isOpen) {
utils.fancyboxOpenImage(data.coverartfull);
}
$rootScope.playingSong = data;
var id = data.id;
var url = data.url;
var position = data.position;
var title = data.name;
var album = data.album;
var artist = data.artist;
var suffix = data.suffix;
var specs = data.specs;
var coverartthumb = data.coverartthumb;
var coverartfull = data.coverartfull;
var starred = data.starred;
$('#playermiddle').css('visibility', 'visible');
$('#songdetails').css('visibility', 'visible');
if (globals.settings.Jukebox) {
$rootScope.addToJukebox(id);
$rootScope.loadjPlayer(player1, url, suffix, true, position);
} else {
$rootScope.loadjPlayer(player1, url, suffix, loadonly, position);
}
var spechtml = '';
var data = $(player1).data().jPlayer;
for (var i = 0; i < data.solutions.length; i++) {
var solution = data.solutions[i];
if (data[solution].used) {
spechtml += "<strong class=\"codesyntax\">" + solution + "</strong> is";
spechtml += " currently being used with<strong>";
angular.forEach(data[solution].support, function (format) {
if (data[solution].support[format]) {
spechtml += " <strong class=\"codesyntax\">" + format + "</strong>";
}
});
spechtml += "</strong> support";
}
}
$('#SMStats').html(spechtml);
scrobbled = false;
if ($rootScope.queue.length > 0) {
$('#queue').stop().scrollTo('#' + id, 400);
}
if (globals.settings.NotificationSong && !loadonly) {
notifications.showNotification(coverartthumb, utils.toHTML.un(title), utils.toHTML.un(artist + ' - ' + album), 'text', '#NextTrack');
}
if (globals.settings.ScrollTitle) {
utils.scrollTitle(utils.toHTML.un(artist) + ' - ' + utils.toHTML.un(title));
} else {
utils.setTitle(utils.toHTML.un(artist) + ' - ' + utils.toHTML.un(title));
}
//utils.safeApply();
if(!$rootScope.$root.$$phase) {
$rootScope.$apply();
}
};
$rootScope.loadjPlayer = function (el, url, suffix, loadonly, position) {
// jPlayer Setup
var volume = 1;
if (utils.getValue('Volume')) {
volume = parseFloat(utils.getValue('Volume'));
}
var audioSolution = "html,flash";
if (globals.settings.ForceFlash) {
audioSolution = "flash,html";
}
if (globals.settings.Debug) { console.log('Setting Audio Solution: ' + audioSolution); }
//var salt = Math.floor(Math.random() * 100000);
//url += '&salt=' + salt;
var muted = false;
if (globals.settings.Jukebox) { muted = true;}
$(el).jPlayer("destroy");
$.jPlayer.timeFormat.showHour = true;
$(el).jPlayer({
swfPath: "js/plugins/jplayer",
wmode: "window",
solution: audioSolution,
supplied: suffix,
volume: volume,
muted: muted,
errorAlerts: false,
warningAlerts: false,
cssSelectorAncestor: "",
cssSelector: {
play: ".PlayTrack",
pause: ".PauseTrack",
seekBar: "#audiocontainer .scrubber",
playBar: "#audiocontainer .progress",
mute: "#action_Mute",
unmute: "#action_UnMute",
volumeMax: "#action_VolumeMax",
currentTime: "#played",
duration: "#duration"
},
ready: function () {
console.log("File Suffix: " + suffix);
if (suffix == 'oga') {
$(this).jPlayer("setMedia", {
oga: url
});
} else if (suffix == 'm4a') {
$(this).jPlayer("setMedia", {
m4a: url
});
} else if (suffix == 'mp3') {
$(this).jPlayer("setMedia", {
mp3: url
});
}
if (!loadonly) { // Start playing
$(this).jPlayer("play");
} else { // Loadonly
//$('#' + songid).addClass('playing');
$(this).jPlayer("pause", position);
}
if (globals.settings.Debug) {
console.log('[jPlayer Version Info]');
utils.logObjectProperties($(el).data("jPlayer").version);
console.log('[HTML5 Debug Info]');
utils.logObjectProperties($(el).data("jPlayer").html);
console.log('[Flash Debug Info]');
utils.logObjectProperties($(el).data("jPlayer").flash);
console.log('[jPlayer Options Info]');
utils.logObjectProperties($(el).data("jPlayer").options);
}
},
timeupdate: function (event) {
// Scrobble song once percentage is reached
var p = event.jPlayer.status.currentPercentAbsolute;
if (!scrobbled && p > 30) {
if (globals.settings.Debug) { console.log('LAST.FM SCROBBLE - Percent Played: ' + p); }
internalScrobbleSong(true);
}
},
volumechange: function (event) {
utils.setValue('Volume', event.jPlayer.options.volume, true);
},
ended: function (event) {
if (globals.settings.Repeat) { // Repeat current track if enabled
$(this).jPlayer("play");
} else {
if (!getNextSong()) { // Action if we are at the last song in queue
if (globals.settings.LoopQueue) { // Loop to first track in queue if enabled
var next = $rootScope.queue[0];
$rootScope.playSong(false, next);
} else if (globals.settings.AutoPlay) { // Load more tracks if enabled
$rootScope.getRandomSongs('play', '', '');
notifications.updateMessage('Auto Play Activated...', true);
}
} else {
$rootScope.nextTrack();
}
}
},
error: function (event) {
var time = $(player1).data("jPlayer").status.currentTime;
$(player1).jPlayer("play", time);
if (globals.settings.Debug) {
console.log("Error Type: " + event.jPlayer.error.type);
console.log("Error Context: " + event.jPlayer.error.context);
console.log("Error Message: " + event.jPlayer.error.message);
console.log("Stream interrupted, retrying from position: " + time);
}
}
});
return;
};
this.playPauseSong = function () {
if (typeof $(player1).data("jPlayer") != 'undefined') {
if ($(player1).data("jPlayer").status.paused) {
$(player1).jPlayer("play");
} else {
$(player1).jPlayer("pause");
}
}
};
this.playVideo = function (id, bitrate) {
var w, h;
bitrate = parseInt(bitrate);
if (bitrate <= 600) {
w = 320; h = 240;
} else if (bitrate <= 1000) {
w = 480; h = 360;
} else {
w = 640; h = 480;
}
//$("#jPlayerSelector").jPlayer("option", "fullScreen", true);
$("#videodeck").jPlayer({
ready: function () {
/*
$.fancybox({
autoSize: false,
width: w + 10,
height: h + 10,
content: $('#videodeck')
});
*/
$(this).jPlayer("setMedia", {
m4v: 'https://&id=' + id + '&salt=83132'
}).jPlayer("play");
$('#videooverlay').show();
},
swfPath: "js/jplayer",
solution: "html, flash",
supplied: "m4v"
});
};
this.scrobbleSong = internalScrobbleSong;
this.rateSong = function (songid, rating) {
$.ajax({
url: baseURL + '/setRating.view?' + baseParams + '&id=' + songid + "&rating=" + rating,
method: 'GET',
dataType: protocol,
timeout: 10000,
success: function () {
updateMessage('Rating Updated!', true);
}
});
};
}]);

View file

@ -1,6 +1,6 @@
<li class="row song" ng-repeat="o in song" ng-click="selectSong(o)" ng-dblclick="playFrom($index)" ng-class="{'selected': o.selected, 'playing': o.playing}">
<div class="itemactions">
<!--<a class="add" href="" title="Add To Queue" ng-click="addSongToQueue(o)" stop-event="click"></a>-->
<a class="add" href="" title="Add To Queue" ng-click="addSongToQueue(o)" stop-event="click"></a>
<!--<a class="remove" href="" title="Remove Song" ng-click="removeSongFromQueue(o)" stop-event="click"></a>-->
<!--<a class="play" href="" title="Start Playing From This Song" ng-click="playFrom($index)" stop-event="click"></a>-->
<!--<a class="download" href="" title="Download Song" ng-click="download(o.id)"></a>-->
@ -18,4 +18,4 @@
<div class="albumblock floatleft" ng-show="!o.album">&nbsp;</div>
<div class="time floatleft">{{o.time}}</div>
<div class="clear"></div>
</li>
</li>

View file

@ -1,10 +1,10 @@
<li class="row song" ng-repeat="o in song" ng-click="selectSong(o)" ng-dblclick="playSong(false, o)" ng-class="{'selected': o.selected, 'playing': o.playing}">
<li class="row song" ng-repeat="o in song" ng-click="selectSong(o)" ng-dblclick="playSong(o)" ng-class="{'selected': o.selected, 'playing': o.playing}">
<div class="itemactions">
<a class="remove" href="" title="Remove Song" ng-click="removeSongFromQueue(o)" stop-event="click"></a>
<a href="" title="Favorite" ng-class="{'favorite': o.starred, 'rate': !o.starred}" ng-click="updateFavorite(o)" stop-event="click"></a>
<div class="clear"></div>
</div>
</div>
<div class="title floatleft" title="{{o.description}}" ng-bind-html="o.name"></div>
<div class="time floatleft">{{o.time}}</div>
<div class="clear"></div>
</li>
</li>

View file

@ -8,22 +8,6 @@ angular.module('jamstash.utils', ['jamstash.settings'])
.service('utils', ['$rootScope', 'globals', function ($rootScope, globals) {
'use strict';
this.fancyboxOpenImage = function (url) {
$.fancybox.open({
helpers : {
overlay : {
css : {
'background' : 'rgba(0, 0, 0, 0.15)'
}
}
},
hideOnContentClick: true,
type: 'image',
openEffect: 'none',
closeEffect: 'none',
href: url
});
};
this.safeApply = function (fn) {
var phase = $rootScope.$root.$$phase;
if (phase === '$apply' || phase === '$digest') {
@ -100,14 +84,6 @@ angular.module('jamstash.utils', ['jamstash.settings'])
break;
}
};
// HTML5
this.browserStorageCheck = function () {
if (typeof (localStorage) === 'undefined') {
return false;
} else {
return true;
}
};
this.timeToSeconds = function (time) {
var a = time.split(':'); // split it at the colons
var seconds;
@ -259,41 +235,6 @@ angular.module('jamstash.utils', ['jamstash.settings'])
var u = strurl.substring(0, strurl.indexOf('?'));
return u;
};
this.setTitle = function (text) {
if (text !== "") {
document.title = text;
}
};
var timer = 0;
this.scrollTitle = function (text) {
var shift = {
"left": function (a) {
a.push(a.shift());
},
"right": function (a) {
a.unshift(a.pop());
}
};
var opts = {
text: text,
dir: "left",
speed: 1200
};
var t = (opts.text || document.title).split("");
if (!t) {
return;
}
t.push(" ");
clearInterval(timer);
timer = setInterval(function () {
var f = shift[opts.dir];
if (f) {
f(t);
document.title = t.join("");
}
}, opts.speed);
};
this.parseVersionString = function (str) {
if (typeof (str) !== 'string') { return false; }
var x = str.split('.');
@ -353,4 +294,4 @@ angular.module('jamstash.utils', ['jamstash.settings'])
var newDate = months[month] + " " + dateParts[2] + ", " + dateParts[0];
return newDate;
};
}]);
}]);

View file

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

Before After
Before After

View file

@ -1,175 +1,121 @@
<!DOCTYPE HTML>
<html lang="en" ng-app="JamStash">
<head>
<meta http-equiv="Content-type" content="text/html; charset=UTF-8">
<meta name="description" content="HTML5 Audio Streamer for Subsonic, Archive.org browsing and streaming">
<meta name="keywords" content="Subsonic, Archive.org, Live Music Archive, HTML5 Audio, Music Streaming, Live Music">
<meta property="og:image" content="http://jamstash.com/images/fbpreview.png"/>
<meta name=viewport content="width=device-width, initial-scale=1">
<title>Jamstash</title>
<link href="images/favicon_32x32.ico" rel="shortcut icon" />
<link rel="icon" href="images/favicon_48x48.png" sizes="48x48"/>
<link rel="icon" href="images/favicon_32x32.png" sizes="32x32"/>
<!-- build:css(.) styles/vendor.min.css -->
<!-- bower:css -->
<link rel="stylesheet" href="bower_components/jplayer/skin/pink.flag/jplayer.pink.flag.css" />
<link rel="stylesheet" href="bower_components/fancybox/source/jquery.fancybox.css" />
<!-- endbower -->
<!-- endbuild -->
<!--<link href="vendor/jquery-split-pane.css" rel="stylesheet" />-->
<link href="styles/Style.css" rel="stylesheet" type="text/css" data-name="main" />
<link href="styles/Mobile.css" rel="stylesheet" type="text/css" data-name="main" />
<link href="" rel="stylesheet" type="text/css" data-name="theme" />
</head>
<body ng-controller="AppCtrl">
<div id="container">
<div id="header">
<div id="messages">
<span ng-attr-id="{{ 'msg_' + $index }}" class="message" ng-repeat="item in Messages track by $index" ng-bind-html="item"></span>
</div>
<div id="loading"></div>
<a id="jslogo" title="Jamstash" class="showQueue" href=""></a>
<a id="sslogo" target="_blank" ng-show="settings.Server" ng-href="{{settings.Server}}" title="{{settings.Server}}"></a>
<div id="globalactions">
<a href="" class="button" ng-click="toggleQueue()" title="Pin Queue"><img src="images/arrow_right_gl_8x8.png" /></a>
</div>
<div id="nav">
<ul class="tabs">
<li><a href="#/library" class="first" id="action_Library" title="Library" ng-class="{'active': isActive('/library')}"><img src="images/headphones_gd_16x14.png" /></a></li>
<li><a href="#/archive" id="action_Archive" class="" title="Archive.org - Live Music Archive" ng-class="{'active': isActive('/archive')}"><img src="images/archive_gd_16x16.png" /></a></li>
<li><a href="#/settings" id="action_Settings" class="last" title="Settings" ng-class="{'active': isActive('/settings')}"><img src="images/cog_16x16.png" /></a></li>
</ul>
</div>
</div>
<div id="content">
<!-- Main -->
<div ng-view></div>
<!-- Audio Player -->
<div class="clear"></div>
<div class="clear"></div>
</div><!-- end #content -->
<div id="SideBar">
<div class="headeractions">
<a class="buttonimg" title="Shuffle Queue" ng-click="queueShuffle()"><img src="images/fork_gd_11x12.png"></a>
<a class="buttonimg" id="action_Empty" title="Delete Queue" ng-click="queueEmpty()"><img src="images/trash_fill_gd_12x12.png"></a>
<a class="buttonimg" id="action_DeleteSelected" title="Remove Selected From Queue" ng-click="queueRemoveSelected()"><img src="images/x_11x11.png"></a>
</div>
<div class="header">Queue</div>
<div id="SideQueue">
<ul class="simplelist songlist noselect">
<div ng-repeat="song in [queue] track by $index" class="songs" ng-include src="'common/songs_lite.html'" sortable></div>
</ul>
<div class="colspacer"></div>
</div>
<!--
<div id="NowPlaying">
<div class="header"><img src="images/rss_12x12.png" /> Now Playing</div>
<div id="NowPlayingList"><span class="user">Loading...</span></div>
</div>
<div id="Chat">
<div class="header"><img src="images/chat_alt_stroke_12x12.png" /> Chat</div>
<div id="ChatMsgs"></div>
</div>
<div class="submit"><img src="images/comment_stroke_gl_12x11.png" /><input type="text" id="ChatMsg" class="chat" title="Hit [Enter] to Post" /></div>
-->
</div>
<!-- Player -->
<div id="player">
<div id="playercontainer">
<div id="playerleft" class="floatleft">
<div class="playeractions floatleft">
<a class="hover" id="PreviousTrack" title="Previous Track" ng-click="previousTrack()"><img src="images/first_alt_24x24.png" /></a>
<a class="hover PlayTrack" title="Play/Pause" ng-click="defaultPlay()"><img src="images/play_alt_24x32.png" /></a>
<a class="hover PauseTrack" title="Play/Pause" ng-click="defaultPlay()" style="display: none;"><img src="images/pause_alt_24x32.png" /></a>
<a class="hover" id="NextTrack" title="Next Track" ng-click="nextTrack()"><img src="images/last_alt_24x24.png" /></a>
</div>
<div id="songdetails">
<div id="coverart"><a ng-click="fancyboxOpenImage(playingSong.coverartfull)"><img ng-src="{{playingSong.coverartthumb}}" src="images/albumdefault_60.jpg" alt="" /></a></div>
<ul>
<li class="song" id="{{playingSong.id}}" ng-bind-html="playingSong.name" title="{{playingSong.specs}}"></li>
<li class="album" ng-bind-html="playingSong.album"></li>
</ul>
<div id="songdetails_controls">
<a href="" class="jukebox" title="Jukebox Mode [Beta]" ng-click="toggleSetting('Jukebox')" ng-class="{'hoverSelected': !settings.Jukebox }"></a>
<a href="" class="loop" title="Repeat" ng-click="toggleSetting('Repeat')" ng-class="{'hoverSelected': !settings.Repeat }"></a>
<a href="" id="action_SaveProgress" class="lock" title="Save Track Position: On" ng-show="settings.SaveTrackPosition"></a>
<a title="Favorite" href="" ng-class="{'favorite': playingSong.starred, 'rate': !playingSong.starred}" ng-click="updateFavorite(playingSong)" stop-event="click"></a>
<a href="" id="action_Mute" class="mute" title="Mute"></a>
<a href="" id="action_UnMute" class="unmute" title="Unmute" style="display: none;"></a>
<!--<div class="jp-volume-bar"><div class="jp-volume-bar-value"></div></div><a href="" id="action_VolumeMax" class="volume" title="Max Volume"></a>-->
</div>
</div>
<div id="playdeck_1"></div>
<div id="playdeck_2"></div>
<div id="submenu_CurrentPlaylist" class="submenu shadow" style="display: none;">
<table id="CurrentPlaylistPreviewContainer" class="simplelist songlist">
<thead></thead>
<tbody></tbody>
</table>
</div>
</div>
<div id="playermiddle">
<div id="audiocontainer">
<div class="audiojs" id="audio_wrapper0">
<div class="scrubber"><div class="progress"></div><div class="loaded"></div></div>
<div class="time"><em id="played">00:00</em>/<strong id="duration">00:00</strong></div>
<div class="error-message"></div>
</div>
</div>
<div id="preview"></div>
</div>
<div class="clear"></div>
</div>
</div>
</div> <!-- End container -->
<script>
(function (i, s, o, g, r, a, m) {
i['GoogleAnalyticsObject'] = r; i[r] = i[r] || function () {
(i[r].q = i[r].q || []).push(arguments)
}, i[r].l = 1 * new Date(); a = s.createElement(o),
m = s.getElementsByTagName(o)[0]; a.async = 1; a.src = g; m.parentNode.insertBefore(a, m)
})(window, document, 'script', '//www.google-analytics.com/analytics.js', 'ga');
ga('create', 'UA-40174100-1', 'jamstash.com');
ga('send', 'pageview');
</script>
<!-- build:js({.,app}) scripts/vendor.min.js -->
<!-- bower:js -->
<script src="bower_components/jquery/jquery.js"></script>
<script src="bower_components/angular/angular.js"></script>
<script src="bower_components/angular-route/angular-route.js"></script>
<script src="bower_components/angular-sanitize/angular-sanitize.js"></script>
<script src="bower_components/angular-cookies/angular-cookies.js"></script>
<script src="bower_components/angular-resource/angular-resource.js"></script>
<script src="bower_components/jquery-ui/ui/jquery-ui.js"></script>
<script src="bower_components/jplayer/dist/jplayer/jquery.jplayer.js"></script>
<script src="bower_components/fancybox/source/jquery.fancybox.js"></script>
<script src="bower_components/notify.js/notify.js"></script>
<script src="bower_components/jquery.scrollTo/jquery.scrollTo.js"></script>
<script src="bower_components/underscore/underscore.js"></script>
<script src="bower_components/angular-underscore/angular-underscore.js"></script>
<!-- endbower -->
<script src="vendor/jquery.base64.js"></script>
<script src="vendor/jquery.dateFormat-1.0.js"></script>
<!-- endbuild -->
<!-- our scripts -->
<!-- build:js(app) scripts/scripts.min.js -->
<script src="app.js"></script>
<script src="settings/settings-service.js"></script>
<script src="common/model-service.js"></script>
<script src="common/utils-service.js"></script>
<script src="common/notification-service.js"></script>
<script src="subsonic/subsonic-service.js"></script>
<script src="archive/archive-service.js"></script>
<script src="common/player-service.js"></script>
<script src="common/main-controller.js"></script>
<script src="settings/settings.js"></script>
<script src="subsonic/subsonic.js"></script>
<script src="archive/archive.js"></script>
<script src="queue/queue.js"></script>
<script src="common/filters.js"></script>
<script src="common/directives.js"></script>
<!-- endbuild -->
</body>
</html>
<!DOCTYPE HTML>
<html lang="en" ng-app="JamStash" ng-controller="AppController">
<head>
<meta http-equiv="Content-type" content="text/html; charset=UTF-8">
<meta name="description" content="HTML5 Audio Streamer for Subsonic, Archive.org browsing and streaming">
<meta name="keywords" content="Subsonic, Archive.org, Live Music Archive, HTML5 Audio, Music Streaming, Live Music">
<meta property="og:image" content="http://jamstash.com/images/fbpreview.png"/>
<meta name=viewport content="width=device-width, initial-scale=1">
<title ng-bind="Page.title()">Jamstash</title>
<link href="images/favicon_32x32.ico" rel="shortcut icon" />
<link rel="icon" href="images/favicon_48x48.png" sizes="48x48"/>
<link rel="icon" href="images/favicon_32x32.png" sizes="32x32"/>
<!-- build:css(.) styles/vendor.min.css -->
<!-- bower:css -->
<link rel="stylesheet" href="bower_components/jplayer/dist/skin/pink.flag/jplayer.pink.flag.css" />
<link rel="stylesheet" href="bower_components/fancybox/source/jquery.fancybox.css" />
<!-- endbower -->
<!-- endbuild -->
<link href="styles/Style.css" rel="stylesheet" type="text/css" data-name="main" />
<link href="styles/Mobile.css" rel="stylesheet" type="text/css" data-name="main" />
<link href="" rel="stylesheet" type="text/css" data-name="theme" />
</head>
<body>
<div id="container">
<div id="header">
<div id="messages">
<span ng-attr-id="{{ 'msg_' + $index }}" class="message" ng-repeat="item in Messages track by $index" ng-bind-html="item"></span>
</div>
<div id="loading"></div>
<a id="jslogo" title="Jamstash" class="showQueue" href=""></a>
<a id="sslogo" target="_blank" ng-show="settings.Server" ng-href="{{settings.Server}}" title="{{settings.Server}}"></a>
<div id="globalactions">
<a href="" class="button" ng-click="toggleQueue()" title="Pin Queue"><img src="images/arrow_right_gl_8x8.png" /></a>
</div>
<div id="nav">
<ul class="tabs">
<li><a href="#/library" class="first" id="action_Library" title="Library" ng-class="{'active': isActive('/library')}"><img src="images/headphones_gd_16x14.png" /></a></li>
<li><a href="#/archive" id="action_Archive" class="" title="Archive.org - Live Music Archive" ng-class="{'active': isActive('/archive')}"><img src="images/archive_gd_16x16.png" /></a></li>
<li><a href="#/settings" id="action_Settings" class="last" title="Settings" ng-class="{'active': isActive('/settings')}"><img src="images/cog_16x16.png" /></a></li>
</ul>
</div>
</div>
<div id="content">
<!-- Main -->
<div ng-view></div>
<!-- Audio Player -->
<div class="clear"></div>
<div class="clear"></div>
</div><!-- end #content -->
<div id="SideBar" ng-include src="'queue/queue.html'" ng-controller="QueueController">
<!--
<div id="NowPlaying">
<div class="header"><img src="images/rss_12x12.png" /> Now Playing</div>
<div id="NowPlayingList"><span class="user">Loading...</span></div>
</div>
<div id="Chat">
<div class="header"><img src="images/chat_alt_stroke_12x12.png" /> Chat</div>
<div id="ChatMsgs"></div>
</div>
<div class="submit"><img src="images/comment_stroke_gl_12x11.png" /><input type="text" id="ChatMsg" class="chat" title="Hit [Enter] to Post" /></div>
-->
</div>
<!-- Player -->
<div ng-include src="'player/player.html'" ng-controller="PlayerController"></div>
</div> <!-- End container -->
<script>
(function (i, s, o, g, r, a, m) {
i['GoogleAnalyticsObject'] = r; i[r] = i[r] || function () {
(i[r].q = i[r].q || []).push(arguments)
}, i[r].l = 1 * new Date(); a = s.createElement(o),
m = s.getElementsByTagName(o)[0]; a.async = 1; a.src = g; m.parentNode.insertBefore(a, m)
})(window, document, 'script', '//www.google-analytics.com/analytics.js', 'ga');
ga('create', 'UA-40174100-1', 'jamstash.com');
ga('send', 'pageview');
</script>
<!-- build:js({.,app}) scripts/vendor.min.js -->
<!-- bower:js -->
<script src="bower_components/jquery/jquery.js"></script>
<script src="bower_components/angular/angular.js"></script>
<script src="bower_components/angular-route/angular-route.js"></script>
<script src="bower_components/angular-sanitize/angular-sanitize.js"></script>
<script src="bower_components/angular-cookies/angular-cookies.js"></script>
<script src="bower_components/angular-resource/angular-resource.js"></script>
<script src="bower_components/jquery-ui/ui/jquery-ui.js"></script>
<script src="bower_components/jplayer/dist/jplayer/jquery.jplayer.js"></script>
<script src="bower_components/fancybox/source/jquery.fancybox.js"></script>
<script src="bower_components/notify.js/notify.js"></script>
<script src="bower_components/jquery.scrollTo/jquery.scrollTo.js"></script>
<script src="bower_components/underscore/underscore.js"></script>
<script src="bower_components/angular-underscore/angular-underscore.js"></script>
<script src="bower_components/angular-locker/dist/angular-locker.min.js"></script>
<!-- endbower -->
<script src="vendor/jquery.base64.js"></script>
<script src="vendor/jquery.dateFormat-1.0.js"></script>
<!-- endbuild -->
<!-- our scripts -->
<!-- build:js(app) scripts/scripts.min.js -->
<script src="app.js"></script>
<script src="settings/settings.js"></script>
<script src="settings/settings-service.js"></script>
<script src="common/model-service.js"></script>
<script src="common/utils-service.js"></script>
<script src="common/page-service.js"></script>
<script src="common/notification-service.js"></script>
<script src="common/persistence-service.js"></script>
<script src="common/main-controller.js"></script>
<script src="subsonic/subsonic.js"></script>
<script src="subsonic/subsonic-service.js"></script>
<script src="archive/archive.js"></script>
<script src="archive/archive-service.js"></script>
<script src="player/player.js"></script>
<script src="player/player-directive.js"></script>
<script src="player/player-service.js"></script>
<script src="queue/queue.js"></script>
<script src="common/filters.js"></script>
<script src="common/directives.js"></script>
<!-- endbuild -->
</body>
</html>

View file

@ -0,0 +1,205 @@
/**
* 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', 'jamstash.notifications', 'jamstash.persistence', 'jamstash.page'])
.directive('jplayer', ['$interval', 'player', 'globals', 'subsonic', 'notifications', 'persistence', 'Page',
function($interval, playerService, globals, subsonic, notifications, persistence, Page) {
'use strict';
return {
restrict: 'EA',
template: '<div></div>',
link: function(scope, element) {
//TODO: Hyz: Move to another directive that delegates either to jPlayer or to jukebox
$('.PlayTrack').on('click', function(event) {
event.preventDefault();
$(this).hide();
$('.PauseTrack').show();
});
//TODO: Hyz: Move to another directive that delegates either to jPlayer or to jukebox
$('.PauseTrack').on('click', function(event) {
event.preventDefault();
$(this).hide();
$('.PlayTrack').show();
});
var timerid;
var $player = element.children('div');
var audioSolution = 'html,flash';
if (globals.settings.ForceFlash) {
audioSolution = 'flash,html';
}
var updatePlayer = function() {
$.jPlayer.timeFormat.showHour = true;
$player.jPlayer({
// Flash fallback for outdated browser not supporting HTML5 audio/video tags
// TODO: Hyz: Replace in Grunt !
swfPath: 'bower_components/jplayer/dist/jplayer/jquery.jplayer.swf',
wmode: 'window',
solution: audioSolution,
supplied: 'mp3, oga, m4a',
preload: 'auto',
errorAlerts: false,
warningAlerts: false,
cssSelectorAncestor: '#player',
cssSelector: {
play: '.PlayTrack',
pause: '.PauseTrack',
seekBar: '#audiocontainer .scrubber',
playBar: '#audiocontainer .progress',
mute: '#action_Mute',
unmute: '#action_UnMute',
volumeMax: '#action_VolumeMax',
currentTime: '#played',
duration: '#duration'
},
setmedia: function() {
scope.scrobbled = false;
},
play: function() {
scope.revealControls();
},
ended: function() {
// We do this here and not on the service because we cannot create
// a circular dependency between the player and subsonic services
// TODO: Hyz: Should be fixed when it's subsonic-controller instead of subsonic-service that uses player-service
if(playerService.isLastSongPlaying() && globals.settings.AutoPlay) {
// Load more random tracks
subsonic.getRandomSongs().then(function (songs) {
playerService.addSongs(songs).songEnded();
notifications.updateMessage('Auto Play Activated...', true);
});
} else {
playerService.songEnded();
}
scope.$apply();
},
timeupdate: function (event) {
// Scrobble song once percentage is reached
var p = event.jPlayer.status.currentPercentAbsolute;
var isPlaying = !event.jPlayer.status.paused;
if (!scope.scrobbled && p > 30 && isPlaying) {
if (globals.settings.Debug) { console.log('LAST.FM SCROBBLE - Percent Played: ' + p); }
subsonic.scrobble(scope.currentSong);
scope.scrobbled = true;
}
},
error: function (event) {
var position = event.jPlayer.status.currentTime;
if(position) {
$player.jPlayer('play', position);
}
if (globals.settings.Debug) {
console.log("jPlayer error: ", event.jPlayer.error);
console.log("Stream interrupted, retrying from position: ", position);
}
}
});
};
scope.$watch(function () {
return playerService.getPlayingSong();
}, function (newSong) {
if(newSong !== undefined) {
scope.currentSong = newSong;
Page.setTitleSong(newSong);
if($.fancybox.isOpen) {
scope.fancyboxOpenImage(newSong.coverartfull);
}
var media = {};
if (newSong.suffix === 'oga') {
media= { oga: newSong.url };
} else if (newSong.suffix === 'm4a') {
media= { m4a: newSong.url };
} else if (newSong.suffix === 'mp3') {
media= { mp3: newSong.url };
}
$player.jPlayer('setMedia', media);
if (globals.settings.Jukebox) {
$player.jPlayer('mute', true);
scope.addToJukebox(newSong.id);
}
if (playerService.loadSong === true || globals.settings.Jukebox) {
// Do not play, only load
playerService.loadSong = false;
scope.revealControls();
$player.jPlayer('pause', newSong.position);
} else {
$player.jPlayer('play');
if(globals.settings.NotificationSong) {
notifications.showNotification(newSong);
}
}
}
});
scope.$watch(function () {
return playerService.restartSong;
}, function (newVal) {
if(newVal === true) {
$player.jPlayer('play', 0);
scope.scrobbled = false;
playerService.restartSong = false;
}
});
scope.$watch(function () {
return globals.settings.SaveTrackPosition;
}, function (newVal) {
if (newVal === true) {
scope.startSavePosition();
}
});
scope.revealControls = function () {
$('#playermiddle').css('visibility', 'visible');
$('#songdetails').css('visibility', 'visible');
};
scope.startSavePosition = function () {
if (globals.settings.SaveTrackPosition) {
if (timerid !== 0) {
$interval.cancel(timerid);
}
timerid = $interval(function () {
var audio = $player.data('jPlayer');
if (globals.settings.SaveTrackPosition && scope.currentSong !== undefined &&
audio !== undefined && audio.status.currentTime > 0 && audio.status.paused === false) {
$('#action_SaveProgress')
.fadeTo("slow", 0).delay(500)
.fadeTo("slow", 1).delay(500)
.fadeTo("slow", 0).delay(500)
.fadeTo("slow", 1);
scope.currentSong.position = audio.status.currentTime;
persistence.saveTrackPosition(scope.currentSong);
persistence.saveQueue();
}
}, 30000);
}
};
// Startup
timerid = 0;
scope.currentSong = {};
scope.scrobbled = false;
updatePlayer();
scope.startSavePosition();
//TODO: Hyz: Maybe move to another directive dedicated to the scrubber ?
$('#audiocontainer .scrubber').mouseover(function () {
$('.audiojs .scrubber').stop().animate({ height: '8px' });
});
$('#audiocontainer .scrubber').mouseout(function () {
$('.audiojs .scrubber').stop().animate({ height: '4px' });
});
} //end link
};
}]);

View file

@ -0,0 +1,301 @@
describe("jplayer directive", function() {
'use strict';
var element, scope, $player, playingSong, deferred,
playerService, mockGlobals, subsonic, notifications, persistence, Page, $interval;
beforeEach(function() {
// We redefine globals because in some tests we need to alter the settings
mockGlobals = {
settings: {
AutoPlay: false,
Jukebox: false,
NotificationSong: false,
SaveTrackPosition: false
}
};
// Redefined to avoid firing 'play' with a previous test song
playingSong = undefined;
module('jamstash.player.directive', function($provide) {
// Mock the player service
$provide.decorator('player', function($delegate) {
$delegate.restartSong = false;
$delegate.loadSong = false;
$delegate.getPlayingSong = jasmine.createSpy('getPlayingSong').and.callFake(function() {
return playingSong;
});
$delegate.nextTrack = jasmine.createSpy('nextTrack');
$delegate.songEnded = jasmine.createSpy('songEnded');
$delegate.isLastSongPlaying = jasmine.createSpy('isLastSongPlaying');
return $delegate;
});
$provide.value('globals', mockGlobals);
});
spyOn($.fn, "jPlayer").and.callThrough();
inject(function($rootScope, $compile, _$interval_, $q, _player_, _subsonic_, _notifications_, _persistence_, _Page_) {
playerService = _player_;
subsonic = _subsonic_;
notifications = _notifications_;
persistence = _persistence_;
$interval = _$interval_;
Page = _Page_;
// Compile the directive
scope = $rootScope.$new();
element = '<div id="playdeck_1" jplayer></div>';
element = $compile(element)(scope);
scope.$digest();
deferred = $q.defer();
});
spyOn(Page, "setTitleSong");
$.fancybox.isOpen = false;
$player = element.children('div');
});
describe("When the player service's current song changes,", function() {
beforeEach(function() {
// To avoid errors breaking the test, we stub jPlayer
$.fn.jPlayer.and.stub();
playingSong = {
id: 659,
url: 'https://gantry.com/antemarital/vigorless?a=oropharyngeal&b=killcrop#eviscerate',
suffix: 'mp3'
};
});
it("it sets jPlayer's media, stores the song for future scrobbling and sets the page title with the song", function() {
scope.$apply();
expect($.fn.jPlayer).toHaveBeenCalledWith('setMedia', {'mp3': 'https://gantry.com/antemarital/vigorless?a=oropharyngeal&b=killcrop#eviscerate'});
expect(scope.currentSong).toEqual(playingSong);
expect(Page.setTitleSong).toHaveBeenCalledWith(playingSong);
});
it("if the global setting Jukebox is true, it mutes jPlayer and adds the song to subsonic's Jukebox", function() {
mockGlobals.settings.Jukebox = true;
scope.addToJukebox = jasmine.createSpy("addToJukebox");
scope.$apply();
expect($player.jPlayer).toHaveBeenCalledWith('mute', true);
expect(scope.addToJukebox).toHaveBeenCalledWith(playingSong.id);
});
it("if the player service's loadSong flag is true, it does not play the song, it displays the player controls and sets the player to the song's supplied position", function() {
spyOn(scope, "revealControls");
playerService.loadSong = true;
playingSong.position = 42.2784;
scope.$apply();
expect($player.jPlayer).not.toHaveBeenCalledWith('play');
expect($player.jPlayer).toHaveBeenCalledWith('pause', 42.2784);
expect(playerService.loadSong).toBeFalsy();
expect(scope.revealControls).toHaveBeenCalled();
});
describe("if the player service's loadSong flag is false,", function() {
it("it plays the song", function() {
playerService.loadSong = false;
scope.$apply();
expect($player.jPlayer).toHaveBeenCalledWith('play');
expect(playerService.loadSong).toBeFalsy();
});
it("if the global setting NotificationSong is true, it displays a notification", function() {
spyOn(notifications, "showNotification");
mockGlobals.settings.NotificationSong = true;
scope.$apply();
expect(notifications.showNotification).toHaveBeenCalledWith(playingSong);
});
});
it("if fancybox is open, it sets it up with the new song's cover art", function() {
$.fancybox.isOpen = true;
scope.fancyboxOpenImage = jasmine.createSpy("fancyboxOpenImage");
scope.$apply();
expect(scope.fancyboxOpenImage).toHaveBeenCalledWith(playingSong.coverartfull);
});
it("if the song's suffix is 'm4a', it sets jPlayer up with this format", function() {
playingSong.suffix = 'm4a';
scope.$apply();
expect($.fn.jPlayer).toHaveBeenCalledWith('setMedia', {'m4a': 'https://gantry.com/antemarital/vigorless?a=oropharyngeal&b=killcrop#eviscerate'});
});
it("if the song's suffix is 'oga', it sets jPlayer up with this format", function() {
playingSong.suffix = 'oga';
scope.$apply();
expect($.fn.jPlayer).toHaveBeenCalledWith('setMedia', {'oga': 'https://gantry.com/antemarital/vigorless?a=oropharyngeal&b=killcrop#eviscerate'});
});
});
it("When the player service's restartSong flag is true, it restarts the current song, resets the restart flag to false and resets the scrobbled flag to false", function() {
$.fn.jPlayer.and.stub();
playerService.restartSong = true;
scope.scrobbled = true;
scope.$apply();
expect($player.jPlayer).toHaveBeenCalledWith('play', 0);
expect(playerService.restartSong).toBeFalsy();
expect(scope.scrobbled).toBeFalsy();
});
describe("When jplayer has finished the current song,", function() {
var ended;
beforeEach(function() {
ended = $.jPlayer.event.ended;
});
it("it notifies the player service that the song has ended", function() {
$player.trigger(ended);
expect(playerService.songEnded).toHaveBeenCalled();
});
it("given that the last song of the queue is playing and that the global setting AutoPlay is true, it asks subsonic for random tracks, notifies the player service that the song has ended and notifies the user", function() {
mockGlobals.settings.AutoPlay = true;
spyOn(subsonic, "getRandomSongs").and.returnValue(deferred.promise);
spyOn(notifications, "updateMessage");
playerService.isLastSongPlaying.and.returnValue(true);
$player.trigger(ended);
deferred.resolve();
scope.$apply();
expect(playerService.isLastSongPlaying).toHaveBeenCalled();
expect(subsonic.getRandomSongs).toHaveBeenCalled();
expect(notifications.updateMessage).toHaveBeenCalledWith('Auto Play Activated...', true);
});
});
it("When jPlayer gets new media, it resets the scrobbled flag to false", function() {
scope.scrobbled = true;
var e = $.jPlayer.event.setmedia;
$player.trigger(e);
expect(scope.scrobbled).toBeFalsy();
});
it("When jPlayer throws an error, it tries to restart playback at the last position", function() {
// Fake jPlayer's internal _trigger event because I can't trigger a manual error
var fakejPlayer = {
element: $player,
status: { currentTime: 10.4228 }
};
var error = $.jPlayer.event.error;
$.jPlayer.prototype._trigger.call(fakejPlayer, error);
expect($player.jPlayer).toHaveBeenCalledWith('play', 10.4228);
});
it("When jPlayer starts to play the current song, it displays the player controls", function() {
spyOn(scope, "revealControls");
var e = $.jPlayer.event.play;
$player.trigger(e);
expect(scope.revealControls).toHaveBeenCalled();
});
it("revealControls - it displays the song details and the player controls", function() {
$.fn.jPlayer.and.stub();
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();
});
});
it("When the global setting SaveTrackPosition becomes true, it starts saving the current song's position", function() {
spyOn(scope, "startSavePosition");
mockGlobals.settings.SaveTrackPosition = true;
scope.$apply();
expect(scope.startSavePosition).toHaveBeenCalled();
});
describe("Given that the global setting SaveTrackPosition is true,", function() {
beforeEach(function() {
mockGlobals.settings.SaveTrackPosition = true;
spyOn(persistence, "saveTrackPosition");
spyOn(persistence, "saveQueue");
});
it("every 30 seconds, it saves the current song's position and the playing queue", function() {
scope.currentSong = { id: 419 };
$player.data('jPlayer').status.currentTime = 35.3877;
$player.data('jPlayer').status.paused = false;
scope.startSavePosition();
$interval.flush(30001);
expect(scope.currentSong.position).toBe(35.3877);
expect(persistence.saveTrackPosition).toHaveBeenCalledWith(scope.currentSong);
expect(persistence.saveQueue).toHaveBeenCalled();
});
it("if the song is not playing, it does not save anything", function() {
$player.data('jPlayer').status.currentTime = 0.0;
$player.data('jPlayer').status.paused = true;
scope.startSavePosition();
$interval.flush(30001);
expect(persistence.saveTrackPosition).not.toHaveBeenCalled();
expect(persistence.saveQueue).not.toHaveBeenCalled();
});
it("if there was already a watcher, it clears it before adding a new one", function() {
spyOn($interval, "cancel");
scope.startSavePosition();
scope.startSavePosition();
expect($interval.cancel).toHaveBeenCalled();
});
});
});

View file

@ -0,0 +1,137 @@
/**
* jamstash.player.service Module
*
* 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'])
.factory('player', ['globals', function (globals) {
'use strict';
var player = {
// playingIndex and playingSong aren't meant to be used, they only are public for unit-testing purposes
_playingIndex: -1,
_playingSong: undefined,
queue: [],
restartSong: false,
loadSong: false,
play: function(song) {
// Find the song's index in the queue, if it's in there
var index = player.indexOfSong(song);
player._playingIndex = (index !== undefined) ? index : -1;
if(player._playingSong === song) {
// We call restart because the _playingSong hasn't changed and the directive won't
// play the song again
player.restart();
} else {
player._playingSong = song;
}
},
playFirstSong: function() {
player._playingIndex = 0;
player.play(player.queue[0]);
},
load: function(song) {
player.loadSong = true;
player.play(song);
},
restart: function() {
player.restartSong = true;
},
// Called from the player directive at the end of the current song
songEnded: function() {
if (globals.settings.Repeat) {
// repeat current track
player.restart();
} else if (player.isLastSongPlaying() === true) {
if (globals.settings.LoopQueue) {
// Loop to first track in queue
player.playFirstSong();
}
} else {
player.nextTrack();
}
},
nextTrack: function() {
// Find the song's index in the queue, in case it changed (with a drag & drop)
var index = player.indexOfSong(player._playingSong);
player._playingIndex = (index !== undefined) ? index : -1;
if((player._playingIndex + 1) < player.queue.length) {
var nextTrack = player.queue[player._playingIndex + 1];
player._playingIndex++;
player.play(nextTrack);
}
},
previousTrack: function() {
// Find the song's index in the queue, in case it changed (with a drag & drop)
var index = player.indexOfSong(player._playingSong);
player._playingIndex = (index !== undefined) ? index : -1;
if((player._playingIndex - 1) > 0) {
var previousTrack = player.queue[player._playingIndex - 1];
player._playingIndex--;
player.play(previousTrack);
} else if (player.queue.length > 0) {
player.playFirstSong();
}
},
emptyQueue: function() {
player.queue = [];
return player;
},
shuffleQueue: function() {
player.queue = _(player.queue).shuffle();
return player;
},
addSong: function(song) {
player.queue.push(song);
return player;
},
addSongs: function (songs) {
player.queue = player.queue.concat(songs);
return player;
},
removeSong: function(song) {
var index = player.queue.indexOf(song);
player.queue.splice(index, 1);
return player;
},
removeSongs: function (songs) {
player.queue = _(player.queue).difference(songs);
return player;
},
getPlayingSong: function() {
return player._playingSong;
},
isLastSongPlaying: function() {
return ((player._playingIndex +1) === player.queue.length);
},
indexOfSong: function(song) {
for (var i = player.queue.length - 1; i >= 0; i--) {
if (angular.equals(song, player.queue[i])) {
return i;
}
}
return undefined;
}
};
return player;
}]);

View file

@ -0,0 +1,280 @@
describe("Player service -", function() {
'use strict';
var player, mockGlobals, firstSong, secondSong, thirdSong, newSong;
beforeEach(function() {
// We redefine globals because in some tests we need to alter the settings
mockGlobals = {
settings: {
Repeat: false,
LoopQueue: false
}
};
module('jamstash.player.service', function ($provide) {
$provide.value('globals', mockGlobals);
});
inject(function (_player_) {
player = _player_;
});
});
describe("Given that I have 3 songs in my playing queue,", function() {
beforeEach(function() {
firstSong = {
id: 6726,
name: 'Guarauno',
artist: 'Carlyn Pollack',
album: 'Arenig'
};
secondSong = {
id: 2452,
name: 'Michoacan',
artist: 'Lura Jeppsen',
album: 'dioptrical'
};
thirdSong = {
id: 574,
name: 'Celtidaceae',
artist: 'Willard Steury',
album: 'redux'
};
player.queue = [firstSong, secondSong, thirdSong];
newSong = {
id: 3573,
name: 'Tritopatores',
artist: 'Alysha Rocher',
album: 'uncombinably'
};
});
describe("when I call nextTrack", function() {
beforeEach(function() {
spyOn(player, "play");
});
it("and no song is playing, it plays the first song", function() {
player.nextTrack();
expect(player._playingIndex).toBe(0);
expect(player.play).toHaveBeenCalledWith(player.queue[0]);
});
it("and the first song is playing, it plays the second song", function() {
player._playingIndex = 0;
player._playingSong = firstSong;
player.nextTrack();
expect(player._playingIndex).toBe(1);
expect(player.play).toHaveBeenCalledWith(player.queue[1]);
});
it("and the last song is playing, it does nothing", function() {
player._playingIndex = 2;
player._playingSong = thirdSong;
player.nextTrack();
expect(player._playingIndex).toBe(2);
expect(player.play).not.toHaveBeenCalled();
});
});
describe("when I call previousTrack", function() {
beforeEach(function() {
spyOn(player, "play");
});
it("and no song is playing, it plays the first song", function() {
player.previousTrack();
expect(player._playingIndex).toBe(0);
expect(player.play).toHaveBeenCalledWith(player.queue[0]);
});
it("and the first song is playing, it restarts the first song", function() {
player._playingIndex = 0;
player._playingSong = firstSong;
player.previousTrack();
expect(player._playingIndex).toBe(0);
expect(player.play).toHaveBeenCalledWith(player.queue[0]);
});
it("and the last song is playing, it plays the second song", function() {
player._playingIndex = 2;
player._playingSong = thirdSong;
player.previousTrack();
expect(player._playingIndex).toBe(1);
expect(player.play).toHaveBeenCalledWith(player.queue[1]);
});
});
it("when I call playFirstSong, it plays the first song and updates the playing index", function() {
spyOn(player, "play");
player.playFirstSong();
expect(player._playingIndex).toBe(0);
expect(player.play).toHaveBeenCalledWith(player.queue[0]);
});
it("when I play the second song, it finds its index in the playing queue and updates the playing index", function() {
player.play(secondSong);
expect(player._playingIndex).toBe(1);
});
it("when I play a song that isn't in the playing queue, the next song will be the first song of the playing queue", function() {
player.play(newSong);
expect(player._playingIndex).toBe(-1);
});
it("when I call emptyQueue, it empties the playing queue", function() {
player.emptyQueue();
expect(player.queue).toEqual([]);
});
it("when I get the index of the first song, it returns 0", function() {
expect(player.indexOfSong(firstSong)).toBe(0);
});
it("when I get the index of a song that isn't in the playing queue, it returns undefined", function() {
expect(player.indexOfSong(newSong)).toBeUndefined();
});
it("when I add a song to the queue, it is appended to the end of the playing queue", function() {
player.addSong(newSong);
expect(player.queue).toEqual([firstSong, secondSong, thirdSong, newSong]);
});
it("when I add 3 songs to the queue, they are appended to the end of the playing queue", function() {
var secondNewSong = {id: 6338, name: 'Preconquest', artist: 'France Wisley', album: 'Unmix'};
var thirdNewSong = {id: 3696, name: 'Cetene', artist: 'Hilario Masley', album: 'Gonapophysal'};
player.addSongs([newSong, secondNewSong, thirdNewSong]);
expect(player.queue).toEqual([firstSong, secondSong, thirdSong, newSong, secondNewSong, thirdNewSong]);
});
it("when I remove the second song, the playing queue is now only the first and third song", function() {
player.removeSong(secondSong);
expect(player.queue).toEqual([firstSong, thirdSong]);
});
it("when I remove the first and third songs, the playing queue is now only the second song", function() {
player.removeSongs([firstSong, thirdSong]);
expect(player.queue).toEqual([secondSong]);
});
it("when the first song is playing, isLastSongPlaying returns false", function() {
player._playingIndex = 0;
expect(player.isLastSongPlaying()).toBeFalsy();
});
it("when the third song is playing, isLastSongPlaying returns true", function() {
player._playingIndex = 2;
expect(player.isLastSongPlaying()).toBeTruthy();
});
it("and the current song is not the last, when the current song ends, it plays the next song in queue", function() {
spyOn(player, "nextTrack");
player._playingIndex = 0;
player.songEnded();
expect(player.nextTrack).toHaveBeenCalled();
});
it("and that the 'Repeat song' setting is true, when the current song ends, it restarts it", function() {
spyOn(player, "restart");
mockGlobals.settings.Repeat = true;
player.songEnded();
expect(player.restart).toHaveBeenCalled();
});
describe("and the current song is the last of the queue, when the current song ends,", function() {
beforeEach(function() {
player._playingIndex = 2;
});
it("if the 'Repeat queue' setting is true, it plays the first song of the queue", function() {
spyOn(player, "playFirstSong");
mockGlobals.settings.LoopQueue = true;
player.songEnded();
expect(player.playFirstSong).toHaveBeenCalled();
});
it("it does not play anything", function() {
spyOn(player, "nextTrack").and.callThrough();
player.songEnded();
expect(player._playingIndex).toBe(2);
expect(player.nextTrack).not.toHaveBeenCalled();
});
});
});
describe("Given a song", function() {
var song;
beforeEach(function() {
song = {
id: 6726,
name: 'Guarauno',
artist: 'Carlyn Pollack',
album: 'Arenig',
playing: false
};
});
it("when the song was playing and I play it again, it restarts the current song", function() {
spyOn(player, "restart");
player.play(song);
player.play(song);
expect(player.restart).toHaveBeenCalled();
});
it("when I restart the current song, the flag for the directive is set", 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 my playing queue is empty", function() {
beforeEach(function() {
player.queue = [];
player._playingIndex = -1;
spyOn(player, "play");
});
it("when I call nextTrack, it does nothing", function() {
player.nextTrack();
expect(player.play).not.toHaveBeenCalled();
expect(player._playingIndex).toBe(-1);
});
it("when I call previousTrack, it does nothing", function() {
player.previousTrack();
expect(player.play).not.toHaveBeenCalled();
expect(player._playingIndex).toBe(-1);
});
});
});

59
app/player/player.html Normal file
View file

@ -0,0 +1,59 @@
<div id="player">
<div id="playercontainer">
<div id="playerleft" class="floatleft">
<div class="playeractions floatleft">
<a class="hover" id="PreviousTrack" title="Previous Track" ng-click="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="play()">
<img src="images/play_alt_24x24.png" height="24" width="24" alt="Play" />
</a>
<a class="hover PauseTrack" title="Play/Pause" style="display: none;" ng-click="pause()">
<img src="images/pause_alt_24x24.png" height="24" width="24" alt="Pause" />
</a>
<a class="hover" id="NextTrack" title="Next Track" ng-click="nextTrack()">
<img src="images/last_alt_24x24.png" height="24" width="24" alt="Next track" />
</a>
</div>
<div id="songdetails">
<div id="coverart">
<a ng-click="fancyboxOpenImage(getPlayingSong().coverartfull)">
<img ng-src="{{getPlayingSong().coverartthumb}}" src="images/albumdefault_60.jpg" height="30" width="30" />
</a>
</div>
<ul>
<li class="song" id="{{getPlayingSong().id}}" ng-bind-html="getPlayingSong().name" title="{{getPlayingSong().specs}}"></li>
<li class="album" ng-bind-html="getPlayingSong().album"></li>
</ul>
<div id="songdetails_controls">
<a href="" class="jukebox" title="Jukebox Mode [Beta]" ng-click="toggleSetting('Jukebox')" ng-class="{'hoverSelected': !settings.Jukebox }"></a>
<a href="" class="loop" title="Repeat" ng-click="toggleSetting('Repeat')" ng-class="{'hoverSelected': !settings.Repeat }"></a>
<a href="" id="action_SaveProgress" class="lock" title="Save Track Position: On" ng-show="settings.SaveTrackPosition"></a>
<a title="Favorite" href="" ng-class="{'favorite': getPlayingSong().starred, 'rate': !getPlayingSong().starred}" ng-click="updateFavorite(getPlayingSong())" stop-event="click"></a>
<a href="" id="action_Mute" class="mute" title="Mute"></a>
<a href="" id="action_UnMute" class="unmute" title="Unmute" style="display: none;"></a>
<!--<div class="jp-volume-bar"><div class="jp-volume-bar-value"></div></div><a href="" id="action_VolumeMax" class="volume" title="Max Volume"></a>-->
</div>
</div>
<div id="playdeck_1" jplayer></div>
<div id="playdeck_2"></div>
<div id="submenu_CurrentPlaylist" class="submenu shadow" style="display: none;">
<table id="CurrentPlaylistPreviewContainer" class="simplelist songlist">
<thead></thead>
<tbody></tbody>
</table>
</div>
</div>
<div id="playermiddle">
<div id="audiocontainer">
<div class="audiojs" id="audio_wrapper0">
<div class="scrubber"><div class="progress"></div><div class="loaded"></div></div>
<div class="time"><em id="played">00:00</em>/<strong id="duration">00:00</strong></div>
<div class="error-message"></div>
</div>
</div>
<div id="preview"></div>
</div>
<div class="clear"></div>
</div>
</div>

39
app/player/player.js Normal file
View file

@ -0,0 +1,39 @@
/**
* jamstash.player Module
*
* Enables basic control of the player : play, pause, previous track, next track.
* Also provides the currently playing song's info through the scope so it can be displayed next to
* the player controls.
*/
angular.module('jamstash.player.controller', ['jamstash.player.service', 'jamstash.player.directive'])
.controller('PlayerController', ['$scope', 'player', 'globals',
function($scope, player, globals){
'use strict';
$scope.getPlayingSong = function () {
return player.getPlayingSong();
};
$scope.play = function () {
if (globals.settings.Jukebox) {
$scope.sendToJukebox('start');
}
};
$scope.pause = function () {
if (globals.settings.Jukebox) {
$scope.sendToJukebox('stop');
}
};
$scope.previousTrack = function () {
player.previousTrack();
};
$scope.nextTrack = function () {
player.nextTrack();
};
//TODO: Hyz: updateFavorite - leave in rootScope ?
}]);

40
app/player/player_test.js Normal file
View file

@ -0,0 +1,40 @@
describe("Player controller", function() {
'use strict';
var player, scope;
beforeEach(function() {
module('jamstash.player.controller');
inject(function ($controller, $rootScope) {
scope = $rootScope.$new();
player = jasmine.createSpyObj("player", ["getPlayingSong", "previousTrack", "nextTrack"]);
$controller('PlayerController', {
$scope: scope,
player: player
});
});
});
it("When I get the currently playing song, it asks the player service", function() {
scope.getPlayingSong();
expect(player.getPlayingSong).toHaveBeenCalled();
});
it("When I get the previous track, it uses the player service", function() {
scope.previousTrack();
expect(player.previousTrack).toHaveBeenCalled();
});
it("When I get the next track, it uses the player service", function() {
scope.nextTrack();
expect(player.nextTrack).toHaveBeenCalled();
});
// TODO: updateFavorite
});

View file

@ -1,11 +1,11 @@

angular.module('JamStash')
.controller('PodcastCtrl', ['$scope', '$rootScope', function PodcastCtrl($scope, $rootScope) {
.controller('PodcastController', ['$scope', '$rootScope', function ($scope, $rootScope) {
'use strict';
$rootScope.song = [];
/* Launch on Startup */
$scope.getPodcasts();
/* End Startup */
}]);
}]);

View file

@ -1,5 +1,24 @@
<div id="queue" class="tabcontent">
<div class="section fullsection floatleft">
<ul class="songlist simplelist noselect" ng-if="song.length > 0" ng-include src="'common/songs.html'" sortable></ul>
</div>
</div>
<div class="headeractions">
<a class="buttonimg" title="Shuffle Queue" ng-click="shuffleQueue()"><img src="images/fork_gd_11x12.png"></a>
<a class="buttonimg" title="Delete Queue" ng-click="emptyQueue()"><img src="images/trash_fill_gd_12x12.png"></a>
<a class="buttonimg" title="Remove Selected From Queue" ng-click="removeSelectedSongsFromQueue()"><img src="images/x_11x11.png"></a>
</div>
<div class="header">Queue</div>
<div id="SideQueue">
<ul class="simplelist songlist noselect">
<div ng-repeat="song in [player.queue] track by $index" class="songs" sortable>
<li class="row song id{{o.id}}" ng-repeat="o in song" ng-click="selectSong(o)" ng-dblclick="playSong(o)" ng-class="{'selected': o.selected, 'playing': isPlayingSong(o)}">
<div class="itemactions">
<a class="remove" href="" title="Remove Song" ng-click="removeSongFromQueue(o)" stop-event="click"></a>
<a href="" title="Favorite" ng-class="{'favorite': o.starred, 'rate': !o.starred}" ng-click="updateFavorite(o)" stop-event="click"></a>
<div class="clear"></div>
</div>
<div class="title floatleft" title="{{o.description}}" ng-bind-html="o.name"></div>
<div class="time floatleft">{{o.time}}</div>
<div class="clear"></div>
</li>
</div>
</ul>
<div class="colspacer"></div>
</div>

View file

@ -1,10 +1,68 @@
angular.module('JamStash')
/**
* jamstash.queue.controller Module
*
* Manages the playing queue. Gives access to the player service's queue-related functions,
* like adding, removing and shuffling the queue.
*/
angular.module('jamstash.queue.controller', ['jamstash.player.service'])
.controller('QueueCtrl', ['$scope', '$rootScope', '$routeParams', '$location', 'utils', 'globals',
function QueueCtrl($scope, $rootScope, $routeParams, $location, utils, globals) {
.controller('QueueController', ['$scope', 'globals', 'player',
function ($scope, globals, player) {
'use strict';
$scope.settings = globals.settings;
$scope.song = $rootScope.queue;
//angular.copy($rootScope.queue, $scope.song);
$scope.itemType = 'pl';
$scope.player = player;
$scope.playSong = function (song) {
player.play(song);
};
$scope.emptyQueue = function() {
player.emptyQueue();
//TODO: Hyz: Shouldn't it be in a directive ?
$.fancybox.close();
};
$scope.shuffleQueue = function() {
player.shuffleQueue();
};
$scope.addSongToQueue = function(song) {
player.addSong(song);
};
$scope.removeSongFromQueue = function(song) {
player.removeSong(song);
};
$scope.removeSelectedSongsFromQueue = function () {
player.removeSongs($scope.selectedSongs);
};
$scope.isPlayingSong = function (song) {
return angular.equals(song, player.getPlayingSong());
};
$scope.$watch(function () {
return player.getPlayingSong();
}, function (newSong) {
if(newSong !== undefined) {
//TODO: Hyz: Shouldn't it be in a directive ?
$('#SideBar').stop().scrollTo('.song.id' + newSong.id, 400);
}
});
/**
* Change the queue's order through jQuery UI's sortable
*/
$scope.dragStart = function (e, ui) {
ui.item.data('start', ui.item.index());
};
$scope.dragEnd = function (e, ui) {
var start = ui.item.data('start'),
end = ui.item.index();
player.queue.splice(end, 0, player.queue.splice(start, 1)[0]);
};
//TODO: Hyz: updateFavorite - leave in rootScope ?
}]);

134
app/queue/queue_test.js Normal file
View file

@ -0,0 +1,134 @@
describe("Queue controller", function() {
'use strict';
var player, scope;
var song;
beforeEach(function() {
module('jamstash.queue.controller');
inject(function ($controller, $rootScope, globals, _player_) {
scope = $rootScope.$new();
player = _player_;
$controller('QueueController', {
$scope: scope,
globals: globals,
player: player
});
});
song = { id: 7310 };
player.queue = [];
});
it("When I play a song, it calls play in the player service", function() {
spyOn(player, "play");
scope.playSong(song);
expect(player.play).toHaveBeenCalledWith(song);
});
it("When I empty the queue, it calls emptyQueue in the player service and closes fancybox", function() {
spyOn(player, "emptyQueue");
spyOn($.fancybox, "close");
scope.emptyQueue();
expect(player.emptyQueue).toHaveBeenCalled();
expect($.fancybox.close).toHaveBeenCalled();
});
it("When I shuffle the queue, it calls shuffleQueue in the player service", function() {
spyOn(player, "shuffleQueue");
scope.shuffleQueue();
expect(player.shuffleQueue).toHaveBeenCalled();
});
it("When I add one song to the queue, it calls addSong in the player service", function() {
spyOn(player, "addSong");
scope.addSongToQueue(song);
expect(player.addSong).toHaveBeenCalledWith(song);
});
it("When I remove a song from the queue, it calls removeSong in the player service", function() {
spyOn(player, "removeSong");
scope.removeSongFromQueue(song);
expect(player.removeSong).toHaveBeenCalledWith(song);
});
it("When I remove all the selected songs from the queue, it calls removeSongs in the player service", function() {
spyOn(player, "removeSongs");
var secondSong = { id: 6791 };
scope.selectedSongs = [song, secondSong];
scope.removeSelectedSongsFromQueue();
expect(player.removeSongs).toHaveBeenCalledWith([song, secondSong]);
});
it("asks the player service if a given song is the currently playing song", function() {
spyOn(player, "getPlayingSong").and.returnValue(song);
expect(scope.isPlayingSong(song)).toBeTruthy();
expect(player.getPlayingSong).toHaveBeenCalled();
});
it("when the player service's current song changes, it scrolls the queue to display it", function() {
spyOn(player, "getPlayingSong").and.callFake(function() {
return song;
});
spyOn($.fn, "scrollTo");
scope.$apply();
expect($.fn.scrollTo).toHaveBeenCalled();
});
describe("reorders the queue by drag and drop - ", function() {
var mockUI;
beforeEach(function() {
player.queue = [
{id: 2246},
{id: 8869},
{id: 285}
];
mockUI = {
item: {}
};
});
it("given a song in the queue, when I start dragging it, it records what its starting position in the queue was", function() {
mockUI.item.index = jasmine.createSpy("index").and.returnValue('1');
mockUI.item.data = jasmine.createSpy("data");
scope.dragStart({}, mockUI);
expect(mockUI.item.index).toHaveBeenCalled();
expect(mockUI.item.data).toHaveBeenCalledWith('start', '1');
});
it("given a song in the queue that I started dragging, when I drop it, its position in the queue has changed", function() {
mockUI.item.index = jasmine.createSpy("index").and.returnValue('0');
mockUI.item.data = jasmine.createSpy("data").and.returnValue('1');
scope.dragEnd({}, mockUI);
expect(mockUI.item.index).toHaveBeenCalled();
expect(mockUI.item.data).toHaveBeenCalledWith('start');
// The second song should now be first
expect(player.queue).toEqual([
{id: 8869},
{id: 2246},
{id: 285}
]);
});
// TODO: Hyz: Maybe it should be an end-to-end test
it("given that the player is playing the second song (B), when I swap the first (A) and the second song (B), the player's next song should be A", function() {
player.play({id: 8869});
mockUI.item.index = jasmine.createSpy("index").and.returnValue('0');
mockUI.item.data = jasmine.createSpy("data").and.returnValue('1');
scope.dragEnd({}, mockUI);
player.nextTrack();
expect(player._playingIndex).toBe(1);
expect(player._playingSong).toEqual({id: 2246});
});
});
});

View file

@ -1,11 +1,10 @@
angular.module('JamStash')
.controller('SettingsCtrl', ['$rootScope', '$scope', '$routeParams', '$location', '$http', '$q', 'utils', 'globals', 'json', 'notifications', 'player',
function ($rootScope, $scope, $routeParams, $location, $http, $q, utils, globals, json, notifications, player) {
.controller('SettingsController', ['$rootScope', '$scope', '$routeParams', '$location', 'utils', 'globals', 'json', 'notifications', 'persistence', 'subsonic',
function ($rootScope, $scope, $routeParams, $location, utils, globals, json, notifications, persistence, subsonic) {
'use strict';
$rootScope.hideQueue();
$scope.settings = globals.settings; /* See service.js */
$scope.ApiVersion = globals.settings.ApiVersion;
$scope.Timeouts = [
{ id: 10000, name: 10 },
{ id: 20000, name: 20 },
@ -28,34 +27,6 @@
$('#AZIndex').show();
}
});
$scope.ping = function () {
var exception = {reason: 'Error when contacting the Subsonic server.'};
var deferred = $q.defer();
var httpPromise;
httpPromise = $http({
method: 'GET',
timeout: globals.settings.Timeout,
// 2015-1-5: This API call only works with json as of SS v5.0?!?
//url: globals.BaseURL() + '/ping.view?' + globals.BaseParams(),
url: globals.BaseURL() + '/ping.view?' + globals.BaseJSONParams()
});
httpPromise.success(function(data, status) {
var subsonicResponse = (data['subsonic-response'] !== undefined) ? data['subsonic-response'] : {status: 'failed'};
if (subsonicResponse.status === 'ok') {
$scope.ApiVersion = subsonicResponse.version;
globals.settings.ApiVersion = $scope.ApiVersion;
deferred.resolve(data);
} else {
if(subsonicResponse.status === 'failed' && subsonicResponse.error !== undefined) {
notifications.updateMessage(subsonicResponse.error.message);
}
}
}).error(function(data, status) {
exception.httpError = status;
deferred.reject(exception);
});
return deferred.promise;
};
$scope.reset = function () {
utils.setValue('Settings', null, true);
$scope.loadSettings();
@ -65,20 +36,21 @@
if ($scope.settings.Server.indexOf('http://') != 0 && $scope.settings.Server.indexOf('https://') != 0) { $scope.settings.Server = 'http://' + $scope.settings.Server; }
if ($scope.settings.NotificationSong) {
notifications.requestPermissionIfRequired();
if (!notifications.hasNotificationSupport()) {
if (!notifications.isSupported()) {
alert('HTML5 Notifications are not available for your current browser, Sorry :(');
}
}
if ($scope.settings.NotificationNowPlaying) {
notifications.requestPermissionIfRequired();
if (!notifications.hasNotificationSupport()) {
if (!notifications.isSupported()) {
alert('HTML5 Notifications are not available for your current browser, Sorry :(');
}
}
if ($scope.settings.SaveTrackPosition) {
player.saveTrackPosition();
persistence.saveQueue();
} else {
player.deleteCurrentQueue();
persistence.deleteTrackPosition();
persistence.deleteQueue();
}
if ($scope.settings.Theme) {
utils.switchTheme(globals.settings.Theme);
@ -92,9 +64,21 @@
notifications.updateMessage('Settings Updated!', true);
$scope.loadSettings();
if (globals.settings.Server !== '' && globals.settings.Username !== '' && globals.settings.Password !== '') {
$scope.ping().then(function() {
subsonic.ping().then(function (subsonicResponse) {
globals.settings.ApiVersion = subsonicResponse.version;
$location.path('/library').replace();
$rootScope.showIndex = true;
}, function (error) {
//TODO: Hyz: Duplicate from subsonic.js - requestSongs. Find a way to handle this only once.
var errorNotif;
if (error.subsonicError !== undefined) {
errorNotif = error.reason + ' ' + error.subsonicError.message;
} else if (error.httpError !== undefined) {
errorNotif = error.reason + ' HTTP error ' + error.httpError;
} else {
errorNotif = error.reason;
}
notifications.updateMessage(errorNotif, true);
});
}
};

View file

@ -5,10 +5,10 @@
* Also offers more fine-grained functionality that is not part of Subsonic's API.
*/
angular.module('jamstash.subsonic.service', ['jamstash.settings', 'jamstash.utils', 'jamstash.model',
'jamstash.notifications', 'angular-underscore/utils'])
'jamstash.notifications', 'jamstash.player.service', 'angular-underscore/utils'])
.factory('subsonic', ['$rootScope', '$http', '$q', 'globals', 'utils', 'map', 'notifications',
function ($rootScope, $http, $q, globals, utils, map, notifications) {
.factory('subsonic', ['$rootScope', '$http', '$q', 'globals', 'utils', 'map', 'notifications', 'player',
function ($rootScope, $http, $q, globals, utils, map, notifications, player) {
'use strict';
var index = { shortcuts: [], artists: [] };
@ -23,7 +23,7 @@ angular.module('jamstash.subsonic.service', ['jamstash.settings', 'jamstash.util
selectedArtist: null,
selectedAlbum: null,
selectedPlaylist: null,
selectedAutoPlaylist: null,
selectedAutoPlaylist: null,
selectedGenre: null,
selectedPodcast: null
};
@ -68,7 +68,7 @@ angular.module('jamstash.subsonic.service', ['jamstash.settings', 'jamstash.util
}
}
};
return {
showIndex: $rootScope.showIndex,
showPlaylist: showPlaylist,
@ -82,7 +82,7 @@ angular.module('jamstash.subsonic.service', ['jamstash.settings', 'jamstash.util
dataType: globals.settings.Protocol,
timeout: globals.settings.Timeout,
success: function (data) {
}
});
*/
@ -104,6 +104,60 @@ angular.module('jamstash.subsonic.service', ['jamstash.settings', 'jamstash.util
}
});
},
/**
* Handles building the URL with the correct parameters and error-handling while communicating with
* a Subsonic server
* @param {String} partialUrl the last part of the Subsonic URL you want, e.g. 'getStarred.view'. If it does not start with a '/', it will be prefixed
* @param {Object} config optional $http config object. The base settings expected by Subsonic (username, password, etc.) will be overwritten.
* @return {Promise} a Promise that will be resolved if we receive the 'ok' status from Subsonic. Will be rejected otherwise with an object : {'reason': a message that can be displayed to a user, 'httpError': the HTTP error code, 'subsonicError': the error Object sent by Subsonic}
*/
subsonicRequest: function (partialUrl, config) {
var exception = { reason: 'Error when contacting the Subsonic server.' };
var deferred = $q.defer();
var actualUrl = (partialUrl.charAt(0) === '/') ? partialUrl : '/' + partialUrl;
var url = globals.BaseURL() + actualUrl;
// Extend the provided config (if it exists) with our params
// Otherwise we create a config object
var actualConfig = config || {};
var params = actualConfig.params || {};
params.u = globals.settings.Username;
params.p = globals.settings.Password;
params.f = globals.settings.Protocol;
params.v = globals.settings.ApiVersion;
params.c = globals.settings.ApplicationName;
actualConfig.params = params;
actualConfig.timeout = globals.settings.Timeout;
var httpPromise;
if(globals.settings.Protocol === 'jsonp') {
actualConfig.params.callback = 'JSON_CALLBACK';
httpPromise = $http.jsonp(url, actualConfig);
} else {
httpPromise = $http.get(url, actualConfig);
}
httpPromise.success(function(data) {
var subsonicResponse = (data['subsonic-response'] !== undefined) ? data['subsonic-response'] : {status: 'failed'};
if (subsonicResponse.status === 'ok') {
deferred.resolve(subsonicResponse);
} else {
if(subsonicResponse.status === 'failed' && subsonicResponse.error !== undefined) {
exception.subsonicError = subsonicResponse.error;
}
deferred.reject(exception);
}
}).error(function(data, status) {
exception.httpError = status;
deferred.reject(exception);
});
return deferred.promise;
},
ping: function () {
return this.subsonicRequest('ping.view');
},
getArtists: function (id, refresh) {
var deferred = $q.defer();
if (refresh || index.artists.length == 0) {
@ -309,16 +363,16 @@ angular.module('jamstash.subsonic.service', ['jamstash.settings', 'jamstash.util
}
if (action == 'add') {
angular.forEach(items, function (item, key) {
$rootScope.queue.push(map.mapSong(item));
player.queue.push(map.mapSong(item));
});
notifications.updateMessage(items.length + ' Song(s) Added to Queue', true);
} else if (action == 'play') {
$rootScope.queue = [];
player.queue = [];
angular.forEach(items, function (item, key) {
$rootScope.queue.push(map.mapSong(item));
player.queue.push(map.mapSong(item));
});
var next = $rootScope.queue[0];
$rootScope.playSong(false, next);
var next = player.queue[0];
player.play(next);
notifications.updateMessage(items.length + ' Song(s) Added to Queue', true);
} else {
if (typeof data["subsonic-response"].directory.id != 'undefined') {
@ -418,69 +472,35 @@ angular.module('jamstash.subsonic.service', ['jamstash.settings', 'jamstash.util
}
return deferred.promise;
},
getRandomSongs: function (action, genre, folder) {
var deferred = $q.defer();
if (globals.settings.Debug) { console.log('action:' + action + ', genre:' + genre + ', folder:' + folder); }
var size = globals.settings.AutoPlaylistSize;
content.selectedPlaylist = null;
if (typeof folder == 'number') {
content.selectedAutoPlaylist = folder;
} else if (genre !== '') {
content.selectedAutoPlaylist = genre;
} else {
content.selectedAutoPlaylist = 'random';
getRandomSongs: function (genre, folder) {
var exception = {reason: 'No songs found on the Subsonic server.'};
var params = {
size: globals.settings.AutoPlaylistSize
};
if (genre !== undefined && genre !== '' && genre !== 'Random') {
params.genre = genre;
}
var genreParams = '';
if (genre !== '' && genre != 'Random') {
genreParams = '&genre=' + genre;
if (!isNaN(folder)) {
params.musicFolderId = folder;
}
var folderParams = '';
if (typeof folder == 'number' && folder !== '' && folder != 'all') {
//alert(folder);
folderParams = '&musicFolderId=' + folder;
} else if (typeof $rootScope.SelectedMusicFolder.id != 'undefined' && $rootScope.SelectedMusicFolder.id >= 0) {
//alert($rootScope.SelectedMusicFolder.id);
folderParams = '&musicFolderId=' + $rootScope.SelectedMusicFolder.id;
}
$.ajax({
url: globals.BaseURL() + '/getRandomSongs.view?' + globals.BaseParams() + '&size=' + size + genreParams + folderParams,
method: 'GET',
dataType: globals.settings.Protocol,
timeout: globals.settings.Timeout,
success: function (data) {
if (typeof data["subsonic-response"].randomSongs.song != 'undefined') {
var items = [];
if (data["subsonic-response"].randomSongs.song.length > 0) {
items = data["subsonic-response"].randomSongs.song;
} else {
items[0] = data["subsonic-response"].randomSongs.song;
}
if (action == 'add') {
angular.forEach(items, function (item, key) {
$rootScope.queue.push(map.mapSong(item));
});
notifications.updateMessage(items.length + ' Song(s) Added to Queue', true);
} else if (action == 'play') {
$rootScope.queue = [];
angular.forEach(items, function (item, key) {
$rootScope.queue.push(map.mapSong(item));
});
var next = $rootScope.queue[0];
$rootScope.playSong(false, next);
notifications.updateMessage(items.length + ' Song(s) Added to Queue', true);
} else {
content.album = [];
content.song = [];
angular.forEach(items, function (item, key) {
content.song.push(map.mapSong(item));
});
}
}
deferred.resolve(content);
var deferred = this.subsonicRequest('getRandomSongs.view', {
params: params
}).then(function (subsonicResponse) {
if(subsonicResponse.randomSongs !== undefined && subsonicResponse.randomSongs.song.length > 0) {
var songs = [];
// TODO: Hyz: Add mapSongs to map service
angular.forEach(subsonicResponse.randomSongs.song, function (item) {
songs.push(map.mapSong(item));
});
return songs;
} else {
return $q.reject(exception);
}
});
return deferred.promise;
return deferred;
},
getPlaylists: function (refresh) {
var deferred = $q.defer();
if (globals.settings.Debug) { console.log("LOAD PLAYLISTS"); }
@ -517,6 +537,7 @@ angular.module('jamstash.subsonic.service', ['jamstash.settings', 'jamstash.util
return deferred.promise;
},
getPlaylist: function (id, action) {
//TODO: Hyz: Test this
var deferred = $q.defer();
content.selectedAutoPlaylist = null;
content.selectedPlaylist = id;
@ -536,16 +557,16 @@ angular.module('jamstash.subsonic.service', ['jamstash.settings', 'jamstash.util
}
if (action == 'add') {
angular.forEach(items, function (item, key) {
$rootScope.queue.push(map.mapSong(item));
player.queue.push(map.mapSong(item));
});
notifications.updateMessage(items.length + ' Song(s) Added to Queue', true);
} else if (action == 'play') {
$rootScope.queue = [];
player.queue = [];
angular.forEach(items, function (item, key) {
$rootScope.queue.push(map.mapSong(item));
player.queue.push(map.mapSong(item));
});
var next = $rootScope.queue[0];
$rootScope.playSong(false, next);
var next = player.queue[0];
player.play(next);
notifications.updateMessage(items.length + ' Song(s) Added to Queue', true);
} else {
content.album = [];
@ -563,61 +584,38 @@ angular.module('jamstash.subsonic.service', ['jamstash.settings', 'jamstash.util
});
return deferred.promise;
},
getStarred: function (action, type) {
var exception = {reason: 'Error when contacting the Subsonic server.'};
var deferred = $q.defer();
var httpPromise;
if(globals.settings.Protocol === 'jsonp') {
httpPromise = $http.jsonp(globals.BaseURL() + '/getStarred.view?callback=JSON_CALLBACK&' + globals.BaseParams(),
{
timeout: globals.settings.Timeout,
cache: true
});
} else {
httpPromise = $http.get(globals.BaseURL() + '/getStarred.view?' + globals.BaseParams(),
{
timeout: globals.settings.Timeout,
cache: true
});
}
httpPromise.success(function(data, status) {
var subsonicResponse = (data['subsonic-response'] !== undefined) ? data['subsonic-response'] : {status: 'failed'};
if (subsonicResponse.status === 'ok') {
getStarred: function () {
var deferred = this.subsonicRequest('getStarred.view', { cache: true })
.then(function (subsonicResponse) {
if(angular.equals(subsonicResponse.starred, {})) {
deferred.reject({reason: 'Nothing is starred on the Subsonic server.'});
return $q.reject({reason: 'Nothing is starred on the Subsonic server.'});
} else {
deferred.resolve(subsonicResponse.starred);
return subsonicResponse.starred;
}
} else {
if(subsonicResponse.status === 'failed' && subsonicResponse.error !== undefined) {
exception.subsonicError = subsonicResponse.error;
}
deferred.reject(exception);
}
}).error(function(data, status) {
exception.httpError = status;
deferred.reject(exception);
});
return deferred.promise;
});
return deferred;
},
getRandomStarredSongs: function() {
var exception = {reason: 'No starred songs found on the Subsonic server.'};
var deferred = $q.defer();
this.getStarred().then(function (data) {
if(data.song !== undefined && data.song.length > 0) {
// Return random subarray of songs
var randomSongs = [].concat(_.sample(data.song, globals.settings.AutoPlaylistSize));
deferred.resolve(randomSongs);
} else {
deferred.reject(exception);
}
}, function (reason) {
deferred.reject(reason);
});
return deferred.promise;
var deferred = this.getStarred()
.then(function (starred) {
if(starred.song !== undefined && starred.song.length > 0) {
// Return random subarray of songs
var songs = [].concat(_(starred.song).sample(globals.settings.AutoPlaylistSize));
var mappedSongs = [];
// TODO: Hyz: Add mapSongs to map service
angular.forEach(songs, function (item) {
mappedSongs.push(map.mapSong(item));
});
return mappedSongs;
} else {
return $q.reject({reason: 'No starred songs found on the Subsonic server.'});
}
});
return deferred;
},
newPlaylist: function (data, event) {
var deferred = $q.defer();
var reply = prompt("Choose a name for your new playlist.", "");
@ -703,7 +701,7 @@ angular.module('jamstash.subsonic.service', ['jamstash.settings', 'jamstash.util
} else {
items[0] = data["subsonic-response"].genres;
}
$rootScope.Genres = items;
$scope.$apply();
}
@ -763,19 +761,19 @@ angular.module('jamstash.subsonic.service', ['jamstash.settings', 'jamstash.util
if (action == 'add') {
angular.forEach(items, function (item, key) {
if (item.status != "skipped") {
$rootScope.queue.push(map.mapPodcast(item));
player.queue.push(map.mapPodcast(item));
}
});
notifications.updateMessage(items.length + ' Song(s) Added to Queue', true);
} else if (action == 'play') {
$rootScope.queue = [];
player.queue = [];
angular.forEach(items, function (item, key) {
if (item.status != "skipped") {
$rootScope.queue.push(map.mapPodcast(item));
player.queue.push(map.mapPodcast(item));
}
});
var next = $rootScope.queue[0];
$rootScope.playSong(false, next);
var next = player.queue[0];
player.play(next);
notifications.updateMessage(items.length + ' Song(s) Added to Queue', true);
} else {
content.album = [];
@ -792,7 +790,20 @@ angular.module('jamstash.subsonic.service', ['jamstash.settings', 'jamstash.util
}
});
return deferred.promise;
},
scrobble: function (song) {
var deferred = this.subsonicRequest('scrobble.view', {
params: {
id: song.id,
submisssion: true
}
}).then(function () {
if(globals.settings.Debug) { console.log('Successfully scrobbled song: ' + song.id); }
return true;
});
return deferred;
}
// End subsonic
};
}]);
}]);

View file

@ -1,160 +1,300 @@
describe("Subsonic service -", function() {
'use strict';
'use strict';
var subsonic, mockBackend, mockGlobals, response;
var subsonic, mockBackend, mockGlobals;
var response;
beforeEach(function() {
// We redefine it because in some tests we need to alter the settings
mockGlobals = {
settings: {
AutoPlaylistSize: 3,
Username: "Hyzual",
Password: "enc:cGFzc3dvcmQ=",
Protocol: "jsonp",
ApiVersion: "1.10.2",
ApplicationName: "Jamstash",
Timeout: 20000
},
BaseURL: function () {
return 'http://demo.subsonic.com/rest';
},
};
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';
module('jamstash.subsonic.service', function ($provide) {
$provide.value('globals', mockGlobals);
// Mock the model service
$provide.decorator('map', function ($delegate) {
$delegate.mapSong = function (argument) {
return argument;
};
return $delegate;
});
});
beforeEach(function() {
// We redefine it because in some tests we need to alter the settings
mockGlobals = {
settings: {
AutoPlaylistSize: 3,
Protocol: 'jsonp'
},
BaseURL: function () {
return 'http://demo.subsonic.com/rest';
},
BaseParams: function () {
return 'u=Hyzual&p=enc:cGFzc3dvcmQ=&v=1.10.2&c=Jamstash&f=jsonp';
}
};
inject(function (_subsonic_, $httpBackend) {
subsonic = _subsonic_;
mockBackend = $httpBackend;
});
response = {"subsonic-response": {status: "ok", version: "1.10.2"}};
});
module('jamstash.subsonic.service', function ($provide) {
$provide.value('globals', mockGlobals);
});
afterEach(function() {
mockBackend.verifyNoOutstandingExpectation();
mockBackend.verifyNoOutstandingRequest();
});
inject(function (_subsonic_, $httpBackend) {
subsonic = _subsonic_;
mockBackend = $httpBackend;
});
response = {"subsonic-response": {status: "ok", version: "1.10.2"}};
});
describe("subsonicRequest -", function() {
var partialUrl, url;
beforeEach(function() {
partialUrl = '/getStarred.view';
url ='http://demo.subsonic.com/rest/getStarred.view?'+
'c=Jamstash&callback=JSON_CALLBACK&f=jsonp&p=enc:cGFzc3dvcmQ%3D&u=Hyzual&v=1.10.2';
});
afterEach(function() {
mockBackend.verifyNoOutstandingExpectation();
mockBackend.verifyNoOutstandingRequest();
});
it("Given that the Subsonic server is not responding, when I make a request to Subsonic it returns an error object with a message", function() {
mockBackend.expectJSONP(url).respond(503, 'Service Unavailable');
describe("getStarred -", function() {
var promise = subsonic.subsonicRequest(partialUrl);
mockBackend.flush();
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}]};
mockBackend.whenJSONP(url).respond(200, JSON.stringify(response));
expect(promise).toBeRejectedWith({reason: 'Error when contacting the Subsonic server.', httpError: 503});
});
var promise = subsonic.getStarred();
mockBackend.flush();
it("Given a missing parameter, when I make a request to Subsonic it returns an error object with a message", function() {
delete mockGlobals.settings.Password;
var missingPasswordUrl = 'http://demo.subsonic.com/rest/getStarred.view?'+
'c=Jamstash&callback=JSON_CALLBACK&f=jsonp&u=Hyzual&v=1.10.2';
var errorResponse = {"subsonic-response" : {
"status" : "failed",
"version" : "1.10.2",
"error" : {"code" : 10,"message" : "Required parameter is missing."}
}};
mockBackend.expectJSONP(missingPasswordUrl).respond(200, JSON.stringify(errorResponse));
expect(promise).toBeResolvedWith({artist: [
{id: 2245}
], album: [
{id: 1799},{id: 20987}
], song: [
{id: 2478},{id: 14726},{id: 742}
]
});
});
var promise = subsonic.subsonicRequest(partialUrl);
mockBackend.flush();
it("Given that the global protocol setting is 'json' and given that I have 3 starred songs in my library, when getting everything starred, it uses GET and returns 3 starred songs", function() {
mockGlobals.settings.Protocol = 'json';
mockGlobals.BaseParams = function() { return 'u=Hyzual&p=enc:cGFzc3dvcmQ=&v=1.10.2&c=Jamstash&f=json'; };
var getUrl = 'http://demo.subsonic.com/rest/getStarred.view?' +
'u=Hyzual&p=enc:cGFzc3dvcmQ=&v=1.10.2&c=Jamstash&f=json';
response["subsonic-response"].starred = {song: [
{id: "2147"},{id:"9847"},{id:"214"}
]};
mockBackend.expectGET(getUrl).respond(200, JSON.stringify(response));
expect(promise).toBeRejectedWith({reason: 'Error when contacting the Subsonic server.', subsonicError: {code: 10, message:'Required parameter is missing.'}});
});
var promise = subsonic.getStarred();
mockBackend.flush();
it("Given a partialUrl that does not start with '/', it adds '/' before it and makes a correct request", function() {
partialUrl = 'getStarred.view';
mockBackend.expectJSONP(url).respond(200, JSON.stringify(response));
expect(promise).toBeResolvedWith({song: [
{id: "2147"},{id:"9847"},{id:"214"}]
});
});
subsonic.subsonicRequest(partialUrl);
mockBackend.flush();
});
it("Given that there is absolutely nothing starred in my library, when getting everything starred, it returns an error object with a message", function() {
response["subsonic-response"].starred = {};
mockBackend.whenJSONP(url).respond(200, JSON.stringify(response));
it("Given $http config params, it does not overwrite them", function() {
partialUrl = 'scrobble.view';
url ='http://demo.subsonic.com/rest/scrobble.view?'+
'c=Jamstash&callback=JSON_CALLBACK&f=jsonp&id=75&p=enc:cGFzc3dvcmQ%3D&submission=false&u=Hyzual&v=1.10.2';
mockBackend.expectJSONP(url).respond(200, JSON.stringify(response));
var promise = subsonic.getStarred();
mockBackend.flush();
subsonic.subsonicRequest(partialUrl, {
params: {
id: 75,
submission: false
}
});
mockBackend.flush();
});
expect(promise).toBeRejectedWith({reason: 'Nothing is starred on the Subsonic server.'});
});
it("Given that the global protocol setting is 'json', when I make a request to Subsonic it uses GET and does not use the JSON_CALLBACK parameter", function() {
mockGlobals.settings.Protocol = 'json';
var getUrl = 'http://demo.subsonic.com/rest/getStarred.view?'+
'c=Jamstash&f=json&p=enc:cGFzc3dvcmQ%3D&u=Hyzual&v=1.10.2';
mockBackend.expectGET(getUrl).respond(200, JSON.stringify(response));
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');
var promise = subsonic.subsonicRequest(partialUrl);
mockBackend.flush();
var promise = subsonic.getStarred();
mockBackend.flush();
expect(promise).toBeResolvedWith({status: "ok", version: "1.10.2"});
});
});
expect(promise).toBeRejectedWith({reason: 'Error when contacting the Subsonic server.', httpError: 503});
});
it("ping - when I ping Subsonic, it returns Subsonic's response, containing its REST API version", function() {
var url = 'http://demo.subsonic.com/rest/ping.view?'+
'c=Jamstash&callback=JSON_CALLBACK&f=jsonp&p=enc:cGFzc3dvcmQ%3D&u=Hyzual&v=1.10.2';
mockBackend.expectJSONP(url).respond(200, JSON.stringify(response));
it("Given a missing parameter, when getting the starred songs, it returns an error object with a message", function() {
mockGlobals.BaseParams = function() { return 'u=Hyzual&v=1.10.2&c=Jamstash&f=jsonp';};
var missingPasswordUrl = 'http://demo.subsonic.com/rest/getStarred.view?'+
'callback=JSON_CALLBACK&u=Hyzual&v=1.10.2&c=Jamstash&f=jsonp';
var errorResponse = {"subsonic-response" : {
"status" : "failed",
"version" : "1.10.2",
"error" : {"code" : 10,"message" : "Required parameter is missing."}
}};
mockBackend.whenJSONP(missingPasswordUrl).respond(200, errorResponse);
var promise = subsonic.ping();
mockBackend.flush();
var promise = subsonic.getStarred();
mockBackend.flush();
expect(promise).toBeResolvedWith({status: "ok", version: "1.10.2"});
});
expect(promise).toBeRejectedWith({reason: 'Error when contacting the Subsonic server.', subsonicError: {code: 10, message:'Required parameter is missing.'}});
});
}); //end getStarred
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?' +
'c=Jamstash&callback=JSON_CALLBACK&f=jsonp&id=45872&p=enc:cGFzc3dvcmQ%3D&submisssion=true&u=Hyzual&v=1.10.2';
describe("getRandomStarredSongs -", function() {
describe("Given that the global setting AutoPlaylist Size is 3", function() {
mockBackend.expectJSONP(url).respond(200, JSON.stringify(response));
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() {
var library = [
{id: "11841"},{id: "12061"},{id: "17322"},{id: "1547"},{id: "14785"}
];
response["subsonic-response"].starred = {song: library};
mockBackend.whenJSONP(url).respond(200, JSON.stringify(response));
var promise = subsonic.scrobble(song);
mockBackend.flush();
var promise = subsonic.getRandomStarredSongs();
// We create a spy in order to get the results of the promise
var success = jasmine.createSpy("success");
promise.then(success);
expect(promise).toBeResolvedWith(true);
});
mockBackend.flush();
describe("getStarred -", function() {
var url = 'http://demo.subsonic.com/rest/getStarred.view?'+
'c=Jamstash&callback=JSON_CALLBACK&f=jsonp&p=enc:cGFzc3dvcmQ%3D&u=Hyzual&v=1.10.2';
expect(promise).toBeResolved();
expect(success).toHaveBeenCalled();
var randomlyPickedSongs = success.calls.mostRecent().args[0];
for (var i = 0; i < randomlyPickedSongs.length; i++) {
expect(library).toContain(randomlyPickedSongs[i]);
}
});
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}]
};
mockBackend.expectJSONP(url).respond(200, JSON.stringify(response));
it("and given that I have only 1 starred song in my library, when getting random starred songs, it returns my starred song", function() {
response["subsonic-response"].starred = {song: [{id: "11841"}]};
mockBackend.whenJSONP(url).respond(200, JSON.stringify(response));
var promise = subsonic.getStarred();
mockBackend.flush();
var promise = subsonic.getRandomStarredSongs();
mockBackend.flush();
expect(promise).toBeResolvedWith({
artist: [{id: 2245}],
album: [{id: 1799},{id: 20987}],
song: [{id: 2478},{id: 14726},{id: 742}]
});
});
expect(promise).toBeResolvedWith([{id: "11841"}]);
});
it("Given that there is absolutely nothing starred in my library, when getting everything starred, it returns an error object with a message", function() {
response["subsonic-response"].starred = {};
mockBackend.expectJSONP(url).respond(200, JSON.stringify(response));
it("and given that I don't have any starred song in my library, when getting random starred songs, it returns an error object with a message", function() {
response["subsonic-response"].starred = {song: []};
mockBackend.whenJSONP(url).respond(200, JSON.stringify(response));
var promise = subsonic.getStarred();
mockBackend.flush();
var promise = subsonic.getRandomStarredSongs();
mockBackend.flush();
expect(promise).toBeRejectedWith({reason: 'Nothing is starred on the Subsonic server.'});
});
});
expect(promise).toBeRejectedWith({reason: 'No starred songs found on the Subsonic server.'});
});
});
});
});
describe("getRandomStarredSongs -", function() {
var url = 'http://demo.subsonic.com/rest/getStarred.view?'+
'c=Jamstash&callback=JSON_CALLBACK&f=jsonp&p=enc:cGFzc3dvcmQ%3D&u=Hyzual&v=1.10.2';
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, it returns 3 starred songs", function() {
var library = [
{id: 11841},{id: 12061},{id: 17322},{id: 1547},{id: 14785}
];
response["subsonic-response"].starred = {song: library};
mockBackend.expectJSONP(url).respond(200, JSON.stringify(response));
var promise = subsonic.getRandomStarredSongs();
// We create a spy in order to get the results of the promise
var success = jasmine.createSpy("success");
promise.then(success);
mockBackend.flush();
expect(promise).toBeResolved();
expect(success).toHaveBeenCalled();
var randomlyPickedSongs = success.calls.mostRecent().args[0];
for (var i = 0; i < randomlyPickedSongs.length; i++) {
expect(library).toContain(randomlyPickedSongs[i]);
}
});
it("and given that I have only 1 starred song in my library, when getting random starred songs, it returns my starred song", function() {
response["subsonic-response"].starred = {song: [{id: 11841}]};
mockBackend.expectJSONP(url).respond(200, JSON.stringify(response));
var promise = subsonic.getRandomStarredSongs();
mockBackend.flush();
expect(promise).toBeResolvedWith([{id: 11841}]);
});
it("and given that I don't have any starred song in my library, when getting random starred songs, it returns an error object with a message", function() {
response["subsonic-response"].starred = {song: []};
mockBackend.expectJSONP(url).respond(200, JSON.stringify(response));
var promise = subsonic.getRandomStarredSongs();
mockBackend.flush();
expect(promise).toBeRejectedWith({reason: 'No starred songs found on the Subsonic server.'});
});
});
});
describe("getRandomSongs -", function() {
var url = 'http://demo.subsonic.com/rest/getRandomSongs.view?'+
'c=Jamstash&callback=JSON_CALLBACK&f=jsonp&p=enc:cGFzc3dvcmQ%3D&size=3&u=Hyzual&v=1.10.2';
describe("Given that the global setting AutoPlaylist Size is 3", function() {
it("and given that I have more than 3 songs in my library, when getting random songs, it returns 3 songs", function() {
var library = [
{id: 1143},{id: 5864},{id: 7407},{id: 6471},{id: 59}
];
response["subsonic-response"].randomSongs = {song: library};
mockBackend.expectJSONP(url).respond(200, JSON.stringify(response));
var promise = subsonic.getRandomSongs();
// We create a spy in order to get the results of the promise
var success = jasmine.createSpy("success");
promise.then(success);
mockBackend.flush();
expect(promise).toBeResolved();
expect(success).toHaveBeenCalled();
var randomlyPickedSongs = success.calls.mostRecent().args[0];
for (var i = 0; i < randomlyPickedSongs.length; i++) {
expect(library).toContain(randomlyPickedSongs[i]);
}
});
it("and given that I have only 1 song in my library, when getting random songs, it returns that song", function() {
response["subsonic-response"].randomSongs = {song: [{id: 7793}]};
mockBackend.expectJSONP(url).respond(200, JSON.stringify(response));
var promise = subsonic.getRandomSongs();
mockBackend.flush();
expect(promise).toBeResolvedWith([{id: 7793}]);
});
it("and given that I don't have any song in my library, when getting random songs, it returns an error object with a message", function() {
response["subsonic-response"].randomSongs = {song: []};
mockBackend.expectJSONP(url).respond(200, JSON.stringify(response));
var promise = subsonic.getRandomSongs();
mockBackend.flush();
expect(promise).toBeRejectedWith({reason: 'No songs found on the Subsonic server.'});
});
it("and given a genre, when getting random songs, it returns 3 songs from the given genre", function() {
url = 'http://demo.subsonic.com/rest/getRandomSongs.view?'+
'c=Jamstash&callback=JSON_CALLBACK&f=jsonp&genre=Rock&p=enc:cGFzc3dvcmQ%3D&size=3&u=Hyzual&v=1.10.2';
var library = [
{id: 9408},{id: 9470},{id: 6932}
];
response["subsonic-response"].randomSongs = {song: library};
mockBackend.expectJSONP(url).respond(200, JSON.stringify(response));
var promise = subsonic.getRandomSongs('Rock');
mockBackend.flush();
expect(promise).toBeResolvedWith([{id: 9408},{id: 9470},{id: 6932}]);
});
it("and given a folder id, when getting random songs, it returns 3 songs from the given folder", function() {
url = 'http://demo.subsonic.com/rest/getRandomSongs.view?'+
'c=Jamstash&callback=JSON_CALLBACK&f=jsonp&musicFolderId=2&p=enc:cGFzc3dvcmQ%3D&size=3&u=Hyzual&v=1.10.2';
var library = [
{id: 9232},{id: 3720},{id: 8139}
];
response["subsonic-response"].randomSongs = {song: library};
mockBackend.expectJSONP(url).respond(200, JSON.stringify(response));
var promise = subsonic.getRandomSongs('', 2);
mockBackend.flush();
expect(promise).toBeResolvedWith([{id: 9232},{id: 3720},{id: 8139}]);
});
});
});
});

View file

@ -6,7 +6,7 @@
<a href="" class="button" id="action_RescanLibrary" title="Rescan Library" ng-click="rescanLibrary()"><img class="pad" src="images/loop_alt1_gd_12x9.png" /></a>
<a href="" class="button" id="action_ToggleArtists" ng-click="toggleArtists()" title="Toggle Artists"><img src="images/arrow_right_gl_8x8.png" /></a>
</div>
<div id="search">
<div id="search">
<input type="text" id="Search" name="Search" class="medium" title="Wildcards (*) supported" placeholder="Search..." ng-enter="search()"/>
<select id="SearchType" name="SearchType" ng-model="SearchType.id" ng-options="o.id as o.name for o in SearchTypes"></select>
<a href="" class="button" id="action_Search" title="Search" ng-click="search()"><img class="pad" src="images/magnifying_glass_alt_12x12.png" /></a>
@ -122,13 +122,15 @@
<li class="index" id="auto">Auto Playlists</li>
<li class="item" ng-click="getRandomStarredSongs('display')" ng-class="{'selected': selectedAutoPlaylist == 'starred'}">
<div class="itemactions">
<a class="add" href="" ng-click="getRandomStarredSongs('add')" title="Add To Play Queue" stop-event="click"></a><a class="play" href="" ng-click="getRandomStarredSongs('play')" title="Play" stop-event="click"></a>
<a class="add" href="" ng-click="getRandomStarredSongs('add')" title="Add To Play Queue" stop-event="click"></a>
<a class="play" href="" ng-click="getRandomStarredSongs('play')" title="Play" stop-event="click"></a>
</div>
<div class="title">Starred</div>
</li>
<li class="item" ng-click="getRandomSongs('', '', '')" ng-class="{'selected': selectedAutoPlaylist == 'random'}">
<li class="item" ng-click="getRandomSongs('display')" ng-class="{'selected': selectedAutoPlaylist == 'random'}">
<div class="itemactions">
<a class="add" href="" title="Add To Play Queue" ng-click="getRandomSongs('add', '', '')" stop-event="click"></a><a class="play" href="" title="Play" ng-click="getRandomSongs('play', '', '')" stop-event="click"></a>
<a class="add" href="" title="Add To Play Queue" ng-click="getRandomSongs('add')" stop-event="click"></a>
<a class="play" href="" title="Play" ng-click="getRandomSongs('play')" stop-event="click"></a>
</div>
<div class="title">Random</div>
</li>
@ -136,18 +138,20 @@
<select id="Genres" name="Genres" class="large" ng-model="selectedGenre" ng-options="o for o in Genres">
<option value="">[Select Genre]</option>
</select>
<li class="item" ng-repeat="o in playlistsGenre" ng-click="getRandomSongs('', o, '')" ng-class="{'selected': selectedAutoPlaylist == o}">
<li class="item" ng-repeat="o in playlistsGenre" ng-click="getRandomSongs('display', o)" ng-class="{'selected': selectedAutoPlaylist == o}">
<div class="itemactions">
<a class="add" href="" title="Add To Play Queue" ng-click="getRandomSongs('add', o, '')" stop-event="click"></a><a class="play" href="" title="Play" ng-click="getRandomSongs('play', o, '')" stop-event="click"></a>
<a class="add" href="" title="Add To Play Queue" ng-click="getRandomSongs('add', o)" stop-event="click"></a>
<a class="play" href="" title="Play" ng-click="getRandomSongs('play', o)" stop-event="click"></a>
</div>
<div class="title">{{o}}</div>
</li>
</ul>
<ul class="simplelist mainlist noselect">
<li class="index" id="folder">Folder Playlists</li>
<li class="item" ng-repeat="o in MusicFolders | musicfolder" ng-click=" getRandomSongs('', '' , o.id)" ng-class="{'selected': o.id == selectedAutoPlaylist}">
<li class="item" ng-repeat="o in MusicFolders | musicfolder" ng-click=" getRandomSongs('display', '' , o.id)" ng-class="{'selected': o.id == selectedAutoPlaylist}">
<div class="itemactions">
<a class="add" href="" title="Add To Play Queue" ng-click="getRandomSongs('add', '', o.id)" stop-event="click"></a><a class="play" href="" title="Play" ng-click="getRandomSongs('play', '', o.id)" stop-event="click"></a>
<a class="add" href="" title="Add To Play Queue" ng-click="getRandomSongs('add', '', o.id)" stop-event="click"></a>
<a class="play" href="" title="Play" ng-click="getRandomSongs('play', '', o.id)" stop-event="click"></a>
</div>
<div class="title">{{o.name}}</div>
</li>
@ -195,4 +199,4 @@
</div>
<div class="clear"></div>
</div>
<!-- End: Library Tab -->
<!-- End: Library Tab -->

View file

@ -1,13 +1,13 @@
/**
* jamstash.subsonic.ctrl Module
/**
* jamstash.subsonic.controller Module
*
* Access and use the Subsonic Server. The Controller is in charge of relaying the Service's messages to the user through the
* notifications.
*/
angular.module('jamstash.subsonic.ctrl', ['jamstash.subsonic.service'])
angular.module('jamstash.subsonic.controller', ['jamstash.subsonic.service', 'jamstash.player.service'])
.controller('SubsonicCtrl', ['$scope', '$rootScope', '$routeParams', 'utils', 'globals', 'map', 'subsonic', 'notifications',
function SubsonicCtrl($scope, $rootScope, $routeParams, utils, globals, map, subsonic, notifications) {
.controller('SubsonicController', ['$scope', '$rootScope', '$routeParams', 'utils', 'globals', 'map', 'subsonic', 'notifications', 'player',
function ($scope, $rootScope, $routeParams, utils, globals, map, subsonic, notifications, player) {
'use strict';
$scope.settings = globals.settings;
@ -143,15 +143,19 @@ angular.module('jamstash.subsonic.ctrl', ['jamstash.subsonic.service'])
$scope.selectNone = function () {
$rootScope.selectNone($scope.song);
};
// TODO: Hyz: Replace
$scope.playAll = function () {
$rootScope.playAll($scope.song);
};
// TODO: Hyz: Replace
$scope.playFrom = function (index) {
$rootScope.playFrom(index, $scope.song);
};
// TODO: Hyz: Replace
$scope.removeSong = function (item) {
$rootScope.removeSong(item, $scope.song);
};
// TODO: Hyz: Replace
$scope.songsRemoveSelected = function () {
subsonic.songsRemoveSelected($scope.selectedSongs).then(function (data) {
$scope.album = data.album;
@ -190,12 +194,70 @@ angular.module('jamstash.subsonic.ctrl', ['jamstash.subsonic.service'])
$scope.selectedPlaylist = data.selectedPlaylist;
});
};
/**
* Handles common actions with songs such as displaying them on the scope, adding them to the playing queue
* and playing the first song after adding them to the queue. Displays notifications for songs added to the playing queue
* Also handles error notifications in case of: a service error, a subsonic error or an HTTP error
* @param {Promise} promise a Promise that must be resolved with an array of songs or must be rejected with an object : {'reason': a message that can be displayed to a user, 'httpError': the HTTP error code, 'subsonicError': the error Object sent by Subsonic}
* @param {String} action the action to be taken with the songs. Must be 'add', 'play' or 'display'
* @return {Promise} the original promise passed in first param. That way we can chain it further !
*/
$scope.requestSongs = function (promise, action) {
promise.then(function (songs) {
if(action === 'play') {
player.emptyQueue().addSongs(songs).playFirstSong();
notifications.updateMessage(songs.length + ' Song(s) Added to Queue', true);
} else if (action === 'add') {
player.addSongs(songs);
notifications.updateMessage(songs.length + ' Song(s) Added to Queue', true);
} else if (action === 'display') {
$scope.album = [];
$scope.song = songs;
}
}, function (error) {
var errorNotif;
if (error.subsonicError !== undefined) {
errorNotif = error.reason + ' ' + error.subsonicError.message;
} else if (error.httpError !== undefined) {
errorNotif = error.reason + ' HTTP error ' + error.httpError;
} else {
errorNotif = error.reason;
}
notifications.updateMessage(errorNotif, true);
});
return promise;
};
$scope.getSongs = function (id, action) {
subsonic.getSongs(id, action).then(function (data) {
$scope.album = data.album;
$scope.song = data.song;
});
};
$scope.getRandomStarredSongs = function (action) {
var promise = subsonic.getRandomStarredSongs();
$scope.requestSongs(promise, action);
$scope.selectedPlaylist = null;
$scope.selectedAutoPlaylist = 'starred';
};
$scope.getRandomSongs = function (action, genre, folder) {
var promise = subsonic.getRandomSongs(genre, folder);
$scope.requestSongs(promise, action);
$scope.selectedPlaylist = null;
if (!isNaN(folder)) {
$scope.selectedAutoPlaylist = folder;
} else if (genre !== undefined && genre !== '' && genre !== 'Random') {
$scope.selectedAutoPlaylist = genre;
} else {
$scope.selectedAutoPlaylist = 'random';
}
};
$scope.getArtistByTag = function (id) { // Gets Artist by ID3 tag
$scope.selectedAutoAlbum = null;
$scope.selectedArtist = id;
@ -264,6 +326,7 @@ angular.module('jamstash.subsonic.ctrl', ['jamstash.subsonic.service'])
$scope.selectedPlaylist = data.selectedPlaylist;
});
};
$scope.getStarred = function (action, type) {
subsonic.getStarred(action, type).then(function (data) {
$scope.album = data.album;
@ -272,45 +335,7 @@ angular.module('jamstash.subsonic.ctrl', ['jamstash.subsonic.service'])
$scope.selectedPlaylist = data.selectedPlaylist;
});
};
$scope.getRandomStarredSongs = function (action) {
subsonic.getRandomStarredSongs()
.then(function (randomStarredSongs) {
var mappedSongs = [];
// Map regardless of the action
for (var i = 0; i < randomStarredSongs.length; i++) {
mappedSongs.push(map.mapSong(randomStarredSongs[i]));
}
if(action === 'play') {
$rootScope.queue = [].concat(mappedSongs);
notifications.updateMessage(mappedSongs.length + ' Song(s) Added to Queue', true);
$rootScope.playSong(false, $rootScope.queue[0]);
} else if (action === 'add') {
$rootScope.queue = $rootScope.queue.concat(mappedSongs);
notifications.updateMessage(mappedSongs.length + ' Song(s) Added to Queue', true);
} else if (action === 'display') {
$scope.album = [];
$scope.song = mappedSongs;
}
}).catch(function (error) {
var errorNotif;
if (error.subsonicError !== undefined) {
errorNotif = error.reason + ' ' + error.subsonicError.message;
} else if (error.httpError !== undefined) {
errorNotif = error.reason + ' HTTP error ' + error.httpError;
} else {
errorNotif = error.reason;
}
notifications.updateMessage(errorNotif, true);
});
};
$rootScope.getRandomSongs = function (action, genre, folder) {
subsonic.getRandomSongs(action, genre, folder).then(function (data) {
$scope.album = data.album;
$scope.song = data.song;
$scope.selectedAutoPlaylist = data.selectedAutoPlaylist;
$scope.selectedPlaylist = data.selectedPlaylist;
});
};
$scope.newPlaylist = function (data, event) {
subsonic.newPlaylist(data, event).then(function (data) {
$scope.getPlaylists(true);
@ -448,6 +473,12 @@ angular.module('jamstash.subsonic.ctrl', ['jamstash.subsonic.service'])
end = ui.item.index();
$scope.song.splice(end, 0, $scope.song.splice(start, 1)[0]);
};
$scope.playSong = function (song) {
player.play(song);
};
$scope.addSongToQueue = function(song) {
player.addSong(song);
};
/* Launch on Startup */
$scope.getArtists();

View file

@ -1,160 +1,237 @@
describe("Subsonic controller", function() {
'use strict';
'use strict';
var scope, $rootScope, subsonic, notifications, deferred;
var scope, $rootScope, subsonic, notifications, player,
deferred;
beforeEach(function() {
jasmine.addCustomEqualityTester(angular.equals);
beforeEach(function() {
jasmine.addCustomEqualityTester(angular.equals);
module('jamstash.subsonic.ctrl');
module('jamstash.subsonic.controller', function ($provide) {
// Mock the player service
$provide.decorator('player', function($delegate) {
inject(function ($controller, _$rootScope_, utils, globals, map, _subsonic_, _notifications_, $q) {
$rootScope = _$rootScope_;
scope = $rootScope.$new();
subsonic = _subsonic_;
notifications = _notifications_;
$delegate.queue = [];
$delegate.play = jasmine.createSpy("play");
$delegate.playFirstSong = jasmine.createSpy("playFirstSong");
return $delegate;
});
// Mock the functions of the services and the rootscope
deferred = $q.defer();
spyOn(subsonic, 'getRandomStarredSongs').and.returnValue(deferred.promise);
spyOn(map, 'mapSong').and.callFake(function (song) {
return {id: song.id};
});
spyOn(notifications, 'updateMessage');
$rootScope.playSong = jasmine.createSpy('playSong');
$rootScope.queue = [];
$provide.decorator('subsonic', function($delegate, $q) {
deferred = $q.defer();
$delegate.getRandomStarredSongs = jasmine.createSpy("getRandomStarredSongs").and.returnValue(deferred.promise);
$delegate.getRandomSongs = jasmine.createSpy("getRandomSongs").and.returnValue(deferred.promise);
return $delegate;
});
$controller('SubsonicCtrl', {
$scope: scope,
$rootScope: $rootScope,
$routeParams: {},
utils: utils,
globals: globals,
map: map,
subsonic: subsonic,
notifications: notifications
});
});
});
$provide.decorator('notifications', function ($delegate) {
$delegate.updateMessage = jasmine.createSpy("updateMessage");
return $delegate;
});
});
//TODO: JMA: It should be the exact same test when getting songs from an album. We aren't testing that it's randomized, that's the service's job.
describe("getRandomStarred -", function() {
inject(function ($controller, _$rootScope_, utils, globals, map, _subsonic_, _notifications_, $q, _player_) {
$rootScope = _$rootScope_;
scope = $rootScope.$new();
subsonic = _subsonic_;
notifications = _notifications_;
player = _player_;
describe("given that my library contains 3 starred songs, ", function() {
var response = [
{id:"2548"}, {id:"8986"}, {id:"2986"}
];
$controller('SubsonicController', {
$scope: scope,
$rootScope: $rootScope,
$routeParams: {},
utils: utils,
globals: globals,
map: map,
subsonic: subsonic,
notifications: notifications
});
});
});
it("when displaying random starred songs, it sets the scope with the selected songs", function() {
scope.getRandomStarredSongs('display');
deferred.resolve(response);
$rootScope.$apply();
describe("given that my library contains 3 songs, ", function() {
var response;
beforeEach(function() {
response = [
{id:"2548"}, {id:"8986"}, {id:"2986"}
];
});
expect(subsonic.getRandomStarredSongs).toHaveBeenCalled();
expect(scope.song).toEqual([
{id: "2548"}, {id: "8986"}, {id: "2986"}
]);
});
describe("get songs -", function() {
beforeEach(function() {
spyOn(scope, "requestSongs");
});
it("when adding random starred songs, it adds the selected songs to the queue and notifies the user", function() {
scope.getRandomStarredSongs('add');
deferred.resolve(response);
$rootScope.$apply();
it("it can get random starred songs from the subsonic service", function() {
scope.getRandomStarredSongs('whatever action');
deferred.resolve(response);
expect(subsonic.getRandomStarredSongs).toHaveBeenCalled();
expect($rootScope.queue).toEqual([
{id: "2548"}, {id: "8986"}, {id: "2986"}
]);
expect(notifications.updateMessage).toHaveBeenCalledWith('3 Song(s) Added to Queue', true);
});
expect(subsonic.getRandomStarredSongs).toHaveBeenCalled();
expect(scope.requestSongs).toHaveBeenCalledWith(deferred.promise, 'whatever action');
expect(scope.selectedPlaylist).toBeNull();
expect(scope.selectedAutoPlaylist).toBe('starred');
});
it("when playing random starred songs, it plays the first selected song, empties the queue and fills it with the selected songs, and notifies the user", function() {
$rootScope.queue = [{id: "7666"}];
it("it can get random songs from all folders or genres from the subsonic service", function() {
scope.getRandomSongs('whatever action');
deferred.resolve(response);
scope.getRandomStarredSongs('play');
deferred.resolve(response);
$rootScope.$apply();
expect(subsonic.getRandomSongs).toHaveBeenCalled();
expect(scope.requestSongs).toHaveBeenCalledWith(deferred.promise, 'whatever action');
expect(scope.selectedPlaylist).toBeNull();
expect(scope.selectedAutoPlaylist).toBe('random');
});
expect(subsonic.getRandomStarredSongs).toHaveBeenCalled();
expect($rootScope.playSong).toHaveBeenCalledWith(false, {id: "2548"});
expect($rootScope.queue).toEqual([
{id: "2548"}, {id: "8986"}, {id: "2986"}
]);
expect(notifications.updateMessage).toHaveBeenCalledWith('3 Song(s) Added to Queue', true);
});
it("it can get random songs from a given genre from the subsonic service", function() {
scope.getRandomSongs('whatever action', 'Rock');
deferred.resolve(response);
});
expect(subsonic.getRandomSongs).toHaveBeenCalledWith('Rock', undefined);
expect(scope.requestSongs).toHaveBeenCalledWith(deferred.promise, 'whatever action');
expect(scope.selectedPlaylist).toBeNull();
expect(scope.selectedAutoPlaylist).toBe('Rock');
});
it("given that I don't have any starred song in my library, when getting random starred songs, it notifies the user with an error message, does not play a song and does not touch the queue", function() {
$rootScope.queue = [{id: "7666"}];
it("it can get random songs from a given folder id from the subsonic service", function() {
scope.getRandomSongs('whatever action', '', 1);
deferred.resolve(response);
scope.getRandomStarredSongs('whatever action');
deferred.reject({reason: 'No starred songs found on the Subsonic server.'});
$rootScope.$apply();
expect(subsonic.getRandomSongs).toHaveBeenCalledWith('', 1);
expect(scope.requestSongs).toHaveBeenCalledWith(deferred.promise, 'whatever action');
expect(scope.selectedPlaylist).toBeNull();
expect(scope.selectedAutoPlaylist).toBe(1);
});
});
expect(subsonic.getRandomStarredSongs).toHaveBeenCalled();
expect($rootScope.playSong).not.toHaveBeenCalled();
expect($rootScope.queue).toEqual([{id: "7666"}]);
expect(notifications.updateMessage).toHaveBeenCalledWith('No starred songs found on the Subsonic server.', true);
});
describe("requestSongs -", function() {
it("when I display songs, it sets the scope with the selected songs", function() {
scope.requestSongs(deferred.promise, 'display');
deferred.resolve(response);
scope.$apply();
it("given that the Subsonic server returns an error, when getting random starred songs, it notifies the user with the error message", function() {
scope.getRandomStarredSongs('whatever action');
deferred.reject({reason: 'Error when contacting the Subsonic server.',
subsonicError: {code: 10, message:'Required parameter is missing.'}
});
$rootScope.$apply();
expect(scope.song).toEqual([
{id: "2548"}, {id: "8986"}, {id: "2986"}
]);
});
expect(subsonic.getRandomStarredSongs).toHaveBeenCalled();
expect(notifications.updateMessage).toHaveBeenCalledWith('Error when contacting the Subsonic server. Required parameter is missing.', true);
});
it("when I add songs, it adds the selected songs to the playing queue and notifies the user", function() {
scope.requestSongs(deferred.promise, 'add');
deferred.resolve(response);
scope.$apply();
it("given that the Subsonic server is unreachable, when getting random starred songs, it notifies the user with the HTTP error code", function() {
scope.getRandomStarredSongs('whatever action');
deferred.reject({reason: 'Error when contacting the Subsonic server.',
httpError: 404
});
$rootScope.$apply();
expect(player.queue).toEqual([
{id: "2548"}, {id: "8986"}, {id: "2986"}
]);
expect(notifications.updateMessage).toHaveBeenCalledWith('3 Song(s) Added to Queue', true);
});
expect(subsonic.getRandomStarredSongs).toHaveBeenCalled();
expect(notifications.updateMessage).toHaveBeenCalledWith('Error when contacting the Subsonic server. HTTP error 404', true);
});
});
it("when I play songs, it plays the first selected song, empties the queue and fills it with the selected songs and it notifies the user", function() {
player.queue = [{id: "7666"}];
describe("reorders playlists by drag and drop - ", function() {
var mockUI;
beforeEach(function() {
scope.song = [{id: "1084"}, {id: "6810"}, {id: "214"}];
mockUI = {
item: {}
};
});
scope.requestSongs(deferred.promise, 'play');
deferred.resolve(response);
scope.$apply();
it("given a song in a list of songs, when I start dragging it, it records what its starting position in the list was", function() {
mockUI.item.index = jasmine.createSpy('index').and.returnValue('1');
mockUI.item.data = jasmine.createSpy('data');
expect(player.playFirstSong).toHaveBeenCalled();
expect(player.queue).toEqual([
{id: "2548"}, {id: "8986"}, {id: "2986"}
]);
expect(notifications.updateMessage).toHaveBeenCalledWith('3 Song(s) Added to Queue', true);
});
scope.dragStart({}, mockUI);
it("when I request songs, it returns a promise so that I can chain it further", function() {
var success = jasmine.createSpy("success");
expect(mockUI.item.index).toHaveBeenCalled();
expect(mockUI.item.data).toHaveBeenCalledWith('start', '1');
});
scope.requestSongs(deferred.promise, 'whatever action').then(success);
deferred.resolve(response);
scope.$apply();
it("given a song in a list of songs that I started dragging, when I drop it, its position in the list of songs has changed", function() {
mockUI.item.index = jasmine.createSpy('index').and.returnValue('0');
mockUI.item.data = jasmine.createSpy('data').and.returnValue('1');
expect(success).toHaveBeenCalled();
});
scope.dragEnd({}, mockUI);
it("given that I don't have any song in my library, when I request songs, it notifies the user with an error message, does not play a song and does not change the queue", function() {
player.queue = [{id: "7666"}];
expect(mockUI.item.index).toHaveBeenCalled();
expect(mockUI.item.data).toHaveBeenCalledWith('start');
// The second song should now be first
expect(scope.song).toEqual([
{id: "6810"}, {id: "1084"}, {id: "214"}
]);
});
});
scope.requestSongs(deferred.promise, 'whatever action');
deferred.reject({reason: 'No songs found on the Subsonic server.'});
scope.$apply();
//TODO: JMA: all starred
});
expect(player.playFirstSong).not.toHaveBeenCalled();
expect(player.queue).toEqual([{id: "7666"}]);
expect(notifications.updateMessage).toHaveBeenCalledWith('No songs found on the Subsonic server.', true);
});
it("given that the Subsonic server returns an error, when I request songs, it notifies the user with the error message", function() {
scope.requestSongs(deferred.promise, 'whatever action');
deferred.reject({reason: 'Error when contacting the Subsonic server.',
subsonicError: {code: 10, message:'Required parameter is missing.'}
});
scope.$apply();
expect(notifications.updateMessage).toHaveBeenCalledWith('Error when contacting the Subsonic server. Required parameter is missing.', true);
});
it("given that the Subsonic server is unreachable, when I request songs, it notifies the user with the HTTP error code", function() {
scope.requestSongs(deferred.promise, 'whatever action');
deferred.reject({reason: 'Error when contacting the Subsonic server.',
httpError: 404
});
scope.$apply();
expect(notifications.updateMessage).toHaveBeenCalledWith('Error when contacting the Subsonic server. HTTP error 404', true);
});
});
});
describe("reorders playlists by drag and drop - ", function() {
var mockUI;
beforeEach(function() {
scope.song = [
{id: 1084},
{id: 6810},
{id: 214}
];
mockUI = {
item: {}
};
});
it("given a song in a list of songs, when I start dragging it, it records what its starting position in the list was", function() {
mockUI.item.index = jasmine.createSpy('index').and.returnValue('1');
mockUI.item.data = jasmine.createSpy('data');
scope.dragStart({}, mockUI);
expect(mockUI.item.index).toHaveBeenCalled();
expect(mockUI.item.data).toHaveBeenCalledWith('start', '1');
});
it("given a song in a list of songs that I started dragging, when I drop it, its position in the list of songs has changed", function() {
mockUI.item.index = jasmine.createSpy('index').and.returnValue('0');
mockUI.item.data = jasmine.createSpy('data').and.returnValue('1');
scope.dragEnd({}, mockUI);
expect(mockUI.item.index).toHaveBeenCalled();
expect(mockUI.item.data).toHaveBeenCalledWith('start');
// The second song should now be first
expect(scope.song).toEqual([
{id: 6810},
{id: 1084},
{id: 214}
]);
});
});
it("When I call playSong, it calls play in the player service", function() {
var fakeSong = {"id": 3572};
scope.playSong(fakeSong);
expect(player.play).toHaveBeenCalledWith(fakeSong);
});
//TODO: JMA: all starred
});

View file

@ -1,6 +1,6 @@
{
"name": "jamstash",
"version": "4.3",
"version": "4.4.0",
"description": "HTML5 Audio Streamer for Subsonic, Archive.org browsing and streaming",
"authors": [
"tsquillario (https://github.com/tsquillario)",
@ -31,14 +31,15 @@
"angular-sanitize": "~1.2.0",
"angular-cookies": "~1.2.0",
"angular-resource": "~1.2.0",
"jquery": "~2.0.3",
"jquery-ui": "~1.10.3",
"jplayer": "~2.8.4",
"jquery": "~2.0.0",
"jquery-ui": "~1.10.0",
"jplayer": "~2.9.0",
"fancybox": "~2.1.4",
"notify.js": "<=1.2.2",
"jquery.scrollTo": "~1.4.5",
"underscore": "~1.7.0",
"angular-underscore": "~0.5.0"
"angular-underscore": "~0.5.0",
"angular-locker": "~1.0.2"
},
"overrides": {
"fancybox": {
@ -50,7 +51,8 @@
},
"devDependencies": {
"angular-mocks": "~1.2.0",
"jasmine-promise-matchers": "~0.0.3"
"jasmine-promise-matchers": "~0.0.3",
"jasmine-fixture": "~1.2.2"
},
"ignore": [
"bower_components",
@ -58,4 +60,4 @@
],
"private": true,
"appPath": "app"
}
}

View file

@ -32,8 +32,10 @@ module.exports = function(config) {
'bower_components/jquery.scrollTo/jquery.scrollTo.js',
'bower_components/underscore/underscore.js',
'bower_components/angular-underscore/angular-underscore.js',
'bower_components/angular-locker/dist/angular-locker.min.js',
'bower_components/angular-mocks/angular-mocks.js',
'bower_components/jasmine-promise-matchers/dist/jasmine-promise-matchers.js',
'bower_components/jasmine-fixture/dist/jasmine-fixture.js',
// endbower
'app/**/*.js',
'app/**/*_test.js'

View file

@ -2,7 +2,7 @@
"manifest_version": 2,
"name": "Jamstash",
"description": "HTML5 Player for Subsonic & Archive.org",
"version": "4.2.3",
"version": "4.4.0",
"app": {
"launch": {
"web_url": "http://jamstash.com"

View file

@ -1,6 +1,6 @@
{
"name": "jamstash",
"version": "4.3",
"version": "4.4.0",
"description": "HTML5 Audio Streamer for Subsonic, Archive.org browsing and streaming",
"author": "Trevor Squillario (https://github.com/tsquillario)",
"contributors": [