diff --git a/.editorconfig b/.editorconfig index 8a80734..50c6884 100644 --- a/.editorconfig +++ b/.editorconfig @@ -1,17 +1,10 @@ -# EditorConfig helps developers define and maintain consistent -# coding styles between different editors and IDEs +# EditorConfig helps developers define and maintain consistent coding styles between different editors and IDEs # editorconfig.org - root = true - [*] - -# Change these settings to your own preference indent_style = space indent_size = 4 - -# We recommend you to keep these unchanged end_of_line = lf charset = utf-8 trim_trailing_whitespace = true diff --git a/.jshintrc b/.jshintrc index dc1d78b..a05f209 100644 --- a/.jshintrc +++ b/.jshintrc @@ -1,38 +1,32 @@ { - "esnext": true, "bitwise": true, - "camelcase": true, "curly": true, "eqeqeq": true, - "immed": true, - "indent": 2, + "esnext": true, + "freeze": true, "latedef": true, - "newcap": true, "noarg": true, - "quotmark": "true", - "regexp": true, + "nonbsp": true, + "nonew": true, + "notypeof": true, + "shadow": false, + "strict": true, "undef": true, "unused": true, - "strict": true, - "trailing": true, - "smarttabs": true, "globals": { "_": false, "affix": false, "after": false, - "afterEach": false, "angular": false, "before": false, - "beforeEach": false, "browser": false, - "describe": false, + "console": false, "expect": false, "inject": false, - "it": false, - "jasmine": false, - "spyOn": false + "installPromiseMatchers": false, + "module": false }, "browser": true, "jquery": true, - "node": true + "jasmine": true } diff --git a/Gruntfile.js b/Gruntfile.js index e190758..1e0c6e8 100644 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -39,7 +39,7 @@ module.exports = function (grunt) { watch: { bower: { files: ['bower.json'], - tasks: ['wiredep'] + tasks: ['wiredep', 'copy:svg'] }, js: { files: ['<%= yeoman.app %>/**/*.js', '!<%= yeoman.app %>/**/*_test.js'], @@ -52,12 +52,17 @@ module.exports = function (grunt) { files: ['<%= yeoman.app %>/**/*_test.js'], tasks: ['karma:continuous:run'] }, + svg: { + files: ['<%= yeoman.app %>/images/**/*.svg', '!<%= yeoman.app %>/images/sprite/**'], + tasks: ['svg_sprite:dist'] + }, gruntfile: { files: ['Gruntfile.js'] }, livereload: { files: [ '<%= yeoman.app %>/**/*.html', + '<%= yeoman.app %>/**/*.css', '<%= yeoman.app %>/styles/{,*/}*.css', '<%= yeoman.app %>/images/{,*/}*.{png,jpg,jpeg,gif,webp,svg}' ] @@ -89,20 +94,6 @@ module.exports = function (grunt) { } } }, - test: { - options: { - port: 9001, - middleware: function (connect) { - return [ - connect().use( - '/bower_components', - connect.static('./bower_components') - ), - connect.static(appConfig.app) - ]; - } - } - }, coverage: { options: { open: true, @@ -123,7 +114,7 @@ module.exports = function (grunt) { // Test settings karma: { options: { - configFile: './karma.conf.js', + configFile: './karma.conf.js' }, unit: { singleRun: true, @@ -161,22 +152,6 @@ module.exports = function (grunt) { } }, - // Make sure code styles are up to par and there are no obvious mistakes - jshint: { - options: { - jshintrc: '.jshintrc', - reporter: require('jshint-stylish'), - force: true //TODO: while I work on correcting those errors, don't block the build - }, - all: { - src: [ - 'Gruntfile.js', - '<%= yeoman.app %>/**/*.js', - '!<%= yeoman.app %>/vendor/**/*.js' - ] - } - }, - // Empties folders to start fresh clean: { dist: { @@ -218,7 +193,7 @@ module.exports = function (grunt) { // Performs rewrites based on filerev and the useminPrepare configuration usemin: { - html: ['<%= yeoman.dist %>/{,*/}*.html'], + html: ['<%= yeoman.dist %>/**/*.html'], css: ['<%= yeoman.dist %>/styles/{,*/}*.css'], js: ['<%= yeoman.dist %>/scripts/*.js'], options: { @@ -264,6 +239,22 @@ module.exports = function (grunt) { } }, + svg_sprite: { + options: { + mode: { + symbol: { + dest: '', + sprite: 'jamstash-sprite.svg' + } + } + }, + dist: { + cwd: '<%= yeoman.app %>/images', + src: ['**/*.svg', '!sprite/**/*.svg'], + dest: '<%= yeoman.app %>/images/sprite' + } + }, + // Minify our CSS files but do not merge them, we still want to have two cssmin: { styles: { @@ -271,7 +262,7 @@ module.exports = function (grunt) { expand: true, cwd: '.tmp/styles', src: ['*.css', '!*.min.css'], - dest: '<%= yeoman.dist %>/styles', + dest: '<%= yeoman.dist %>/styles' }] } }, @@ -309,6 +300,12 @@ module.exports = function (grunt) { ], dest: '<%= yeoman.dist %>' }, + { + expand: true, + cwd: '<%= yeoman.app %>', + src: ['images/sprite/*.svg'], + dest: '<%= yeoman.dist %>' + }, { expand: true, cwd: '<%= yeoman.app %>', @@ -324,6 +321,13 @@ module.exports = function (grunt) { 'bower_components/fancybox/source/*.{png,gif}' ], dest: '.tmp/styles' + } + ] + }, + svg: { + files: [{ + src: ['bower_components/open-iconic/sprite/sprite.svg'], + dest: '<%= yeoman.app %>/images/sprite/iconic.svg' }] } }, @@ -365,7 +369,7 @@ module.exports = function (grunt) { sftp: { test: { files: { - './': ['<%= yeoman.dist %>/{,*/}*', '<%= yeoman.dist %>/.git*'] + './': ['<%= yeoman.dist %>/**/*', '<%= yeoman.dist %>/.git*'] }, options: { path: '/var/www/jamstash', @@ -388,7 +392,7 @@ module.exports = function (grunt) { } }); - grunt.registerTask('serve', 'Compile then start a connect web server', function(target) { + grunt.registerTask('serve', 'Compile then start a connect web server', function (target) { if (target === 'dist') { return grunt.task.run(['build', 'connect:dist:keepalive']); } @@ -401,14 +405,13 @@ module.exports = function (grunt) { ]); }); - grunt.registerTask('test', 'Run unit tests and jshint', function() { + grunt.registerTask('test', 'Run unit tests and jshint', function () { return grunt.task.run([ - 'karma:unit', - 'jshint' + 'karma:unit' ]); }); - grunt.registerTask('coverage', 'Run unit tests and display test coverage results on browser', function() { + grunt.registerTask('coverage', 'Run unit tests and display test coverage results on browser', function () { return grunt.task.run([ 'clean:coverage', 'karma:unit', @@ -416,10 +419,11 @@ module.exports = function (grunt) { ]); }); - grunt.registerTask('build', 'Concatenate all JS files, minify all JS, CSS, HTML and image files and version all static assets', function() { + grunt.registerTask('build', 'Concatenate all JS files, minify all JS, CSS, HTML and image files and version all static assets', function () { return grunt.task.run([ 'clean:dist', 'wiredep:app', + 'copy:svg', 'useminPrepare', 'concat:generated', 'copy:dist', @@ -433,7 +437,7 @@ module.exports = function (grunt) { ]); }); - grunt.registerTask('deploy', 'Build and deploy to test server', function() { + grunt.registerTask('deploy', 'Build and deploy to test server', function () { return grunt.task.run([ 'build', 'sshexec:cleanTest', diff --git a/app/archive/archive-service.js b/app/archive/archive-service.js index e2c37b4..01166f2 100644 --- a/app/archive/archive-service.js +++ b/app/archive/archive-service.js @@ -23,44 +23,45 @@ angular.module('jamstash.archive.service', ['jamstash.settings.service', 'jamsta }; var offset = 0; - var mapAlbum = function (data) { - var song = data; - var coverartthumb, coverartfull, starred, title, album, publisher, avg_rating, downloads, identifier, source, date; - var url = globals.archiveUrl + 'details/' + song.identifier; - coverartthumb = 'images/albumdefault_50.jpg'; - coverartfull = 'images/albumdefault_160.jpg'; - if (parseInt(song.avg_rating) == 5) { starred = true; } else { starred = false; } - if (typeof song.title == 'undefined') { title = ' '; } else { title = song.title.toString(); } - if (typeof song.identifier == 'undefined') { identifier = ' '; } else { identifier = song.identifier.toString(); } - if (typeof song.collection[0] == 'undefined') { album = ' '; } else { album = song.collection[0].toString(); } - if (typeof song.source == 'undefined') { source = ' '; } else { source = song.source.toString(); } - if (typeof song.date == 'undefined') { date = ' '; } else { date = song.date.toString(); } - if (typeof song.publisher == 'undefined') { publisher = ' '; } else { publisher = song.publisher.toString(); } - if (typeof song.avg_rating == 'undefined') { avg_rating = ' '; } else { avg_rating = song.avg_rating.toString(); } - if (typeof song.downloads == 'undefined') { downloads = ' '; } else { downloads = song.downloads.toString(); } + var archiveService = { + mapAlbum: function (data) { + var song = data; + var coverartthumb, coverartfull, starred, title, album, publisher, avg_rating, downloads, identifier, source, date; + var url = globals.archiveUrl + 'details/' + song.identifier; + coverartthumb = 'images/albumdefault_50.jpg'; + coverartfull = 'images/albumdefault_160.jpg'; + if (parseInt(song.avg_rating) == 5) { starred = true; } else { starred = false; } + if (typeof song.title == 'undefined') { title = ' '; } else { title = song.title.toString(); } + if (typeof song.identifier == 'undefined') { identifier = ' '; } else { identifier = song.identifier.toString(); } + if (typeof song.collection[0] == 'undefined') { album = ' '; } else { album = song.collection[0].toString(); } + if (typeof song.source == 'undefined') { source = ' '; } else { source = song.source.toString(); } + if (typeof song.date == 'undefined') { date = ' '; } else { date = song.date.toString(); } + if (typeof song.publisher == 'undefined') { publisher = ' '; } else { publisher = song.publisher.toString(); } + if (typeof song.avg_rating == 'undefined') { avg_rating = ' '; } else { avg_rating = song.avg_rating.toString(); } + if (typeof song.downloads == 'undefined') { downloads = ' '; } else { downloads = song.downloads.toString(); } - //var description = 'Details
'; - var description = 'Source: ' + source + '
'; - description += 'Date: ' + date + '
'; - description += 'Transferer: ' + publisher + '
'; - description += 'Rating: ' + avg_rating + '
'; - description += 'Downloads: ' + downloads + '
'; - return new model.Album(identifier, null, title, album, '', coverartthumb, coverartfull, $.format.date(new Date(song.publicdate), "yyyy-MM-dd h:mm a"), starred, $sce.trustAsHtml(description), url); - }; - var mapSong = function (key, song, server, dir, identifier, coverart) { - var url, time, track, title, rating, starred, contenttype, suffix; - var specs = ''; - if (song.format == 'VBR MP3') { - url = 'http://' + server + dir + key; - if (typeof song.bitrate == 'undefined' || typeof song.format == 'undefined') { specs = ' '; } else { specs = song.bitrate + 'kbps, ' + song.format.toLowerCase(); } - if (typeof song.track == 'undefined') { track = ' '; } else { track = song.track; } - if (typeof song.title == 'undefined') { title = ' '; } else { title = song.title; } - if (typeof song.length == 'undefined') { time = ' '; } else { time = utils.timeToSeconds(song.length); } - return new model.Song(song.md5, identifier, song.track, title, song.creator, '', song.album, '', coverart, coverart, time, '', '', 'mp3', specs, url, 0, ''); - } - }; + //var description = 'Details
'; + var description = 'Source: ' + source + '
'; + description += 'Date: ' + date + '
'; + description += 'Transferer: ' + publisher + '
'; + description += 'Rating: ' + avg_rating + '
'; + description += 'Downloads: ' + downloads + '
'; + return new model.Album(identifier, null, title, album, '', coverartthumb, coverartfull, utils.formatDate(new Date(song.publicdate), "yyyy-MM-dd h:mm a"), starred, $sce.trustAsHtml(description), url); + }, + + mapSong: function (key, song, server, dir, identifier, coverart) { + var url, time, track, title, rating, starred, contenttype, suffix; + var specs = ''; + if (song.format == 'VBR MP3') { + url = 'http://' + server + dir + key; + if (typeof song.bitrate == 'undefined' || typeof song.format == 'undefined') { specs = ' '; } else { specs = song.bitrate + 'kbps, ' + song.format.toLowerCase(); } + if (typeof song.track == 'undefined') { track = ' '; } else { track = song.track; } + if (typeof song.title == 'undefined') { title = ' '; } else { title = song.title; } + if (typeof song.length == 'undefined') { time = ' '; } else { time = utils.timeToSeconds(song.length); } + return new model.Song(song.md5, identifier, song.track, title, song.creator, '', song.album, '', coverart, coverart, time, '', '', 'mp3', specs, url, 0, ''); + } + }, - return { getArtists: function (query) { var deferred = $q.defer(); if (globals.settings.Debug) { console.log("LOAD ARCHIVE.ORG COLLECTIONS"); } @@ -138,7 +139,7 @@ angular.module('jamstash.archive.service', ['jamstash.settings.service', 'jamsta content.album = []; content.song = []; angular.forEach(items, function (item, key) { - content.album.push(mapAlbum(item)); + content.album.push(archiveService.mapAlbum(item)); }); notifications.updateMessage(content.album.length + ' Items Returned', true); } else { @@ -178,7 +179,7 @@ angular.module('jamstash.archive.service', ['jamstash.settings.service', 'jamsta var items = data.files; if (action == 'add') { angular.forEach(items, function (item, key) { - var song = mapSong(key, item, server, dir, identifier, coverart); + var song = archiveService.mapSong(key, item, server, dir, identifier, coverart); if (song) { player.queue.push(song); } @@ -187,7 +188,7 @@ angular.module('jamstash.archive.service', ['jamstash.settings.service', 'jamsta } else if (action == 'play') { player.queue = []; angular.forEach(items, function (item, key) { - var song = mapSong(key, item, server, dir, identifier, coverart); + var song = archiveService.mapSong(key, item, server, dir, identifier, coverart); if (song) { player.queue.push(song); } @@ -199,7 +200,7 @@ angular.module('jamstash.archive.service', ['jamstash.settings.service', 'jamsta content.album = []; content.song = []; angular.forEach(items, function (item, key) { - var song = mapSong(key, item, server, dir, identifier, coverart); + var song = archiveService.mapSong(key, item, server, dir, identifier, coverart); if (song) { content.song.push(song); } @@ -214,4 +215,5 @@ angular.module('jamstash.archive.service', ['jamstash.settings.service', 'jamsta return deferred.promise; } }; + return archiveService; }]); diff --git a/app/archive/archive-service_test.js b/app/archive/archive-service_test.js index 543f5a7..981d047 100644 --- a/app/archive/archive-service_test.js +++ b/app/archive/archive-service_test.js @@ -1,4 +1,52 @@ describe("archive service", function() { - 'use strict'; + 'use strict'; -}); \ No newline at end of file + var archive, mockBackend, mockGlobals, utils, + response; + + beforeEach(function() { + + mockGlobals = { + archiveUrl: "http://hysterotomy.com/hippolytus/quercitrin?a=chillagite&b=savour#superfecundation" + }; + + module('jamstash.archive.service', function ($provide) { + $provide.value('globals', mockGlobals); + $provide.decorator('player', function () { + var playerService = jasmine.createSpyObj("player", ["play"]); + playerService.queue = []; + return playerService; + }); + $provide.decorator('notifications', function () { + return jasmine.createSpyObj("notifications", ["updateMessage"]); + }); + $provide.decorator('utils', function () { + return jasmine.createSpyObj("utils", ["formatDate"]); + }); + }); + + inject(function (_archive_, $httpBackend, _utils_) { + archive = _archive_; + mockBackend = $httpBackend; + utils = _utils_; + }); + }); + + afterEach(function() { + mockBackend.verifyNoOutstandingExpectation(); + mockBackend.verifyNoOutstandingRequest(); + }); + + describe("mapAlbum() -", function() { + it("Given album data with a publicDate defined, when I map it to an Album, then utils.formatDate will be called", function() { + var albumData = { + id: 504, + publicDate: "2015-03-29T18:22:06.000Z", + collection: ['Sternal Daubreelite'] + }; + + archive.mapAlbum(albumData); + expect(utils.formatDate).toHaveBeenCalledWith(jasmine.any(Date), "yyyy-MM-dd h:mm a"); + }); + }); +}); diff --git a/app/archive/archive.html b/app/archive/archive.html index d9ff685..7a4ea67 100644 --- a/app/archive/archive.html +++ b/app/archive/archive.html @@ -44,7 +44,7 @@ - +
{{o.name}}
@@ -90,4 +90,4 @@
- \ No newline at end of file + diff --git a/app/archive/archive.js b/app/archive/archive.js index fe12d8a..9ab4110 100644 --- a/app/archive/archive.js +++ b/app/archive/archive.js @@ -166,6 +166,10 @@ angular.module('jamstash.archive.controller', ['jamstash.archive.service']) $rootScope.removeSong(item, $scope.song); }; + $scope.toggleStar = function (item) { + //Do nothing: we aren't logged in archive.org, so we can't star anything there. + }; + /* Launch on Startup */ //$scope.getArtists(); $scope.getAlbums(); diff --git a/app/common/main-controller.js b/app/common/main-controller.js index e972870..0aa3bbe 100644 --- a/app/common/main-controller.js +++ b/app/common/main-controller.js @@ -1,6 +1,6 @@ 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) { +.controller('AppController', ['$scope', '$rootScope', '$document', '$window', '$location', '$cookieStore', '$http', 'utils', 'globals', 'model', 'notifications', 'player', 'persistence', 'Page', 'subsonic', + function ($scope, $rootScope, $document, $window, $location, $cookieStore, $http, utils, globals, model, notifications, player, persistence, Page, subsonic) { 'use strict'; $rootScope.settings = globals.settings; @@ -10,7 +10,6 @@ angular.module('JamStash') $rootScope.Genres = []; $rootScope.Messages = []; - $rootScope.SelectedMusicFolder = ""; $rootScope.unity = null; $scope.Page = Page; $rootScope.loggedIn = function () { @@ -380,27 +379,13 @@ angular.module('JamStash') }); }; - $scope.updateFavorite = function (item) { - var id = item.id; - var starred = item.starred; - var url; - if (starred) { - url = globals.BaseURL() + '/unstar.view?' + globals.BaseParams() + '&id=' + id; - item.starred = undefined; - } else { - url = globals.BaseURL() + '/star.view?' + globals.BaseParams() + '&id=' + id; - item.starred = true; - } - $.ajax({ - url: url, - method: 'GET', - dataType: globals.settings.Protocol, - timeout: globals.settings.Timeout, - success: function () { - notifications.updateMessage('Favorite Updated!', true); - } + $scope.toggleStar = function (item) { + subsonic.toggleStar(item).then(function (newStarred) { + item.starred = newStarred; + notifications.updateMessage('Favorite Updated!', true); }); }; + $scope.toTrusted = function (html) { return $sce.trustAsHtml(html); }; diff --git a/app/common/main-controller_test.js b/app/common/main-controller_test.js index 8c9b659..44fe035 100644 --- a/app/common/main-controller_test.js +++ b/app/common/main-controller_test.js @@ -1,13 +1,14 @@ describe("Main controller", function() { 'use strict'; - var controllerParams, $controller, scope, mockGlobals, player, utils, persistence; + var controllerParams, $controller, $q, scope, mockGlobals, player, utils, persistence, subsonic, notifications, + deferred; beforeEach(function() { mockGlobals = { settings: { SaveTrackPosition: false, ShowQueue: false, - Debug: true, + Debug: false, Jukebox: false } }; @@ -30,9 +31,21 @@ describe("Main controller", function() { "saveSettings" ]); - inject(function (_$controller_, $rootScope, _$document_, _$window_, _$location_, _$cookieStore_, _utils_, globals, _model_, _notifications_, _Page_) { + // Mock the subsonic service + subsonic = jasmine.createSpyObj("subsonic", [ + "toggleStar" + ]); + + // Mock the notifications service + notifications = jasmine.createSpyObj("notifications", [ + "updateMessage" + ]); + + inject(function (_$controller_, $rootScope, _$q_, _$document_, _$window_, _$location_, _$cookieStore_, _utils_, globals, _model_, _Page_) { scope = $rootScope.$new(); utils = _utils_; + $q = _$q_; + deferred = $q.defer(); spyOn(utils, "switchTheme"); @@ -47,37 +60,15 @@ describe("Main controller", function() { utils: utils, globals: globals, model: _model_, - notifications: _notifications_, + notifications: notifications, player: player, persistence: persistence, - Page: _Page_ + Page: _Page_, + subsonic: subsonic }; }); }); - 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() { }); @@ -191,6 +182,34 @@ describe("Main controller", function() { }); }); + + describe("toggleStar() -", function() { + beforeEach(function() { + subsonic.toggleStar.and.returnValue(deferred.promise); + }); + + it("Given an artist that was not starred, when I toggle its star, then subsonic service will be called, the artist will be starred and a notification will be displayed", function() { + var artist = { id: 4218, starred: false }; + scope.toggleStar(artist); + deferred.resolve(true); + scope.$apply(); + + expect(subsonic.toggleStar).toHaveBeenCalledWith(artist); + expect(artist.starred).toBeTruthy(); + expect(notifications.updateMessage).toHaveBeenCalledWith('Favorite Updated!', true); + }); + + it("Given a song that was starred, when I toggle its star, then subsonic service will be called, the song will be starred and a notification will be displayed", function() { + var song = { id: 784, starred: true }; + scope.toggleStar(song); + deferred.resolve(false); + scope.$apply(); + + expect(subsonic.toggleStar).toHaveBeenCalledWith(song); + expect(song.starred).toBeFalsy(); + expect(notifications.updateMessage).toHaveBeenCalledWith('Favorite Updated!', true); + }); + }); }); describe("When starting up,", function() { diff --git a/app/common/model-service_test.js b/app/common/model-service_test.js index 8729d5b..e411286 100644 --- a/app/common/model-service_test.js +++ b/app/common/model-service_test.js @@ -58,7 +58,7 @@ describe("model service", function() { expect(result).toEqual(episodes); }); - it("Given album data without artist info, when I map it to an Album, an Album with an empty artist name will be returned", function() { + it("Given album data without artist info, when I map it to an Album, then an Album with an empty artist name will be returned", function() { var albumData = { id: 584, artist: undefined, diff --git a/app/common/persistence-service.js b/app/common/persistence-service.js index 02c145f..85236f2 100644 --- a/app/common/persistence-service.js +++ b/app/common/persistence-service.js @@ -77,6 +77,19 @@ angular.module('jamstash.persistence', ['angular-locker', locker.forget('Volume'); }; + /* Manage selected music folder */ + this.getSelectedMusicFolder = function () { + return locker.get('MusicFolders'); + }; + + this.saveSelectedMusicFolder = function (selectedMusicFolder) { + locker.put('MusicFolders', selectedMusicFolder); + }; + + this.deleteSelectedMusicFolder = function () { + locker.forget('MusicFolders'); + }; + /* Manage user settings */ this.getSettings = function () { // If the latest version from changelog.json is newer than the version stored in local storage, diff --git a/app/common/persistence-service_test.js b/app/common/persistence-service_test.js index 5512dee..93e32b0 100644 --- a/app/common/persistence-service_test.js +++ b/app/common/persistence-service_test.js @@ -154,6 +154,48 @@ describe("Persistence service", function() { expect(locker.forget).toHaveBeenCalledWith('Volume'); }); + describe("getSelectedMusicFolder() -", function() { + it("Given a previously saved selected music folder in local storage, when I get the saved music folder, then an object containing the id and name of the selected music folder will be returned", function() { + fakeStorage = { + 'MusicFolders': { + 'id': 74, + 'name': 'kooliman unhurled' + } + }; + + var selectedMusicFolder = persistence.getSelectedMusicFolder(); + + expect(locker.get).toHaveBeenCalledWith('MusicFolders'); + expect(selectedMusicFolder).toEqual({ + 'id': 74, + 'name': 'kooliman unhurled' + }); + }); + + it("Given that no selected music folder was previously saved in local storage, when I get the saved music folder, then undefined will be returned", function() { + var selectedMusicFolder = persistence.getSelectedMusicFolder(); + + expect(locker.get).toHaveBeenCalledWith('MusicFolders'); + expect(selectedMusicFolder).toBeUndefined(); + }); + }); + + it("saveSelectedMusicFolder() - given an object containing the id and name of the selected music folder, when I save the music folder, then it will be set in local storage", function() { + persistence.saveSelectedMusicFolder({ + id: 41, + name: 'parlormaid carcinolytic' + }); + expect(locker.put).toHaveBeenCalledWith('MusicFolders', { + id: 41, + name: 'parlormaid carcinolytic' + }); + }); + + it("deleteSelectedMusicFolder() - when I delete the selected music folder, then it will be erased from local storage", function() { + persistence.deleteSelectedMusicFolder(); + expect(locker.forget).toHaveBeenCalledWith('MusicFolders'); + }); + describe("getSettings() -", function() { beforeEach(function() { spyOn(persistence, 'upgradeVersion'); diff --git a/app/common/songs.html b/app/common/songs.html index 37c442c..8df712b 100644 --- a/app/common/songs.html +++ b/app/common/songs.html @@ -2,9 +2,9 @@
- + - +
diff --git a/app/common/songs_lite.html b/app/common/songs_lite.html index 9997004..2509e98 100644 --- a/app/common/songs_lite.html +++ b/app/common/songs_lite.html @@ -1,7 +1,7 @@
  • - +
    diff --git a/app/images/loop-single.svg b/app/images/loop-single.svg new file mode 100644 index 0000000..094b972 --- /dev/null +++ b/app/images/loop-single.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/images/loop_alt3_gd_12x9.png b/app/images/loop_alt3_gd_12x9.png deleted file mode 100644 index 10842f9..0000000 Binary files a/app/images/loop_alt3_gd_12x9.png and /dev/null differ diff --git a/app/images/loop_alt3_w_12x9.png b/app/images/loop_alt3_w_12x9.png deleted file mode 100644 index f2bd9bc..0000000 Binary files a/app/images/loop_alt3_w_12x9.png and /dev/null differ diff --git a/app/images/sprite/iconic.svg b/app/images/sprite/iconic.svg new file mode 100644 index 0000000..63d2cf4 --- /dev/null +++ b/app/images/sprite/iconic.svg @@ -0,0 +1,673 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/images/sprite/jamstash-sprite.svg b/app/images/sprite/jamstash-sprite.svg new file mode 100644 index 0000000..6d61a3b --- /dev/null +++ b/app/images/sprite/jamstash-sprite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/index.html b/app/index.html index ef4500e..052a1d4 100755 --- a/app/index.html +++ b/app/index.html @@ -18,6 +18,10 @@ + + + +
    @@ -87,13 +91,12 @@ + - - @@ -113,6 +116,7 @@ + diff --git a/app/player/player-directive_test.js b/app/player/player-directive_test.js index 60ddddc..ea48650 100644 --- a/app/player/player-directive_test.js +++ b/app/player/player-directive_test.js @@ -32,7 +32,6 @@ describe("jplayer directive", function() { return $delegate; }); $provide.value('globals', mockGlobals); - $provide.constant('jamstashVersion', '1.0.0'); }); spyOn($.fn, "jPlayer").and.callThrough(); diff --git a/app/player/player-service.js b/app/player/player-service.js index 5752941..441cfd3 100644 --- a/app/player/player-service.js +++ b/app/player/player-service.js @@ -3,9 +3,9 @@ * * 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.service', 'angular-underscore/utils']) +angular.module('jamstash.player.service', ['angular-underscore/utils']) -.factory('player', ['globals', function (globals) { +.factory('player', [function () { 'use strict'; var playerVolume = 1.0; @@ -18,6 +18,10 @@ angular.module('jamstash.player.service', ['jamstash.settings.service', 'angular pauseSong: false, restartSong: false, loadSong: false, + settings: { + repeat: "none", + repeatValues: ["queue", "song", "none"] + }, play: function (song) { // Find the song's index in the queue, if it's in there @@ -56,11 +60,11 @@ angular.module('jamstash.player.service', ['jamstash.settings.service', 'angular // Called from the player directive at the end of the current song songEnded: function () { - if (globals.settings.Repeat) { + if (player.settings.repeat === "song") { // repeat current track player.restart(); } else if (player.isLastSongPlaying() === true) { - if (globals.settings.LoopQueue) { + if (player.settings.repeat === "queue") { // Loop to first track in queue player.playFirstSong(); } @@ -101,7 +105,13 @@ angular.module('jamstash.player.service', ['jamstash.settings.service', 'angular }, shuffleQueue: function () { - player.queue = _(player.queue).shuffle(); + var shuffled = _(player.queue).without(player._playingSong); + shuffled = _(shuffled).shuffle(); + if(player._playingSong !== undefined) { + shuffled.unshift(player._playingSong); + player._playingIndex = 0; + } + player.queue = shuffled; return player; }, diff --git a/app/player/player-service_test.js b/app/player/player-service_test.js index 9aa5b0b..65fc1e8 100644 --- a/app/player/player-service_test.js +++ b/app/player/player-service_test.js @@ -1,21 +1,13 @@ describe("Player service -", function() { 'use strict'; - var player, mockGlobals, firstSong, secondSong, thirdSong, newSong; + var player, 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); - }); + module('jamstash.player.service'); inject(function (_player_) { player = _player_; }); + player.settings.repeat = "none"; }); describe("Given that I have 3 songs in my playing queue,", function() { @@ -138,6 +130,25 @@ describe("Player service -", function() { expect(player.queue).toEqual([]); }); + it("and given the third song was playing, when I shuffle the playing queue, then the third song will be at the first position and the rest of the queue will be shuffled", function() { + player._playingSong = thirdSong; + + player.shuffleQueue(); + + expect(player.queue[0]).toBe(thirdSong); + expect(player.queue).toContain(firstSong); + expect(player.queue).toContain(secondSong); + }); + + it("and given no song was playing, when I shuffle the playing queue, then the whole queue will be shuffled", function() { + player.shuffleQueue(); + + expect(player.queue).toContain(firstSong); + expect(player.queue).toContain(secondSong); + expect(player.queue).toContain(thirdSong); + expect(player.queue).not.toContain(undefined); + }); + it("when I get the index of the first song, it returns 0", function() { expect(player.indexOfSong(firstSong)).toBe(0); }); @@ -187,9 +198,9 @@ describe("Player service -", function() { expect(player.nextTrack).toHaveBeenCalled(); }); - it("and that the 'Repeat song' setting is true, when the current song ends, it restarts it", function() { + it("and that the 'Repeat' setting is set to 'song', when the current song ends, it restarts it", function() { spyOn(player, "restart"); - mockGlobals.settings.Repeat = true; + player.settings.repeat = "song"; player.songEnded(); @@ -201,9 +212,9 @@ describe("Player service -", function() { player._playingIndex = 2; }); - it("if the 'Repeat queue' setting is true, it plays the first song of the queue", function() { + it("if the 'Repeat' setting is set to 'queue', it plays the first song of the queue", function() { spyOn(player, "playFirstSong"); - mockGlobals.settings.LoopQueue = true; + player.settings.repeat = "queue"; player.songEnded(); diff --git a/app/player/player.css b/app/player/player.css new file mode 100644 index 0000000..9c68e19 --- /dev/null +++ b/app/player/player.css @@ -0,0 +1,9 @@ +.icon { + height: 12px; + width: 12px; + display: block; +} +.icon-wrap { + margin: 4px 2px; + float: left; +} diff --git a/app/player/player.html b/app/player/player.html index 6c7d9f3..2318e5a 100644 --- a/app/player/player.html +++ b/app/player/player.html @@ -17,7 +17,7 @@
    @@ -27,9 +27,9 @@
    - + - + diff --git a/app/player/player.js b/app/player/player.js index e296a71..18eb078 100644 --- a/app/player/player.js +++ b/app/player/player.js @@ -5,13 +5,15 @@ * 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']) +angular.module('jamstash.player.controller', ['jamstash.player.service', 'jamstash.player.directive', 'jamstash.repeat.directive']) .controller('PlayerController', ['$scope', 'player', 'globals', - function($scope, player, globals){ + function ($scope, player, globals) { 'use strict'; $scope.getPlayingSong = player.getPlayingSong; + $scope.settings = globals.settings; + $scope.playerSettings = player.settings; $scope.play = function () { if (globals.settings.Jukebox) { @@ -31,6 +33,4 @@ angular.module('jamstash.player.controller', ['jamstash.player.service', 'jamsta $scope.previousTrack = player.previousTrack; $scope.nextTrack = player.nextTrack; - - //TODO: Hyz: updateFavorite - leave in rootScope ? }]); diff --git a/app/player/player_test.js b/app/player/player_test.js index 7991b5a..e99c68b 100644 --- a/app/player/player_test.js +++ b/app/player/player_test.js @@ -1,40 +1,64 @@ describe("Player controller", function() { 'use strict'; - var player, scope; + var player, scope, mockGlobals; beforeEach(function() { + // We redefine globals because in some tests we need to alter the settings + mockGlobals = { + settings: { + Jukebox: false + } + }; + module('jamstash.player.controller'); inject(function ($controller, $rootScope) { scope = $rootScope.$new(); - player = jasmine.createSpyObj("player", ["getPlayingSong", "previousTrack", "nextTrack"]); + player = jasmine.createSpyObj("player", [ + "getPlayingSong", + "previousTrack", + "nextTrack", + "getRepeatValues", + "togglePause" + ]); $controller('PlayerController', { $scope: scope, - player: player + player: player, + globals: mockGlobals }); }); }); - it("When I get the currently playing song, it asks the player service", function() { + it("When I play a song, the player service will be called", function() { + scope.play(); + + expect(player.togglePause).toHaveBeenCalled(); + }); + + it("when I pause a song, the player service will be called", function() { + scope.pause(); + + expect(player.togglePause).toHaveBeenCalled(); + }); + + it("When I get the currently playing song, the player service will be called", function() { scope.getPlayingSong(); expect(player.getPlayingSong).toHaveBeenCalled(); }); - it("When I get the previous track, it uses the player service", function() { + it("When I get the previous track, the player service will be called", function() { scope.previousTrack(); expect(player.previousTrack).toHaveBeenCalled(); }); - it("When I get the next track, it uses the player service", function() { + it("When I get the next track, the player service will be called", function() { scope.nextTrack(); expect(player.nextTrack).toHaveBeenCalled(); }); - - // TODO: updateFavorite }); diff --git a/app/player/repeat-directive/repeat-directive.css b/app/player/repeat-directive/repeat-directive.css new file mode 100644 index 0000000..0719c71 --- /dev/null +++ b/app/player/repeat-directive/repeat-directive.css @@ -0,0 +1,6 @@ +.icon-loop-queue, .icon-loop-single { + fill: #fff; +} +.icon-loop-none { + fill: #adadad; +} diff --git a/app/player/repeat-directive/repeat-directive.html b/app/player/repeat-directive/repeat-directive.html new file mode 100644 index 0000000..a9bc0f1 --- /dev/null +++ b/app/player/repeat-directive/repeat-directive.html @@ -0,0 +1,20 @@ + + + + Repeat the playing queue + + + + + + Repeat the current song + + + + + + Disable repeat + + + + diff --git a/app/player/repeat-directive/repeat-directive.js b/app/player/repeat-directive/repeat-directive.js new file mode 100644 index 0000000..616e0c1 --- /dev/null +++ b/app/player/repeat-directive/repeat-directive.js @@ -0,0 +1,29 @@ +/** +* jamstash.repeat.directive Module +* +* Triple-state button to toggle between repeating the entire playing queue, the current playing song and disabling repeat +*/ +angular.module('jamstash.repeat.directive', ['jamstash.notifications']) + +.directive('jamstashRepeat', ['notifications', function (notifications) { + 'use strict'; + return { + restrict: 'E', + templateUrl: 'player/repeat-directive/repeat-directive.html', + replace: true, + scope: { + selectedValue: '=', + values: '=' + }, + link: function ($scope) { + $scope.$watch('selectedValue', function (newVal) { + $scope.selectedIndex = $scope.values.indexOf(newVal); + }); + $scope.cycleRepeat = function () { + $scope.selectedIndex = ($scope.selectedIndex + 1) % $scope.values.length; + $scope.selectedValue = $scope.values[$scope.selectedIndex]; + notifications.updateMessage('Repeat ' + $scope.selectedValue, true); + }; + } + }; +}]); diff --git a/app/player/repeat-directive/repeat-directive_test.js b/app/player/repeat-directive/repeat-directive_test.js new file mode 100644 index 0000000..b686b7b --- /dev/null +++ b/app/player/repeat-directive/repeat-directive_test.js @@ -0,0 +1,69 @@ +describe("repeat directive", function() { + 'use strict'; + + var element, scope, isolateScope, notifications, mockGlobals; + + beforeEach(module ('templates')); + beforeEach(function() { + // We redefine globals because in some tests we need to alter the settings + mockGlobals = { + settings: { + RepeatValues: ["queue", "song", "none"], + Repeat: "none" + } + }; + + module('jamstash.repeat.directive', function($provide) { + $provide.value('globals', mockGlobals); + // Mock the notifications service + $provide.decorator('notifications', function () { + return jasmine.createSpyObj("notifications", ["updateMessage"]); + }); + }); + + inject(function ($rootScope, $compile, _notifications_) { + notifications = _notifications_; + // Compile the directive + scope = $rootScope.$new(); + scope.settings = mockGlobals.settings; + element = ''; + element = $compile(element)(scope); + scope.$digest(); + isolateScope = element.isolateScope(); + }); + }); + + it("Given that the Repeat setting was set to 'none', when I cycle through the values, then the Repeat setting will be set to 'queue'", function() { + isolateScope.cycleRepeat(); + isolateScope.$apply(); + + expect(mockGlobals.settings.Repeat).toBe('queue'); + }); + + it("Given that the Repeat setting was set to 'queue', when I cycle through the values, then the Repeat setting will be set to 'song'", function() { + mockGlobals.settings.Repeat = 'queue'; + isolateScope.$apply(); + + isolateScope.cycleRepeat(); + isolateScope.$apply(); + + expect(mockGlobals.settings.Repeat).toBe('song'); + }); + + it("Given that the Repeat setting was set to 'song', when I cycle through the values, then the Repeat setting will be set to 'none", function() { + mockGlobals.settings.Repeat = 'song'; + isolateScope.$apply(); + + isolateScope.cycleRepeat(); + isolateScope.$apply(); + + expect(mockGlobals.settings.Repeat).toBe('none'); + }); + + it("When I cycle through the values, then the user will be notified with the new value", function() { + isolateScope.cycleRepeat(); + isolateScope.$apply(); + + expect(notifications.updateMessage).toHaveBeenCalledWith('Repeat queue', true); + }); +}); diff --git a/app/queue/queue.html b/app/queue/queue.html index 0ee61b1..cea869d 100644 --- a/app/queue/queue.html +++ b/app/queue/queue.html @@ -10,7 +10,7 @@
  • - +
    diff --git a/app/queue/queue.js b/app/queue/queue.js index eba8315..403cad8 100644 --- a/app/queue/queue.js +++ b/app/queue/queue.js @@ -4,7 +4,7 @@ * 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']) +angular.module('jamstash.queue.controller', ['jamstash.player.service', 'jamstash.settings.service']) .controller('QueueController', ['$scope', 'globals', 'player', function ($scope, globals, player) { @@ -16,21 +16,23 @@ angular.module('jamstash.queue.controller', ['jamstash.player.service']) player.play(song); }; - $scope.emptyQueue = function() { + $scope.emptyQueue = function () { player.emptyQueue(); //TODO: Hyz: Shouldn't it be in a directive ? $.fancybox.close(); }; - $scope.shuffleQueue = function() { + $scope.shuffleQueue = function () { player.shuffleQueue(); + //TODO: Hyz: Shouldn't it be in a directive ? + $('#SideBar').stop().scrollTo('.header', 400); }; - $scope.addSongToQueue = function(song) { + $scope.addSongToQueue = function (song) { player.addSong(song); }; - $scope.removeSongFromQueue = function(song) { + $scope.removeSongFromQueue = function (song) { player.removeSong(song); }; @@ -63,6 +65,4 @@ angular.module('jamstash.queue.controller', ['jamstash.player.service']) end = ui.item.index(); player.queue.splice(end, 0, player.queue.splice(start, 1)[0]); }; - - //TODO: Hyz: updateFavorite - leave in rootScope ? }]); diff --git a/app/queue/queue_test.js b/app/queue/queue_test.js index 859ee12..6d3f099 100644 --- a/app/queue/queue_test.js +++ b/app/queue/queue_test.js @@ -36,10 +36,14 @@ describe("Queue controller", function() { expect($.fancybox.close).toHaveBeenCalled(); }); - it("When I shuffle the queue, it calls shuffleQueue in the player service", function() { + it("When I shuffle the queue, then the player's shuffleQueue will be called and the queue will be scrolled back to the first element", function() { spyOn(player, "shuffleQueue"); + spyOn($.fn, 'scrollTo'); + scope.shuffleQueue(); + expect(player.shuffleQueue).toHaveBeenCalled(); + expect($.fn.scrollTo).toHaveBeenCalledWith('.header', jasmine.any(Number)); }); it("When I add one song to the queue, it calls addSong in the player service", function() { diff --git a/app/settings/settings.html b/app/settings/settings.html index 6cc47f3..fe5e96f 100644 --- a/app/settings/settings.html +++ b/app/settings/settings.html @@ -33,9 +33,6 @@
    -
    - -
    diff --git a/app/settings/settings.js b/app/settings/settings.js index 30c2bec..0075aff 100644 --- a/app/settings/settings.js +++ b/app/settings/settings.js @@ -71,6 +71,7 @@ angular.module('jamstash.settings.controller', ['jamstash.settings.service', 'ja $rootScope.showIndex = true; }, function (error) { //TODO: Hyz: Duplicate from subsonic.js - requestSongs. Find a way to handle this only once. + globals.settings.ApiVersion = error.version; var errorNotif; if (error.subsonicError !== undefined) { errorNotif = error.reason + ' ' + error.subsonicError.message; diff --git a/app/settings/settings_test.js b/app/settings/settings_test.js index 3755ad8..e1839b3 100644 --- a/app/settings/settings_test.js +++ b/app/settings/settings_test.js @@ -90,6 +90,24 @@ describe("Settings controller", function() { expect(subsonic.ping).toHaveBeenCalled(); }); + + it("Given the server and Jamstash had different api versions, when I save the settings and the server responds an error, then the ApiVersion setting will be updated with the one sent from the server", function() { + scope.settings.Server = 'http://gallotannate.com/tetranychus/puzzlement?a=stoically&b=mantuamaker#marianolatrist'; + scope.settings.Username = 'Vandervelden'; + scope.settings.Password = 'PA3DhdfAu0dy'; + scope.ApiVersion = '1.10.2'; + subsonic.ping.and.returnValue(deferred.promise); + + scope.save(); + deferred.reject({ + reason: 'Error when contacting the Subsonic server.', + subsonicError: {code: 30, message: 'Incompatible Subsonic REST protocol version. Server must upgrade.'}, + version: '1.8.0' + }); + scope.$apply(); + + expect(mockGlobals.settings.ApiVersion).toEqual('1.8.0'); + }); }); it("reset() - When I reset the settings, they will be deleted from the persistence service and will be reloaded with default values", function() { diff --git a/app/styles/Style.css b/app/styles/Style.css index ea869b8..91bc706 100644 --- a/app/styles/Style.css +++ b/app/styles/Style.css @@ -1,14 +1,14 @@ html, body { height: 100%; width: 100%; } -body +body { font: 100% Trebuchet MS, Arial, Helvetica, sans-serif; background: #fafafa; margin: 0; padding: 0; - text-align: center; + text-align: center; color: #5b5b4e; } -img +img { border: none; background: none; @@ -87,7 +87,7 @@ span.apiversion font-size: 10px; opacity: .8; } -#nav +#nav { height: 54px; /*width: 270px;*/ @@ -96,18 +96,18 @@ span.apiversion left: 50px; z-index: 99; } -#nav ul +#nav ul { list-style-type: none; padding: 0; margin: 0 0 0 4px; } -#nav li +#nav li { float: left; margin: 0 1px 0 0; } -#nav a +#nav a { display: block; padding: 8px 12px; @@ -122,34 +122,34 @@ span.apiversion border-left: 1px solid #D5D5D5; border-bottom: none; } -#nav a:hover +#nav a:hover { - color: #545454; + color: #545454; border: 1px solid #D5D5D5; border-bottom: none; } -#nav a.active +#nav a.active { - color: #545454; - background: #ffffff; + color: #545454; + background: #ffffff; border: 1px solid #CBCBCB; border-bottom: none; padding: 8px 12px 9px 12px; box-shadow: -2px 1px 2px -2px rgba(0, 0, 0, 0.25); } -#nav a.active:hover +#nav a.active:hover { - color: #545454; + color: #545454; border: 1px solid #CBCBCB; border-bottom: none; } -#nav a img +#nav a img { display: block; margin: 3px 0 4px 1px; } -#nav a.active img +#nav a.active img { display: block; margin: 3px 0 4px 1px; @@ -161,12 +161,12 @@ span.apiversion left: 12px; width: 32px; height: 32px; - background: url('../images/favicon_32x32.png') no-repeat 0 0; + background: url('../images/favicon_32x32.png') no-repeat 0 0; cursor: pointer; } #jslogo:hover { - background: url('../images/favicon_32x32.png') no-repeat 0 1px; + background: url('../images/favicon_32x32.png') no-repeat 0 1px; } .pagetabcenter { @@ -196,14 +196,14 @@ span.apiversion right: 7px; width: 54px; height: 36px; - background: url('../images/subsonic_36.png') no-repeat 5px 3px; + background: url('../images/subsonic_36.png') no-repeat 5px 3px; cursor: pointer; } #sslogo:hover { - background: url('../images/subsonic_dn_36.png') no-repeat 5px 2px; + background: url('../images/subsonic_dn_36.png') no-repeat 5px 2px; } -#content +#content { background: #EDEDED; width: 100%; @@ -219,7 +219,7 @@ span.apiversion { margin: 0; } -.smcolumn +.smcolumn { height: 100%; overflow-y: auto; @@ -242,7 +242,7 @@ span.apiversion position: fixed; left: 264px; } -.lgcolumn +.lgcolumn { height: 100%; overflow-y: auto; @@ -271,7 +271,7 @@ span.apiversion background: #fff; border-top: 1px solid #cbcbcb; border-bottom: 1px solid #cbcbcb; - width: 100%; + width: 100%; overflow: hidden; position: fixed; top: 40px; @@ -280,7 +280,7 @@ span.apiversion */ margin-bottom: -42px; } -#SideBar +#SideBar { display: none; min-height: 100%; @@ -407,7 +407,7 @@ span.apiversion border-radius: 4px 4px 4px 4px; box-shadow: 1px 1px 2px rgba(0, 0, 0, 0.1); opacity: 0.95; - cursor: pointer; + cursor: pointer; } .pager { @@ -420,7 +420,7 @@ span.apiversion { margin: 0 5px 0 0; } -.pager a.next +.pager a.next { margin: 0 0 0 5px; } @@ -455,7 +455,7 @@ span.apiversion } #welcome { display: none; - background: rgba(0, 0, 0, 0.25); + background: rgba(0, 0, 0, 0.25); border: solid 1px #a5a5a5; border-radius: 2px; -webkit-border-radius: 2px; @@ -638,13 +638,13 @@ ul.tablist li a:hover { color: #bbb; } -#BreadCrumb +#BreadCrumb { float: left; margin: 16px 0 0 0; padding: 2px 0; color: #AEAEA7; - border-radius: .4em; + border-radius: .4em; } #BreadCrumb img { @@ -666,7 +666,7 @@ ul.tablist li a:hover { text-decoration: underline; } -#BreadCrumbs .crumb +#BreadCrumbs .crumb { float: left; } @@ -695,14 +695,14 @@ ul.songlist .album ul.songlist .album a { color: #7EA8D5; - text-decoration: none; + text-decoration: none; overflow: hidden; white-space: nowrap; font-size: 11px; } ul.songlist .album a:hover { - text-decoration: underline; + text-decoration: underline; } ul.songlist .album .description { @@ -772,11 +772,11 @@ ul.songlist .album a.remove } ul.songlist .album a.rate { - background: url('../images/star_lgo_12x12.png') 4px center no-repeat; + background: url('../images/star_lgo_12x12.png') 4px center no-repeat; } ul.songlist .album a.favorite { - background: url('../images/star_yo_12x12.png') 4px center no-repeat; + background: url('../images/star_yo_12x12.png') 4px center no-repeat; } ul.songlist .album a.info { @@ -822,7 +822,7 @@ ul.songlist li:hover .albumgrid .albumart img { top: -4px; left: -4px; } -ul.songlist .albumgrid .albuminfo +ul.songlist .albumgrid .albuminfo { float: left; margin: 0 10px; @@ -845,7 +845,7 @@ ul.songlist .albumgrid .itemactions a } ul.songlist .albumgrid .itemactions a:hover { box-shadow: 0px 1px 1px 0px rgba(0, 0, 0, 0.2); -} +} ul.songlist .row { cursor: pointer; @@ -855,11 +855,11 @@ ul.songlist .row ul.songlist .row a { color: #7EA8D5; - text-decoration: none; + text-decoration: none; } ul.songlist .row a:hover { - text-decoration: underline; + text-decoration: underline; } ul.songlist .row span.albumblock { @@ -912,7 +912,7 @@ ul.songlist .row .albumblock img height: 25px; overflow: hidden; } -ul.songlist .row .artist +ul.songlist .row .artist { width: 15%; padding: 0 10px 0 0; @@ -952,7 +952,7 @@ ul.songlist .row a.add:hover } ul.songlist .row a.rate { - background: url('../images/star_lgo_12x12.png') center 3px no-repeat; + background: url('../images/star_lgo_12x12.png') center 3px no-repeat; } ul.songlist .row a.rate:hover { @@ -960,7 +960,7 @@ ul.songlist .row a.rate:hover } ul.songlist .row a.favorite { - background: url('../images/star_yo_12x12.png') center 3px no-repeat; + background: url('../images/star_yo_12x12.png') center 3px no-repeat; } ul.songlist .row a.remove { @@ -1011,9 +1011,9 @@ ul.songlist li:hover position: absolute; /*top: 41px;*/ top: 0; - bottom: 0; + bottom: 0; left: 0; - margin: 0; + margin: 0; overflow-y: scroll; overflow-x: hidden; } @@ -1034,7 +1034,7 @@ ul.songlist li:hover display: block; } #AZIndex a:hover { text-decoration: underline; } -#submenu_AZIndex +#submenu_AZIndex { display: none; width: 200px; @@ -1043,8 +1043,8 @@ ul.songlist li:hover background: #f9f9f9; border: 1px solid #C3C3C3; z-index: 999; -} -#submenu_AZIndex ul +} +#submenu_AZIndex ul { list-style-type: none; margin: 0; @@ -1062,7 +1062,7 @@ ul.songlist li:hover text-decoration: none; font-size: 24px; padding: 0 5px 0 0; - cursor: pointer; + cursor: pointer; } #submenu_AZIndex li a:hover { @@ -1097,7 +1097,7 @@ ul.songlist li:hover padding: 0; float: right; } -#globalactions +#globalactions { position: absolute; top: 0px; @@ -1110,7 +1110,7 @@ ul.songlist li:hover margin: 5px; float: left; } -.submenu +.submenu { position: fixed; background: #fff; @@ -1140,7 +1140,7 @@ ul.songlist li:hover width: 220px; text-align: right; } -#submenu_CurrentPlaylist +#submenu_CurrentPlaylist { width: 400px; height: 200px; @@ -1264,7 +1264,7 @@ ul.songlist li:hover #audiocontainer { } -.audiojs +.audiojs { width: auto; height: 45px; @@ -1293,7 +1293,7 @@ ul.songlist li:hover overflow: hidden; cursor: pointer; } -.audiojs .progress +.audiojs .progress { position: absolute; top: 0px; @@ -1305,7 +1305,7 @@ ul.songlist li:hover background-image: -webkit-gradient(linear, left top, left bottom, color-stop(0, #CCC), color-stop(0.5, #DDD), color-stop(0.51, #CCC), color-stop(1, #CCC)); background-image: -moz-linear-gradient(center top, #CCC 0%, #DDD 50%, #CCC 51%, #CCC 100%); } -.audiojs .loaded +.audiojs .loaded { position: absolute; top: 0px; @@ -1357,15 +1357,15 @@ ul.songlist li:hover width: 322px; margin: 6px; float: left; - cursor: pointer; + cursor: pointer; padding: 0; visibility: hidden; text-align: left; color: #fff; } -#songdetails.hover +#songdetails.hover { - background: -webkit-gradient(linear, center top, center bottom, from(rgba(255, 255, 255, 0)), to(rgba(244, 244, 244, .2))); + background: -webkit-gradient(linear, center top, center bottom, from(rgba(255, 255, 255, 0)), to(rgba(244, 244, 244, .2))); } #songdetails .jp-volume-bar { overflow: hidden; @@ -1423,7 +1423,7 @@ ul.songlist li:hover width: 12px; margin: 2px; display: block; - background: url('../images/star_w_12x12.png') 0 center no-repeat; + background: url('../images/star_w_12x12.png') 0 center no-repeat; } #songdetails a.rate:hover { @@ -1436,7 +1436,7 @@ ul.songlist li:hover width: 12px; margin: 2px; display: block; - background: url('../images/star_yo_12x12.png') 0 center no-repeat; + background: url('../images/star_yo_12x12.png') 0 center no-repeat; } #songdetails a.mute { @@ -1445,7 +1445,7 @@ ul.songlist li:hover height: 12px; width: 16px; display: block; - background: url('../images/volume_mute_gl_12x9.png') 0 center no-repeat; + background: url('../images/volume_mute_gl_12x9.png') 0 center no-repeat; } #songdetails a.unmute { @@ -1472,16 +1472,7 @@ ul.songlist li:hover height: 12px; width: 9px; display: block; - background: url('../images/lock_stroke_gl_9x12.png') 0 center no-repeat; -} -#songdetails a.loop -{ - float: left; - margin: 4px 2px; - height: 9px; - width: 12px; - display: block; - background: url('../images/loop_alt3_w_12x9.png') 0 center no-repeat; + background: url('../images/lock_stroke_gl_9x12.png') 0 center no-repeat; } #songdetails a.jukebox { @@ -1490,7 +1481,7 @@ ul.songlist li:hover height: 9px; width: 12px; display: block; - background: url('../images/cloud_w_12x8.png') 0 center no-repeat; + background: url('../images/cloud_w_12x8.png') 0 center no-repeat; } #songdetails a.shuffle { @@ -1499,11 +1490,11 @@ ul.songlist li:hover height: 11px; width: 12px; display: block; - background: url('../images/fork_gd_11x12.png') 0 center no-repeat; + background: url('../images/fork_gd_11x12.png') 0 center no-repeat; } #songdetails a.first { - margin: 2px 2px 2px 0; + margin: 2px 2px 2px 0; } .vertshade { position: relative; @@ -1537,7 +1528,7 @@ ul.songlist li:hover filter: alpha(opacity=80); } /* Settings */ -ul.preferences +ul.preferences { list-style-type: none; padding: 0; @@ -1555,7 +1546,7 @@ ul.preferences li.title font-variant: small-caps; padding: 0; } -ul.preferences li em +ul.preferences li em { background: none repeat scroll 0 0 #D3D3D3; border-radius: 4px 4px 4px 4px; @@ -1636,7 +1627,7 @@ a.buttonimg { display: inline-block; } a.buttonimg:hover { - opacity: .6; + opacity: .6; } a.buttonvertical { margin: 2px 0; @@ -1681,7 +1672,7 @@ a.hoverSelected { opacity: .6; } height: 8px; } ::-webkit-scrollbar-track { - -webkit-box-shadow: inset 0 0 2px rgba(0,0,0,0.2); + -webkit-box-shadow: inset 0 0 2px rgba(0,0,0,0.2); /*-webkit-border-radius: 5px;*/ background: #f2f2f2; } @@ -1833,5 +1824,3 @@ legend font-variant: small-caps; font-weight: bold; } - - diff --git a/app/subsonic/subsonic-service.js b/app/subsonic/subsonic-service.js index d83d253..54d33d7 100644 --- a/app/subsonic/subsonic-service.js +++ b/app/subsonic/subsonic-service.js @@ -5,10 +5,10 @@ * Also offers more fine-grained functionality that is not part of Subsonic's API. */ angular.module('jamstash.subsonic.service', ['angular-underscore/utils', - 'jamstash.settings.service', 'jamstash.utils', 'jamstash.model', 'jamstash.notifications', 'jamstash.player.service']) + 'jamstash.settings.service', 'jamstash.utils', 'jamstash.model']) -.factory('subsonic', ['$rootScope', '$http', '$q', 'globals', 'utils', 'map', 'notifications', 'player', - function ($rootScope, $http, $q, globals, utils, map, notifications, player) { +.factory('subsonic', ['$rootScope', '$http', '$q', 'globals', 'utils', 'map', + function ($rootScope, $http, $q, globals, utils, map) { 'use strict'; //TODO: Hyz: Remove when refactored @@ -31,9 +31,10 @@ angular.module('jamstash.subsonic.service', ['angular-underscore/utils', var offset = 0; var showPlaylist = false; - return { + var subsonicService = { showIndex: $rootScope.showIndex, showPlaylist: showPlaylist, + //TODO: Hyz: Do we still need this ? it's only used in the songpreview directive getSongTemplate: function (callback) { var id = '16608'; var url = globals.BaseURL() + '/getMusicDirectory.view?' + globals.BaseParams() + '&id=' + id; @@ -106,6 +107,7 @@ angular.module('jamstash.subsonic.service', ['angular-underscore/utils', } else { if(subsonicResponse.status === 'failed' && subsonicResponse.error !== undefined) { exception.subsonicError = subsonicResponse.error; + exception.version = subsonicResponse.version; } deferred.reject(exception); } @@ -117,28 +119,36 @@ angular.module('jamstash.subsonic.service', ['angular-underscore/utils', }, ping: function () { - return this.subsonicRequest('ping.view'); + return subsonicService.subsonicRequest('ping.view'); + }, + + getMusicFolders: function () { + var exception = {reason: 'No music folder found on the Subsonic server.'}; + var promise = subsonicService.subsonicRequest('getMusicFolders.view') + .then(function (subsonicResponse) { + if (subsonicResponse.musicFolders !== undefined && subsonicResponse.musicFolders.musicFolder !== undefined) { + return [].concat(subsonicResponse.musicFolders.musicFolder); + } else { + return $q.reject(exception); + } + }); + return promise; }, getArtists: function (folder) { var exception = {reason: 'No artist found on the Subsonic server.'}; - // TODO: Hyz: Move loading / saving the music folder to persistence-service - if (isNaN(folder) && utils.getValue('MusicFolders')) { - var musicFolder = angular.fromJson(utils.getValue('MusicFolders')); - folder = musicFolder.id; - } var params; if (!isNaN(folder)) { params = { musicFolderId: folder }; } - var promise = this.subsonicRequest('getIndexes.view', { + var promise = subsonicService.subsonicRequest('getIndexes.view', { params: params }).then(function (subsonicResponse) { if(subsonicResponse.indexes !== undefined && (subsonicResponse.indexes.index !== undefined || subsonicResponse.indexes.shortcut !== undefined)) { // Make sure shortcut, index and each index's artist are arrays - // because Madsonic will return objects and not arrays if there is only 1 artist + // because Madsonic will return an object when there's only one element var formattedResponse = {}; formattedResponse.shortcut = [].concat(subsonicResponse.indexes.shortcut); formattedResponse.index = [].concat(subsonicResponse.indexes.index); @@ -160,10 +170,11 @@ angular.module('jamstash.subsonic.service', ['angular-underscore/utils', var params = { id: id }; - var promise = this.subsonicRequest('getMusicDirectory.view', { + var promise = subsonicService.subsonicRequest('getMusicDirectory.view', { params: params }).then(function (subsonicResponse) { if(subsonicResponse.directory.child !== undefined) { + // Make sure this is an array using concat because Madsonic will return an object when there's only one element var childArray = [].concat(subsonicResponse.directory.child); if (childArray.length > 0) { content.song = []; @@ -199,10 +210,11 @@ angular.module('jamstash.subsonic.service', ['angular-underscore/utils', type: id, offset: offset }; - var promise = this.subsonicRequest('getAlbumList.view', { + var promise = subsonicService.subsonicRequest('getAlbumList.view', { params: params }).then(function (subsonicResponse) { if(subsonicResponse.albumList.album !== undefined) { + // Make sure this is an array using concat because Madsonic will return an object when there's only one element var albumArray = [].concat(subsonicResponse.albumList.album); if (albumArray.length > 0) { content.song = []; @@ -253,52 +265,25 @@ angular.module('jamstash.subsonic.service', ['angular-underscore/utils', }); return deferred.promise; }, - getSongs: function (id, action) { - var exception = {reason: 'No songs found on the Subsonic server.'}; - var promise = this.subsonicRequest('getMusicDirectory.view', { + + getSongs: function (id) { + var exception = {reason: 'This directory is empty.'}; + var promise = subsonicService.subsonicRequest('getMusicDirectory.view', { params: { id: id } }).then(function (subsonicResponse) { if(subsonicResponse.directory.child !== undefined) { - var items = [].concat(subsonicResponse.directory.child); - if (items.length > 0) { - content.selectedAlbum = id; - if (action == 'add') { - angular.forEach(items, function (item, key) { - player.queue.push(map.mapSong(item)); - }); - notifications.updateMessage(items.length + ' Song(s) Added to Queue', true); - } else if (action == 'play') { - player.queue = []; - angular.forEach(items, function (item, key) { - player.queue.push(map.mapSong(item)); - }); - var next = player.queue[0]; - player.play(next); - notifications.updateMessage(items.length + ' Song(s) Added to Queue', true); - } else { - if (subsonicResponse.directory.id != 'undefined') { - var albumId = subsonicResponse.directory.id; - var albumName = subsonicResponse.directory.name; - if (content.breadcrumb.length > 0) { content.breadcrumb.splice(1, (content.breadcrumb.length - 1)); } - content.breadcrumb.push({ 'type': 'album', 'id': albumId, 'name': albumName }); - } - content.song = []; - content.album = []; - var albums = []; - angular.forEach(items, function (item, key) { - if (item.isDir) { - albums.push(map.mapAlbum(item)); - } else { - content.song.push(map.mapSong(item)); - } - }); - if (albums.length > 0) { - content.album = albums; - } - } - return content; + // Make sure this is an array using concat because Madsonic will return an object when there's only one element + var children = [].concat(subsonicResponse.directory.child); + if (children.length > 0) { + var allChildren = _(children).partition(function (item) { + return item.isDir; + }); + return { + directories: map.mapAlbums(allChildren[0]), + songs: map.mapSongs(allChildren[1]) + }; } } // We end up here for every else @@ -307,28 +292,73 @@ angular.module('jamstash.subsonic.service', ['angular-underscore/utils', return promise; }, + // This is used when we add or play a directory, so we recursively get all its contents + recursiveGetSongs: function (id) { + var deferred = $q.defer(); + // We first use getSongs() to get the contents of the root directory + subsonicService.getSongs(id).then(function (data) { + var directories = data.directories; + var songs = data.songs; + // If there are only songs, we return them immediately: this is a leaf directory and the end of the recursion + if (directories.length === 0) { + deferred.resolve(songs); + } else { + // otherwise, for each directory, we call ourselves + var promises = []; + angular.forEach(directories, function (dir) { + var subdirectoryRequest = subsonicService.recursiveGetSongs(dir.id).then(function (data) { + // This is where we join all the songs together in a single array + return songs.concat(data); + }); + promises.push(subdirectoryRequest); + }); + // since all of this is asynchronous, we need to wait for all the requests to finish by using $q.all() + var allRequestsFinished = $q.all(promises).then(function (data) { + // and since $q.all() wraps everything in another array, we use flatten() to end up with only one array of songs + return _(data).flatten(); + }); + deferred.resolve(allRequestsFinished); + } + }, function () { + // Even if getSongs returns an error, we resolve with an empty array. Otherwise one empty directory somewhere + // would keep us from playing all the songs of a directory recursively + deferred.resolve([]); + }); + return deferred.promise; + }, + search: function (query, type) { if(_([0, 1, 2]).contains(type)) { - var promise = this.subsonicRequest('search2.view', { + var promise = subsonicService.subsonicRequest('search2.view', { params: { query: query } }).then(function (subsonicResponse) { + var searchResult; if (!_.isEmpty(subsonicResponse.searchResult2)) { + searchResult = subsonicResponse.searchResult2; + } else if (!_.isEmpty(subsonicResponse.search2)) { + // We also check search2 because Music Cabinet doesn't respond the same thing + // as everyone else... + searchResult = subsonicResponse.search2; + } + if (!_.isEmpty(searchResult)) { + // Make sure that song, album and artist are arrays using concat + // because Madsonic will return an object when there's only one element switch (type) { case 0: - if (subsonicResponse.searchResult2.song !== undefined) { - return map.mapSongs([].concat(subsonicResponse.searchResult2.song)); + if (searchResult.song !== undefined) { + return map.mapSongs([].concat(searchResult.song)); } break; case 1: - if (subsonicResponse.searchResult2.album !== undefined) { - return map.mapAlbums([].concat(subsonicResponse.searchResult2.album)); + if (searchResult.album !== undefined) { + return map.mapAlbums([].concat(searchResult.album)); } break; case 2: - if (subsonicResponse.searchResult2.artist !== undefined) { - return [].concat(subsonicResponse.searchResult2.artist); + if (searchResult.artist !== undefined) { + return [].concat(searchResult.artist); } break; } @@ -353,10 +383,11 @@ angular.module('jamstash.subsonic.service', ['angular-underscore/utils', if (!isNaN(folder)) { params.musicFolderId = folder; } - var promise = this.subsonicRequest('getRandomSongs.view', { + var promise = subsonicService.subsonicRequest('getRandomSongs.view', { params: params }).then(function (subsonicResponse) { if(subsonicResponse.randomSongs !== undefined) { + // Make sure this is an array using concat because Madsonic will return an object when there's only one element var songArray = [].concat(subsonicResponse.randomSongs.song); if (songArray.length > 0) { return map.mapSongs(songArray); @@ -369,7 +400,7 @@ angular.module('jamstash.subsonic.service', ['angular-underscore/utils', }, getStarred: function () { - var promise = this.subsonicRequest('getStarred.view', { cache: true }) + var promise = subsonicService.subsonicRequest('getStarred.view', { cache: true }) .then(function (subsonicResponse) { if(angular.equals(subsonicResponse.starred, {})) { return $q.reject({reason: 'Nothing is starred on the Subsonic server.'}); @@ -381,9 +412,10 @@ angular.module('jamstash.subsonic.service', ['angular-underscore/utils', }, getRandomStarredSongs: function () { - var promise = this.getStarred() + var promise = subsonicService.getStarred() .then(function (starred) { if(starred.song !== undefined) { + // Make sure this is an array using concat because Madsonic will return an object when there's only one element var songArray = [].concat(starred.song); if (songArray.length > 0) { // Return random subarray of songs @@ -399,9 +431,10 @@ angular.module('jamstash.subsonic.service', ['angular-underscore/utils', getPlaylists: function () { var exception = {reason: 'No playlist found on the Subsonic server.'}; - var promise = this.subsonicRequest('getPlaylists.view') + var promise = subsonicService.subsonicRequest('getPlaylists.view') .then(function (subsonicResponse) { if(subsonicResponse.playlists.playlist !== undefined) { + // Make sure this is an array using concat because Madsonic will return an object when there's only one element var playlistArray = [].concat(subsonicResponse.playlists.playlist); if (playlistArray.length > 0) { var allPlaylists = _(playlistArray).partition(function (item) { @@ -418,12 +451,13 @@ angular.module('jamstash.subsonic.service', ['angular-underscore/utils', getPlaylist: function (id) { var exception = {reason: 'This playlist is empty.'}; - var promise = this.subsonicRequest('getPlaylist.view', { + var promise = subsonicService.subsonicRequest('getPlaylist.view', { params: { id: id } }).then(function (subsonicResponse) { if (subsonicResponse.playlist.entry !== undefined) { + // Make sure this is an array using concat because Madsonic will return an object when there's only one element var entryArray = [].concat(subsonicResponse.playlist.entry); if (entryArray.length > 0) { return map.mapSongs(entryArray); @@ -436,7 +470,7 @@ angular.module('jamstash.subsonic.service', ['angular-underscore/utils', }, newPlaylist: function (name) { - var promise = this.subsonicRequest('createPlaylist.view', { + var promise = subsonicService.subsonicRequest('createPlaylist.view', { params: { name: name } @@ -445,7 +479,7 @@ angular.module('jamstash.subsonic.service', ['angular-underscore/utils', }, deletePlaylist: function (id) { - var promise = this.subsonicRequest('deletePlaylist.view', { + var promise = subsonicService.subsonicRequest('deletePlaylist.view', { params: { id: id } @@ -463,7 +497,7 @@ angular.module('jamstash.subsonic.service', ['angular-underscore/utils', for (var i = 0; i < songs.length; i++) { params.params.songId.push(songs[i].id); } - return this.subsonicRequest('createPlaylist.view', params); + return subsonicService.subsonicRequest('createPlaylist.view', params); }, //TODO: Hyz: move to controller @@ -507,13 +541,14 @@ angular.module('jamstash.subsonic.service', ['angular-underscore/utils', getPodcasts: function () { var exception = {reason: 'No podcast found on the Subsonic server.'}; - var promise = this.subsonicRequest('getPodcasts.view', { + var promise = subsonicService.subsonicRequest('getPodcasts.view', { params: { includeEpisodes: false } }) .then(function (subsonicResponse) { if (subsonicResponse.podcasts !== undefined && subsonicResponse.podcasts.channel !== undefined) { + // Make sure this is an array using concat because Madsonic will return an object when there's only one element var channelArray = [].concat(subsonicResponse.podcasts.channel); if (channelArray.length > 0) { return channelArray; @@ -527,7 +562,7 @@ angular.module('jamstash.subsonic.service', ['angular-underscore/utils', getPodcast: function (id) { var exception = {reason: 'This podcast was not found on the Subsonic server.'}; - var promise = this.subsonicRequest('getPodcasts.view', { + var promise = subsonicService.subsonicRequest('getPodcasts.view', { params: { id: id, includeEpisodes: true @@ -535,10 +570,12 @@ angular.module('jamstash.subsonic.service', ['angular-underscore/utils', }).then(function (subsonicResponse) { var episodes = []; if (subsonicResponse.podcasts.channel !== undefined) { + // Make sure this is an array using concat because Madsonic will return an object when there's only one element var channelArray = [].concat(subsonicResponse.podcasts.channel); if (channelArray.length > 0) { var channel = channelArray[0]; if (channel !== null && channel.id === id) { + // Make sure this is an array using concat because Madsonic will return an object when there's only one element var episodesArray = [].concat(channel.episode); episodes = _(episodesArray).filter(function (episode) { return episode.status === "completed"; @@ -558,7 +595,7 @@ angular.module('jamstash.subsonic.service', ['angular-underscore/utils', }, scrobble: function (song) { - var promise = this.subsonicRequest('scrobble.view', { + var promise = subsonicService.subsonicRequest('scrobble.view', { params: { id: song.id, submisssion: true @@ -568,7 +605,20 @@ angular.module('jamstash.subsonic.service', ['angular-underscore/utils', return true; }); return promise; + }, + + toggleStar: function (item) { + var partialUrl = (item.starred) ? 'unstar.view' : 'star.view'; + var promise = subsonicService.subsonicRequest(partialUrl, { + params: { + id: item.id + } + }).then(function () { + return !item.starred; + }); + return promise; } // End subsonic }; + return subsonicService; }]); diff --git a/app/subsonic/subsonic-service_test.js b/app/subsonic/subsonic-service_test.js index 728ca7d..fa2b412 100644 --- a/app/subsonic/subsonic-service_test.js +++ b/app/subsonic/subsonic-service_test.js @@ -1,8 +1,8 @@ describe("Subsonic service -", function() { 'use strict'; - var subsonic, mockBackend, mockGlobals; - var response; + var $q, mockBackend, subsonic, mockGlobals, + response, url; beforeEach(function() { // We redefine it because in some tests we need to alter the settings mockGlobals = { @@ -46,9 +46,12 @@ describe("Subsonic service -", function() { }); }); - inject(function (_subsonic_, $httpBackend) { + installPromiseMatchers(); + + inject(function (_subsonic_, $httpBackend, _$q_) { subsonic = _subsonic_; mockBackend = $httpBackend; + $q = _$q_; }); response = {"subsonic-response": {status: "ok", version: "1.10.2"}}; }); @@ -59,40 +62,65 @@ describe("Subsonic service -", function() { }); describe("subsonicRequest() -", function() { - var partialUrl, url; + var partialUrl; 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'; }); - 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() { + it("Given that the Subsonic server was not responding, when I make a request to Subsonic, then an error object with a message will be returned", function() { mockBackend.expectJSONP(url).respond(503, 'Service Unavailable'); var promise = subsonic.subsonicRequest(partialUrl); mockBackend.flush(); - expect(promise).toBeRejectedWith({reason: 'Error when contacting the Subsonic server.', httpError: 503}); + expect(promise).toBeRejectedWith({ + reason: 'Error when contacting the Subsonic server.', + httpError: 503 + }); }); - it("Given a missing parameter, when I make a request to Subsonic it returns an error object with a message", function() { + it("Given a missing parameter, when I make a request to Subsonic, then an error object with a message will be returned", 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."} + var errorResponse = {'subsonic-response' : { + status : 'failed', + version : '1.10.2', + error : {code : 10, message : 'Required parameter is missing.'} }}; mockBackend.expectJSONP(missingPasswordUrl).respond(JSON.stringify(errorResponse)); var promise = subsonic.subsonicRequest(partialUrl); mockBackend.flush(); - expect(promise).toBeRejectedWith({reason: 'Error when contacting the Subsonic server.', subsonicError: {code: 10, message:'Required parameter is missing.'}}); + expect(promise).toBeRejectedWith({ + reason: 'Error when contacting the Subsonic server.', + subsonicError: {code: 10, message:'Required parameter is missing.'}, + version: '1.10.2' + }); }); - it("Given a partialUrl that does not start with '/', it adds '/' before it and makes a correct request", function() { + it("Given that server and Jamstash had different api versions, when I make a request to Subsonic and the server responds an error, then it will return the server's api version in the error object", function() { + var errorResponse = {'subsonic-response': { + status: 'failed', + version: '1.8.0', + error: {code: 30, message: 'Incompatible Subsonic REST protocol version. Server must upgrade.'} + }}; + mockBackend.expectJSONP(url).respond(JSON.stringify(errorResponse)); + + var promise = subsonic.subsonicRequest(partialUrl); + mockBackend.flush(); + + expect(promise).toBeRejectedWith({ + reason: 'Error when contacting the Subsonic server.', + subsonicError: {code: 30, message: 'Incompatible Subsonic REST protocol version. Server must upgrade.'}, + version: '1.8.0' + }); + }); + + it("Given a partialUrl that did not start with '/', when I make a request to Subsonic, then a '/' will be added before the partial url", function() { partialUrl = 'getStarred.view'; mockBackend.expectJSONP(url).respond(JSON.stringify(response)); @@ -100,7 +128,7 @@ describe("Subsonic service -", function() { mockBackend.flush(); }); - it("Given $http config params, it does not overwrite them", function() { + it("Given $http config params, when I make a request to Subsonic, then the params won't be overwritten", 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'; @@ -115,7 +143,7 @@ describe("Subsonic service -", function() { mockBackend.flush(); }); - 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() { + it("Given that the global protocol setting was 'json', when I make a request to Subsonic, then it will use GET and won't 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'; @@ -129,7 +157,6 @@ describe("Subsonic service -", function() { }); describe("getAlbums() -", function() { - var url; beforeEach(function() { url = 'http://demo.subsonic.com/rest/getMusicDirectory.view?'+ 'c=Jamstash&callback=JSON_CALLBACK&f=jsonp'+'&id=21'+'&p=enc:cGFzc3dvcmQ%3D&u=Hyzual&v=1.10.2'; @@ -182,62 +209,127 @@ describe("Subsonic service -", function() { }); }); + //TODO: Hyz: Rename into getDirectory(), because we don't know if there will be songs or other directories in it describe("getSongs() -", function() { - var url; beforeEach(function() { - var url = 'http://demo.subsonic.com/rest/getMusicDirectory.view?'+ + url = 'http://demo.subsonic.com/rest/getMusicDirectory.view?'+ 'c=Jamstash&callback=JSON_CALLBACK&f=jsonp'+'&id=209'+'&p=enc:cGFzc3dvcmQ%3D&u=Hyzual&v=1.10.2'; }); - it("Given that there were 2 songs in the given directory id in my library, when I get the songs from that directory, then a promise will be resolved with an array of 2 songs", function() { + it("Given a directory containing 2 songs and 1 subdirectory and given its id, when I get the songs from that directory, then a promise will be resolved with an array of 2 songs and an array of 1 subdirectory", function() { response["subsonic-response"].directory = { child: [ { id: 778 }, - { id: 614 } + { id: 614 }, + { id: 205, isDir: true} ] }; mockBackend.expectJSONP(url).respond(JSON.stringify(response)); var promise = subsonic.getSongs(209); - //TODO: Hyz: Replace with toBeResolvedWith() when getSongs() is refactored - var success = function (data) { - expect(data.album).toEqual([]); - expect(data.song).toEqual([ - { id: 778 }, - { id: 614 } - ]); - }; - promise.then(success); - mockBackend.flush(); - expect(promise).toBeResolved(); + expect(promise).toBeResolvedWith({ + directories: [{ id: 205, isDir: true}], + songs: [{id: 778}, {id: 614}] + }); }); - it("Given that there was only 1 song in the given directory id in my Madsonic library, when I get the songs from that directory, then a promise will be resolved with an array of 1 song", function() { + it("Given a directory containing 1 song in my Madsonic library and given its id, when I get the songs from that directory, then a promise will be resolved with an array of 1 song and an empty array of subdirectories", function() { response["subsonic-response"].directory = { child: { id: 402 } }; mockBackend.expectJSONP(url).respond(JSON.stringify(response)); var promise = subsonic.getSongs(209); - //TODO: Hyz: Replace with toBeResolvedWith() when getSongs() is refactored - var success = function (data) { - expect(data.album).toEqual([]); - expect(data.song).toEqual([ - { id: 402 } - ]); - }; - promise.then(success); - mockBackend.flush(); - expect(promise).toBeResolved(); + expect(promise).toBeResolvedWith({ + directories: [], + songs: [{id: 402}] + }); + }); + + it("Given a directory that didn't contain anything and given its id, when I get the songs from that directory, then a promise will be rejected with an error message", function() { + response["subsonic-response"].directory = {}; + mockBackend.expectJSONP(url).respond(JSON.stringify(response)); + + var promise = subsonic.getSongs(209); + mockBackend.flush(); + + expect(promise).toBeRejectedWith({reason: 'This directory is empty.'}); + }); + }); + + describe("recursiveGetSongs() -", function() { + var deferred; + beforeEach(function() { + deferred = $q.defer(); + spyOn(subsonic, 'getSongs').and.returnValue(deferred.promise); + }); + it("Given a directory containing 2 songs and a subdirectory itself containing 2 songs and given its id, when I get the songs from that directory, then a promise will be resolved with an array of 4 songs", function() { + // Mock getSongs so we are only testing the recursivity + var firstDeferred = $q.defer(); + var secondDeferred = $q.defer(); + subsonic.getSongs.and.callFake(function (id) { + // First call to getSongs + if (id === 499) { + return firstDeferred.promise; + // Second call to getSongs + } else if (id === 553) { + return secondDeferred.promise; + } + }); + + var promise = subsonic.recursiveGetSongs(499); + // On the first call to getSongs, we expect 2 songs and a subdirectory + firstDeferred.resolve({ + directories: [{ id: 553, type: 'byfolder' }], + songs: [ + { id: 695 }, + { id: 227 } + ] + }); + // On the second call, we expect 2 songs + secondDeferred.resolve({ + directories: [], + songs: [ + { id: 422 }, + { id: 171 } + ] + }); + + expect(promise).toBeResolvedWith([ + { id: 695 }, + { id: 227 }, + { id: 422 }, + { id: 171 }, + ]); + }); + + it("Given a directory containing only 2 songs and given its id, when I get the songs from that directory, then a promise will be resolved with an array of 2 songs", function() { + var promise = subsonic.recursiveGetSongs(14); + deferred.resolve({ + directories: [], + songs: [ + { id: 33 }, { id: 595 } + ] + }); + + expect(promise).toBeResolvedWith([ + { id: 33 }, { id: 595 } + ]); + }); + + it("Given a directory that didn't contain anything and given its id, when I get the songs from that directory, then a promise will be resolved with an empty array", function() { + var promise = subsonic.recursiveGetSongs(710); + deferred.reject({reason: 'This directory is empty.'}); + + expect(promise).toBeResolvedWith([]); }); }); describe("getAlbumListBy() -", function() { - var url; beforeEach(function() { url = 'http://demo.subsonic.com/rest/getAlbumList.view?'+ 'c=Jamstash&callback=JSON_CALLBACK&f=jsonp'+'&offset=0'+'&p=enc:cGFzc3dvcmQ%3D'+'&size=3&type=newest'+'&u=Hyzual&v=1.10.2'; @@ -295,7 +387,7 @@ describe("Subsonic service -", function() { }); describe("getStarred() -", function() { - var url + var url; beforeEach(function() { 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'; @@ -331,7 +423,6 @@ describe("Subsonic service -", function() { }); describe("getRandomStarredSongs() -", function() { - var url; beforeEach(function() { 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'; @@ -393,7 +484,6 @@ describe("Subsonic service -", function() { }); describe("getRandomSongs() -", function() { - var url; beforeEach(function() { 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'; @@ -507,8 +597,83 @@ describe("Subsonic service -", function() { expect(promise).toBeResolvedWith(true); }); + + describe("toggleStar() -", function() { + it("Given an item (can be an artist, an album or a song) that wasn't starred, when I toggle its star, then a promise will be resolved with true", function() { + var song = { id: 7748, starred: false }; + var url = 'http://demo.subsonic.com/rest/star.view?' + + 'c=Jamstash&callback=JSON_CALLBACK&f=jsonp'+'&id=7748'+'&p=enc:cGFzc3dvcmQ%3D&u=Hyzual&v=1.10.2'; + mockBackend.expectJSONP(url).respond(JSON.stringify(response)); + + var promise = subsonic.toggleStar(song); + mockBackend.flush(); + + expect(promise).toBeResolvedWith(true); + }); + + it("Given an item (can be an artist, an album or a song) that was starred, when I toggle its star, then a promise will be resolved with false", function() { + var album = { id: 6631, starred: true }; + var url = 'http://demo.subsonic.com/rest/unstar.view?' + + 'c=Jamstash&callback=JSON_CALLBACK&f=jsonp'+'&id=6631'+'&p=enc:cGFzc3dvcmQ%3D&u=Hyzual&v=1.10.2'; + mockBackend.expectJSONP(url).respond(JSON.stringify(response)); + + var promise = subsonic.toggleStar(album); + mockBackend.flush(); + + expect(promise).toBeResolvedWith(false); + }); + }); + + describe("getMusicFolders() -", function() { + beforeEach(function() { + url = 'http://demo.subsonic.com/rest/getMusicFolders.view?'+ + 'c=Jamstash&callback=JSON_CALLBACK&f=jsonp&p=enc:cGFzc3dvcmQ%3D&u=Hyzual&v=1.10.2'; + }); + + it("Given that there were 2 music folders in my library, when I get the music folders, then a promise will be resolved with an array of 2 music folders", function() { + response["subsonic-response"].musicFolders = { + musicFolder: [ + { id: 80, name: "languageless" }, + { id: 38, name: "mala" } + ] + }; + mockBackend.expectJSONP(url).respond(JSON.stringify(response)); + + var promise = subsonic.getMusicFolders(); + mockBackend.flush(); + + expect(promise).toBeResolvedWith([ + { id: 80, name: "languageless" }, + { id: 38, name: "mala" } + ]); + }); + + it("Given that there was 1 music folder in my Madsonic library, when I get the music folders, then a promise will be resolved with an array of 1 music folder", function() { + response["subsonic-response"].musicFolders = { + musicFolder: {id: 56, name: "dite"} + }; + mockBackend.expectJSONP(url).respond(JSON.stringify(response)); + + var promise = subsonic.getMusicFolders(); + mockBackend.flush(); + + expect(promise).toBeResolvedWith([ + { id: 56, name: "dite"} + ]); + }); + + it("Given that there wasn't any music folder in my library, when I get the music folders, then a promise will be rejected with an error message", function() { + response["subsonic-response"].musicFolders = {}; + mockBackend.expectJSONP(url).respond(JSON.stringify(response)); + + var promise = subsonic.getMusicFolders(); + mockBackend.flush(); + + expect(promise).toBeRejectedWith({reason: 'No music folder found on the Subsonic server.'}); + }); + }); + describe("getArtists() -", function() { - var url; beforeEach(function() { url = 'http://demo.subsonic.com/rest/getIndexes.view?'+ 'c=Jamstash&callback=JSON_CALLBACK&f=jsonp&p=enc:cGFzc3dvcmQ%3D&u=Hyzual&v=1.10.2'; @@ -542,6 +707,15 @@ describe("Subsonic service -", function() { expect(promise).toBeResolvedWith(response["subsonic-response"].indexes); }); + it("Given a folder id, when I get the artists, then it will be used as parameter in the request", function() { + url = 'http://demo.subsonic.com/rest/getIndexes.view?'+ + 'c=Jamstash&callback=JSON_CALLBACK&f=jsonp'+'&musicFolderId=54'+'&p=enc:cGFzc3dvcmQ%3D&u=Hyzual&v=1.10.2'; + mockBackend.expectJSONP(url).respond(JSON.stringify(response)); + + subsonic.getArtists(54); + mockBackend.flush(); + }); + it("Given that there were 2 artist at the top level of my Madsonic library, when I get the artists, then a promise will be resolved with an array of two artist", function() { response["subsonic-response"].indexes = { shortcut: { id: 433, name: "Podcast" }, @@ -689,7 +863,6 @@ describe("Subsonic service -", function() { }); describe("getPlaylist() -", function() { - var url; beforeEach(function() { url = 'http://demo.subsonic.com/rest/getPlaylist.view?'+ 'c=Jamstash&callback=JSON_CALLBACK&f=jsonp'+'&id=9123'+'&p=enc:cGFzc3dvcmQ%3D&u=Hyzual&v=1.10.2'; @@ -781,7 +954,6 @@ describe("Subsonic service -", function() { }); describe("getPodcasts() -", function() { - var url; beforeEach(function() { url = 'http://demo.subsonic.com/rest/getPodcasts.view?'+ 'c=Jamstash&callback=JSON_CALLBACK&f=jsonp'+'&includeEpisodes=false'+'&p=enc:cGFzc3dvcmQ%3D&u=Hyzual&v=1.10.2'; @@ -833,7 +1005,6 @@ describe("Subsonic service -", function() { }); describe("getPodcast() -", function() { - var url; beforeEach(function() { url = 'http://demo.subsonic.com/rest/getPodcasts.view?'+ 'c=Jamstash&callback=JSON_CALLBACK&f=jsonp'+'&id=2695&includeEpisodes=true'+'&p=enc:cGFzc3dvcmQ%3D&u=Hyzual&v=1.10.2'; @@ -933,7 +1104,6 @@ describe("Subsonic service -", function() { }); describe("search() -", function() { - var url; beforeEach(function() { url = 'http://demo.subsonic.com/rest/search2.view?'+ 'c=Jamstash&callback=JSON_CALLBACK&f=jsonp&p=enc:cGFzc3dvcmQ%3D'+'&query=unintersetingly'+'&u=Hyzual&v=1.10.2'; @@ -967,6 +1137,34 @@ describe("Subsonic service -", function() { ]); }); + it("Given that songs containing 'unintersetingly' existed in my Music Cabinet library, when I search for a song that contains 'unintersetingly', then a promise will be resolved with an array of songs", function() { + response["subsonic-response"].search2 = { + song: [ + { + id: 7907, + name: "unintersetingly Caragana" + }, { + id: 4089, + name: "attacolite unintersetingly" + } + ] + }; + mockBackend.expectJSONP(url).respond(JSON.stringify(response)); + + var promise = subsonic.search("unintersetingly", 0); + mockBackend.flush(); + + expect(promise).toBeResolvedWith([ + { + id: 7907, + name: "unintersetingly Caragana" + }, { + id: 4089, + name: "attacolite unintersetingly" + } + ]); + }); + it("Given that only one song containing 'unintersetingly' existed in my Madsonic library, when I search for a song that contains 'unintersetingly', then a promise will be resolved with an array of one song", function() { response["subsonic-response"].searchResult2 = { song: { id: 142, name: "unintersetingly rescue" } diff --git a/app/subsonic/subsonic.html b/app/subsonic/subsonic.html index e9a65d5..e8a9597 100644 --- a/app/subsonic/subsonic.html +++ b/app/subsonic/subsonic.html @@ -41,7 +41,7 @@
  • @@ -49,12 +49,12 @@
    - +
    A-Z
    diff --git a/app/subsonic/subsonic.js b/app/subsonic/subsonic.js index 55bba95..d13c190 100644 --- a/app/subsonic/subsonic.js +++ b/app/subsonic/subsonic.js @@ -4,10 +4,10 @@ * 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.controller', ['jamstash.subsonic.service', 'jamstash.player.service']) +angular.module('jamstash.subsonic.controller', ['jamstash.subsonic.service', 'jamstash.player.service', 'jamstash.persistence']) -.controller('SubsonicController', ['$scope', '$rootScope', '$routeParams', '$window', 'utils', 'globals', 'map', 'subsonic', 'notifications', 'player', - function ($scope, $rootScope, $routeParams, $window, utils, globals, map, subsonic, notifications, player) { +.controller('SubsonicController', ['$scope', '$rootScope', '$routeParams', '$window', 'utils', 'globals', 'map', 'subsonic', 'notifications', 'player', 'persistence', + function ($scope, $rootScope, $routeParams, $window, utils, globals, map, subsonic, notifications, player, persistence) { 'use strict'; $scope.settings = globals.settings; @@ -141,13 +141,20 @@ angular.module('jamstash.subsonic.controller', ['jamstash.subsonic.service', 'ja } } }); - $rootScope.$watch("SelectedMusicFolder", function (newValue, oldValue) { + + $scope.$watch("SelectedMusicFolder", function (newValue, oldValue) { if (newValue !== oldValue) { - // TODO: Hyz: Move loading / saving the music folder to persistence-service - utils.setValue('MusicFolders', angular.toJson(newValue), true); - $scope.getArtists(newValue.id); + var folderId; + if (newValue) { + folderId = newValue.id; + persistence.saveSelectedMusicFolder(newValue); + } else { + persistence.deleteSelectedMusicFolder(); + } + $scope.getArtists(folderId); } }); + $scope.searching = { query: "", typeId: globals.settings.DefaultSearchType, @@ -201,7 +208,12 @@ angular.module('jamstash.subsonic.controller', ['jamstash.subsonic.service', 'ja $scope.song = data.song; }); }; + $scope.getArtists = function (folder) { + var savedFolder = $scope.SelectedMusicFolder; + if (isNaN(folder) && savedFolder) { + folder = savedFolder.id; + } var promise = subsonic.getArtists(folder); $scope.handleErrors(promise).then(function (data) { $scope.index = data.index; @@ -214,8 +226,9 @@ angular.module('jamstash.subsonic.controller', ['jamstash.subsonic.service', 'ja } }); }; + $scope.refreshArtists = function () { - utils.setValue('MusicFolders', null, true); + $scope.SelectedMusicFolder = undefined; $scope.getArtists(); $scope.getPlaylists(); }; @@ -279,10 +292,11 @@ angular.module('jamstash.subsonic.controller', ['jamstash.subsonic.service', 'ja * @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 ! */ + //TODO: Hyz: Maybe we should move this to a service $scope.requestSongs = function (promise, action) { $scope.handleErrors(promise) .then(function (songs) { - if(action === 'play') { + if (action === 'play') { player.emptyQueue().addSongs(songs).playFirstSong(); notifications.updateMessage(songs.length + ' Song(s) Added to Queue', true); } else if (action === 'add') { @@ -300,18 +314,33 @@ angular.module('jamstash.subsonic.controller', ['jamstash.subsonic.service', 'ja return promise; }; - $scope.getSongs = function (id, action) { - subsonic.getSongs(id, action).then(function (data) { - $scope.album = data.album; - $scope.song = data.song; - $scope.BreadCrumbs = data.breadcrumb; - $scope.selectedAutoAlbum = data.selectedAutoAlbum; - $scope.selectedArtist = data.selectedArtist; - $scope.selectedPlaylist = data.selectedPlaylist; - if ($scope.SelectedAlbumSort.id != "default") { - sortSubsonicAlbums($scope.SelectedAlbumSort.id); - } - }); + $scope.getSongs = function (action, id, name) { + var promise; + if (action === 'play' || action === 'add') { + promise = subsonic.recursiveGetSongs(id); + $scope.requestSongs(promise, action); + } else if (action === 'display') { + promise = subsonic.getSongs(id); + $scope.handleErrors(promise).then(function (data) { + $scope.album = data.directories; + $scope.song = data.songs; + if ($scope.BreadCrumbs.length > 0) { + $scope.BreadCrumbs.splice(1, ($scope.BreadCrumbs.length - 1)); + } + $scope.BreadCrumbs.push({'type': 'album', 'id': id, 'name': name}); + $scope.selectedAutoAlbum = null; + $scope.selectedArtist = null; + $scope.selectedAlbum = id; + $scope.selectedAutoPlaylist = null; + $scope.selectedPlaylist = null; + $scope.selectedPodcast = null; + if ($scope.SelectedAlbumSort.id !== "default") { + sortSubsonicAlbums($scope.SelectedAlbumSort.id); + } + }, function (error) { + notifications.updateMessage(error.reason, true); + }); + } }; $scope.getRandomStarredSongs = function (action) { @@ -578,43 +607,19 @@ angular.module('jamstash.subsonic.controller', ['jamstash.subsonic.service', 'ja }; $scope.getMusicFolders = function () { - $.ajax({ - url: globals.BaseURL() + '/getMusicFolders.view?' + globals.BaseParams(), - method: 'GET', - dataType: globals.settings.Protocol, - timeout: globals.settings.Timeout, - success: function (data) { - if (data["subsonic-response"].musicFolders.musicFolder !== undefined) { - var folders = []; - if (data["subsonic-response"].musicFolders.musicFolder.length > 0) { - folders = data["subsonic-response"].musicFolders.musicFolder; - } else { - folders[0] = data["subsonic-response"].musicFolders.musicFolder; - } - - folders.unshift({ - "id": -1, - "name": "All Folders" - }); - $rootScope.MusicFolders = folders; - if (utils.getValue('MusicFolders')) { - var folder = angular.fromJson(utils.getValue('MusicFolders')); - var i = 0, index = ""; - angular.forEach($rootScope.MusicFolders, function (item, key) { - if (item.id == folder.id) { - index = i; - } - i++; - }); - $rootScope.SelectedMusicFolder = $rootScope.MusicFolders[index]; - } else { - $rootScope.SelectedMusicFolder = $rootScope.MusicFolders[0]; - } - $scope.$apply(); + var promise = subsonic.getMusicFolders(); + $scope.handleErrors(promise).then(function (musicFolders) { + var folders = musicFolders; + $scope.MusicFolders = folders; + var savedFolder = persistence.getSelectedMusicFolder(); + if (savedFolder) { + if (_.findIndex(folders, {id: savedFolder.id}) !== -1) { + $scope.SelectedMusicFolder = savedFolder; } } }); }; + /** * Change the order of playlists through jQuery UI's sortable */ @@ -627,19 +632,19 @@ angular.module('jamstash.subsonic.controller', ['jamstash.subsonic.service', 'ja $scope.song.splice(end, 0, $scope.song.splice(start, 1)[0]); }; $scope.playSong = function (song) { - player.play(song); + player.emptyQueue().addSong(song).playFirstSong(); }; $scope.addSongToQueue = function (song) { player.addSong(song); }; /* Launch on Startup */ + $scope.getMusicFolders(); $scope.getArtists(); $scope.getPlaylists(); $scope.getGenres(); $scope.getPodcasts(); $scope.openDefaultSection(); - $scope.getMusicFolders(); if ($routeParams.artistId && $routeParams.albumId) { $scope.getAlbumByTag($routeParams.albumId); } else if ($routeParams.artistId) { diff --git a/app/subsonic/subsonic_test.js b/app/subsonic/subsonic_test.js index b00694f..856f819 100644 --- a/app/subsonic/subsonic_test.js +++ b/app/subsonic/subsonic_test.js @@ -2,59 +2,74 @@ describe("Subsonic controller", function() { 'use strict'; var scope, $rootScope, $controller, $window, - subsonic, notifications, player, controllerParams, deferred; + subsonic, notifications, player, utils, persistence, controllerParams, deferred; beforeEach(function() { jasmine.addCustomEqualityTester(angular.equals); - module('jamstash.subsonic.controller', function ($provide) { - // Mock the player service - $provide.decorator('player', function($delegate) { + module('jamstash.subsonic.controller'); - $delegate.queue = []; - $delegate.play = jasmine.createSpy("play"); - $delegate.playFirstSong = jasmine.createSpy("playFirstSong"); - return $delegate; - }); - }); - - inject(function (_$controller_, _$rootScope_, utils, globals, map, $q, _player_) { + inject(function (_$controller_, _$rootScope_, globals, map, $q) { $rootScope = _$rootScope_; scope = $rootScope.$new(); deferred = $q.defer(); - player = _player_; $window = jasmine.createSpyObj("$window", [ "prompt", "confirm" ]); notifications = jasmine.createSpyObj("notifications", ["updateMessage"]); + utils = jasmine.createSpyObj("utils", ["getValue"]); + persistence = jasmine.createSpyObj("persistence", [ + "getSelectedMusicFolder", + "saveSelectedMusicFolder", + "deleteSelectedMusicFolder" + ]); // Mock the subsonic service subsonic = jasmine.createSpyObj("subsonic", [ + "deletePlaylist", "getAlbums", "getArtists", "getGenres", - "getPlaylists", - "getPodcasts", - "getRandomStarredSongs", - "getRandomSongs", + "getMusicFolders", "getPlaylist", - "newPlaylist", - "deletePlaylist", - "savePlaylist", + "getPlaylists", "getPodcast", - "search" + "getPodcasts", + "getRandomSongs", + "getRandomStarredSongs", + "getSongs", + "newPlaylist", + "recursiveGetSongs", + "savePlaylist", + "search", ]); // We make them return different promises and use our deferred variable only when testing // a particular function, so that they stay isolated subsonic.getAlbums.and.returnValue($q.defer().promise); subsonic.getArtists.and.returnValue($q.defer().promise); subsonic.getGenres.and.returnValue($q.defer().promise); + subsonic.getMusicFolders.and.returnValue($q.defer().promise); subsonic.getPlaylists.and.returnValue($q.defer().promise); subsonic.getPodcasts.and.returnValue($q.defer().promise); + subsonic.getSongs.and.returnValue($q.defer().promise); + subsonic.recursiveGetSongs.and.returnValue($q.defer().promise); subsonic.showIndex = false; + // Mock the player service + player = jasmine.createSpyObj("player", [ + "emptyQueue", + "addSong", + "addSongs", + "play", + "playFirstSong" + ]); + player.emptyQueue.and.returnValue(player); + player.addSong.and.returnValue(player); + player.addSongs.and.returnValue(player); + player.queue = []; + $controller = _$controller_; controllerParams = { $scope: scope, @@ -65,7 +80,9 @@ describe("Subsonic controller", function() { globals: globals, map: map, subsonic: subsonic, - notifications: notifications + notifications: notifications, + player: player, + persistence: persistence }; }); }); @@ -74,6 +91,139 @@ describe("Subsonic controller", function() { beforeEach(function() { $controller('SubsonicController', controllerParams); scope.selectedPlaylist = null; + scope.$apply(); + }); + + describe("getSongs -", function() { + beforeEach(function() { + scope.BreadCrumbs = []; + scope.SelectedAlbumSort= { + id: "default" + }; + subsonic.getSongs.and.returnValue(deferred.promise); + subsonic.recursiveGetSongs.and.returnValue(deferred.promise); + spyOn(scope, "requestSongs").and.returnValue(deferred.promise); + }); + + it("Given a music directory that contained 3 songs and given its id and name, when I display it, then subsonic-service will be called, the breadcrumbs will be updated and the songs will be published to the scope", function() { + scope.getSongs('display', 87, 'Covetous Dadayag'); + deferred.resolve({ + directories: [], + songs: [ + { id: 660 }, + { id: 859 }, + { id: 545 } + ] + }); + scope.$apply(); + + expect(subsonic.getSongs).toHaveBeenCalledWith(87); + expect(scope.album).toEqual([]); + expect(scope.song).toEqual([ + { id: 660 }, + { id: 859 }, + { id: 545 } + ]); + expect(scope.BreadCrumbs).toEqual([{ + type: 'album', + id: 87, + name: 'Covetous Dadayag' + }]); + expect(scope.selectedAutoAlbum).toBeNull(); + expect(scope.selectedArtist).toBeNull(); + expect(scope.selectedAlbum).toBe(87); + expect(scope.selectedAutoPlaylist).toBeNull(); + expect(scope.selectedPlaylist).toBeNull(); + expect(scope.selectedPodcast).toBeNull(); + }); + + it("Given that there was a previous level in the breadcrumbs, when I display a music directory, then the album breadcrumb will be added", function() { + scope.BreadCrumbs = [ + { + type: 'artist', + id: 73, + name: 'Evan Mestanza' + } + ]; + scope.getSongs('display', 883, 'Pitiedly preutilizable'); + deferred.resolve({ + directories: [], + songs: [] + }); + scope.$apply(); + + expect(scope.BreadCrumbs).toEqual([ + { + type: 'artist', + id: 73, + name: 'Evan Mestanza' + }, { + type: 'album', + id: 883, + name: 'Pitiedly preutilizable' + } + ]); + }); + + it("Given a music directory that contained 2 songs and 1 subdirectory and given its id and name, when I display it, then subsonic-service will be called, the songs and directory will be published to the scope", function() { + scope.getSongs('display', 6, 'Potsander dormilona'); + deferred.resolve({ + directories: [{id: 387, type: 'byfolder'}], + songs: [ + { id: 102 }, + { id: 340 } + ] + }); + scope.$apply(); + + expect(scope.album).toEqual([ + {id: 387, type: 'byfolder'} + ]); + expect(scope.song).toEqual([ + { id: 102 }, + { id: 340 } + ]); + }); + + it("Given a music directory, when I display it, then handleErrors will handle HTTP and Subsonic errors", function() { + spyOn(scope, 'handleErrors').and.returnValue(deferred.promise); + scope.getSongs('display', 88); + expect(scope.handleErrors).toHaveBeenCalledWith(deferred.promise); + }); + + it("Given a music directory that didn't contain anything, when I display it, then an error notification will be displayed", function() { + scope.getSongs('display', 214, 'Upside bunemost'); + deferred.reject({reason: 'This directory is empty.'}); + scope.$apply(); + + expect(notifications.updateMessage).toHaveBeenCalledWith('This directory is empty.', true); + }); + + it("Given a music directory that contained 3 songs and given its id, when I add it to the playing queue, then requestSongs() will be called", function() { + scope.getSongs('add', 720); + deferred.resolve([ + { id: 927 }, + { id: 598 }, + { id: 632 } + ]); + scope.$apply(); + + expect(subsonic.recursiveGetSongs).toHaveBeenCalledWith(720); + expect(scope.requestSongs).toHaveBeenCalledWith(deferred.promise, 'add'); + }); + + it("Given a music directory that contained 3 songs and given its id, when I play it, then requestSongs() will be called", function() { + scope.getSongs('play', 542); + deferred.resolve([ + { id: 905 }, + { id: 181 }, + { id: 880 } + ]); + scope.$apply(); + + expect(subsonic.recursiveGetSongs).toHaveBeenCalledWith(542); + expect(scope.requestSongs).toHaveBeenCalledWith(deferred.promise, 'play'); + }); }); describe("Given that my library contained 3 songs, ", function() { @@ -234,23 +384,22 @@ describe("Subsonic controller", function() { deferred.resolve(response); scope.$apply(); - expect(player.queue).toEqual([ + expect(player.addSongs).toHaveBeenCalledWith([ {id: "2548"}, {id: "8986"}, {id: "2986"} ]); expect(notifications.updateMessage).toHaveBeenCalledWith('3 Song(s) Added to Queue', 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"}]; - scope.requestSongs(deferred.promise, 'play'); deferred.resolve(response); scope.$apply(); - expect(player.playFirstSong).toHaveBeenCalled(); - expect(player.queue).toEqual([ + expect(player.emptyQueue).toHaveBeenCalled(); + expect(player.addSongs).toHaveBeenCalledWith([ {id: "2548"}, {id: "8986"}, {id: "2986"} ]); + expect(player.playFirstSong).toHaveBeenCalled(); expect(notifications.updateMessage).toHaveBeenCalledWith('3 Song(s) Added to Queue', true); }); @@ -362,22 +511,48 @@ describe("Subsonic controller", function() { }); }); - it("When I call playSong, it calls play in the player service", function() { + it("Given a song, when I call playSong, then the player service's queue will be emptied, the song will be added to the queue and played", function() { var fakeSong = {"id": 3572}; scope.playSong(fakeSong); - expect(player.play).toHaveBeenCalledWith(fakeSong); + expect(player.emptyQueue).toHaveBeenCalled(); + expect(player.addSong).toHaveBeenCalledWith({"id": 3572}); + expect(player.playFirstSong).toHaveBeenCalled(); }); //TODO: Hyz: all starred - describe("When I load the artists,", function() { + describe("", function() { + beforeEach(function() { + spyOn(scope, "getArtists"); + }); + + it("Given no previously selected music folder, when I select a music folder, then it will be stored in persistence and the artists will be loaded from subsonic", function() { + scope.SelectedMusicFolder = { id: 22, name: "Cascadia" }; + scope.$apply(); + + expect(persistence.saveSelectedMusicFolder).toHaveBeenCalledWith({ id: 22, name: "Cascadia" }); + expect(scope.getArtists).toHaveBeenCalledWith(22); + }); + + it("Given a previously selected music folder, when I select the 'All Folders' (undefined) music folder, then the stored value will be deleted from persistence and all the artists will be loaded from subsonic", function() { + scope.SelectedMusicFolder = { id: 23, name: "grantable" }; + scope.$apply(); + scope.SelectedMusicFolder = undefined; + scope.$apply(); + + expect(persistence.deleteSelectedMusicFolder).toHaveBeenCalled(); + expect(scope.getArtists).not.toHaveBeenCalledWith(jasmine.any(Number)); + }); + }); + + describe("getArtists() -", function() { beforeEach(function() { subsonic.getArtists.and.returnValue(deferred.promise); }); - it("Given that there are songs in the library, it loads the artists and publishes them to the scope", function() { + it("Given that there were artists in the library, when I load the artists, then subsonic will be queried an index array containing the artists and a shortcut array containing the shortcuts (such as Podcasts) will be publisehd to the scope", function() { scope.getArtists(); deferred.resolve({ index: [ @@ -398,7 +573,15 @@ describe("Subsonic controller", function() { ]); }); - it("Given that there aren't any songs in the library, when loading indexes, it notifies the user with an error message", function() { + it("Given no folder id and given a selected music folder had been set in the scope, when I get the artists, then the selected music folder's id will be used as parameter to subsonic service", function() { + scope.SelectedMusicFolder = { id: 62, name: "dollardee" }; + + scope.getArtists(); + + expect(subsonic.getArtists).toHaveBeenCalledWith(62); + }); + + it("Given that there weren't any artist in the library, when I load the artists, then a notification with an error message will be displayed", function() { scope.getArtists(); deferred.reject({reason: 'No artist found on the Subsonic server.'}); scope.$apply(); @@ -408,13 +591,24 @@ describe("Subsonic controller", function() { expect(notifications.updateMessage).toHaveBeenCalledWith('No artist found on the Subsonic server.', true); }); - it("it lets handleErrors handle HTTP and Subsonic errors", function() { + it("Given that the server was unreachable, when I get the music folders, then handleErrors() will deal with the error", function() { spyOn(scope, 'handleErrors').and.returnValue(deferred.promise); scope.getArtists(); expect(scope.handleErrors).toHaveBeenCalledWith(deferred.promise); }); }); + it("refreshArtists() - When I refresh the artists, then the selected music folder will be reset to undefined and the artists and playlists will be loaded", function() { + spyOn(scope, "getArtists"); + spyOn(scope, "getPlaylists"); + + scope.refreshArtists(); + + expect(scope.SelectedMusicFolder).toBeUndefined(); + expect(scope.getArtists).toHaveBeenCalled(); + expect(scope.getPlaylists).toHaveBeenCalled(); + }); + describe("When I load the playlists,", function() { beforeEach(function() { subsonic.getPlaylists.and.returnValue(deferred.promise); @@ -490,7 +684,7 @@ describe("Subsonic controller", function() { expect(scope.getPlaylists).toHaveBeenCalled(); }); - it("Given no selected playlist, when I try to delete a playlist, an error message will be notified", function() { + it("Given no selected playlist, when I try to delete a playlist, an error notification will be displayed", function() { scope.selectedPlaylist = null; scope.deletePlaylist(); @@ -522,7 +716,7 @@ describe("Subsonic controller", function() { expect(notifications.updateMessage).toHaveBeenCalledWith('Playlist Updated!', true); }); - it("Given no selected playlist, when I try to save a playlist, an error message will be notified", function() { + it("Given no selected playlist, when I try to save a playlist, an error notification will be displayed", function() { scope.selectedPlaylist = null; scope.savePlaylist(); @@ -564,6 +758,64 @@ describe("Subsonic controller", function() { }); }); + describe("getMusicFolders", function() { + beforeEach(function() { + subsonic.getMusicFolders.and.returnValue(deferred.promise); + }); + + it("Given that there were music folders in the library, when I get the music folders, then the folders will be published to the scope", function() { + scope.getMusicFolders(); + deferred.resolve([ + { id: 74, name: "scirrhosis"}, + { id: 81, name: "drooper"} + ]); + scope.$apply(); + + expect(subsonic.getMusicFolders).toHaveBeenCalled(); + expect(scope.MusicFolders).toEqual([ + { id: 74, name: "scirrhosis"}, + { id: 81, name: "drooper"} + ]); + }); + + describe("Given that there was a selected music folder previously saved in persistence", function() { + it("and that it matched one of the music folders returned by subsonic, when I get the music folders, then the scope's selected music folder will be set", function() { + persistence.getSelectedMusicFolder.and.returnValue({ id: 79, name: "dismember" }); + + scope.getMusicFolders(); + deferred.resolve([ + { id: 93, name: "illish" }, + { id: 79, name: "dismember" } + ]); + scope.$apply(); + + expect(scope.SelectedMusicFolder).toEqual({ id: 79, name: "dismember" }); + }); + + it("and that it didn't match one of the music folders returned by subsonic, when I get the music folders, then the scope's selected music folder will be undefined", function() { + persistence.getSelectedMusicFolder.and.returnValue({ id: 49, name: "metafulminuric" }); + + scope.getMusicFolders(); + deferred.resolve([ + { id: 94, name: "dorsiflexor" } + ]); + scope.$apply(); + + expect(scope.SelectedMusicFolder).toBeUndefined(); + }); + }); + + it("Given that there weren't any music folder in the library, when I get the music folders, then handleErrors() will deal with the error", function() { + spyOn(scope, 'handleErrors').and.returnValue(deferred.promise); + + scope.getMusicFolders(); + deferred.reject({reason: 'No music folder found on the Subsonic server.'}); + scope.$apply(); + + expect(scope.handleErrors).toHaveBeenCalledWith(deferred.promise); + }); + }); + describe("search() -", function() { beforeEach(function() { subsonic.search.and.returnValue(deferred.promise); diff --git a/app/vendor/jquery.base64.js b/app/vendor/jquery.base64.js deleted file mode 100644 index 763b08f..0000000 --- a/app/vendor/jquery.base64.js +++ /dev/null @@ -1,142 +0,0 @@ - - /** - * jQuery BASE64 functions - * - * - * Encodes the given data with base64. - * String $.base64Encode ( String str ) - *
    - * Decodes a base64 encoded data. - * String $.base64Decode ( String str ) - *
    - * - * Encodes and Decodes the given data in base64. - * This encoding is designed to make binary data survive transport through transport layers that are not 8-bit clean, such as mail bodies. - * Base64-encoded data takes about 33% more space than the original data. - * This javascript code is used to encode / decode data using base64 (this encoding is designed to make binary data survive transport through transport layers that are not 8-bit clean). Script is fully compatible with UTF-8 encoding. You can use base64 encoded data as simple encryption mechanism. - * If you plan using UTF-8 encoding in your project don't forget to set the page encoding to UTF-8 (Content-Type meta tag). - * This function orginally get from the WebToolkit and rewrite for using as the jQuery plugin. - * - * Example - * Code - * - * $.base64Encode("I'm Persian."); - * - * Result - * - * "SSdtIFBlcnNpYW4u" - * - * Code - * - * $.base64Decode("SSdtIFBlcnNpYW4u"); - * - * Result - * - * "I'm Persian." - * - * - * @alias Muhammad Hussein Fattahizadeh < muhammad [AT] semnanweb [DOT] com > - * @link http://www.semnanweb.com/jquery-plugin/base64.html - * @see http://www.webtoolkit.info/ - * @license http://www.gnu.org/licenses/gpl.html [GNU General Public License] - * @param {jQuery} {base64Encode:function(input)) - * @param {jQuery} {base64Decode:function(input)) - * @return string - */ - - (function($){ - - var keyString = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/="; - - var uTF8Encode = function(string) { - string = string.replace(/\x0d\x0a/g, "\x0a"); - var output = ""; - for (var n = 0; n < string.length; n++) { - var c = string.charCodeAt(n); - if (c < 128) { - output += String.fromCharCode(c); - } else if ((c > 127) && (c < 2048)) { - output += String.fromCharCode((c >> 6) | 192); - output += String.fromCharCode((c & 63) | 128); - } else { - output += String.fromCharCode((c >> 12) | 224); - output += String.fromCharCode(((c >> 6) & 63) | 128); - output += String.fromCharCode((c & 63) | 128); - } - } - return output; - }; - - var uTF8Decode = function(input) { - var string = ""; - var i = 0; - var c = c1 = c2 = 0; - while ( i < input.length ) { - c = input.charCodeAt(i); - if (c < 128) { - string += String.fromCharCode(c); - i++; - } else if ((c > 191) && (c < 224)) { - c2 = input.charCodeAt(i+1); - string += String.fromCharCode(((c & 31) << 6) | (c2 & 63)); - i += 2; - } else { - c2 = input.charCodeAt(i+1); - c3 = input.charCodeAt(i+2); - string += String.fromCharCode(((c & 15) << 12) | ((c2 & 63) << 6) | (c3 & 63)); - i += 3; - } - } - return string; - } - - $.extend({ - base64Encode: function(input) { - var output = ""; - var chr1, chr2, chr3, enc1, enc2, enc3, enc4; - var i = 0; - input = uTF8Encode(input); - while (i < input.length) { - chr1 = input.charCodeAt(i++); - chr2 = input.charCodeAt(i++); - chr3 = input.charCodeAt(i++); - enc1 = chr1 >> 2; - enc2 = ((chr1 & 3) << 4) | (chr2 >> 4); - enc3 = ((chr2 & 15) << 2) | (chr3 >> 6); - enc4 = chr3 & 63; - if (isNaN(chr2)) { - enc3 = enc4 = 64; - } else if (isNaN(chr3)) { - enc4 = 64; - } - output = output + keyString.charAt(enc1) + keyString.charAt(enc2) + keyString.charAt(enc3) + keyString.charAt(enc4); - } - return output; - }, - base64Decode: function(input) { - var output = ""; - var chr1, chr2, chr3; - var enc1, enc2, enc3, enc4; - var i = 0; - input = input.replace(/[^A-Za-z0-9\+\/\=]/g, ""); - while (i < input.length) { - enc1 = keyString.indexOf(input.charAt(i++)); - enc2 = keyString.indexOf(input.charAt(i++)); - enc3 = keyString.indexOf(input.charAt(i++)); - enc4 = keyString.indexOf(input.charAt(i++)); - chr1 = (enc1 << 2) | (enc2 >> 4); - chr2 = ((enc2 & 15) << 4) | (enc3 >> 2); - chr3 = ((enc3 & 3) << 6) | enc4; - output = output + String.fromCharCode(chr1); - if (enc3 != 64) { - output = output + String.fromCharCode(chr2); - } - if (enc4 != 64) { - output = output + String.fromCharCode(chr3); - } - } - output = uTF8Decode(output); - return output; - } - }); - })(jQuery); \ No newline at end of file diff --git a/app/vendor/jquery.dateFormat-1.0.js b/app/vendor/jquery.dateFormat-1.0.js deleted file mode 100644 index 985d313..0000000 --- a/app/vendor/jquery.dateFormat-1.0.js +++ /dev/null @@ -1,317 +0,0 @@ -(function (jQuery) { - - var daysInWeek = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]; - var shortMonthsInYear = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; - var longMonthsInYear = ["January", "February", "March", "April", "May", "June", - "July", "August", "September", "October", "November", "December"]; - var shortMonthsToNumber = []; - shortMonthsToNumber["Jan"] = "01"; - shortMonthsToNumber["Feb"] = "02"; - shortMonthsToNumber["Mar"] = "03"; - shortMonthsToNumber["Apr"] = "04"; - shortMonthsToNumber["May"] = "05"; - shortMonthsToNumber["Jun"] = "06"; - shortMonthsToNumber["Jul"] = "07"; - shortMonthsToNumber["Aug"] = "08"; - shortMonthsToNumber["Sep"] = "09"; - shortMonthsToNumber["Oct"] = "10"; - shortMonthsToNumber["Nov"] = "11"; - shortMonthsToNumber["Dec"] = "12"; - - jQuery.format = (function () { - function strDay(value) { - return daysInWeek[parseInt(value, 10)] || value; - } - - function strMonth(value) { - var monthArrayIndex = parseInt(value, 10) - 1; - return shortMonthsInYear[monthArrayIndex] || value; - } - - function strLongMonth(value) { - var monthArrayIndex = parseInt(value, 10) - 1; - return longMonthsInYear[monthArrayIndex] || value; - } - - var parseMonth = function (value) { - return shortMonthsToNumber[value] || value; - }; - - var parseTime = function (value) { - var retValue = value; - var millis = ""; - if (retValue.indexOf(".") !== -1) { - var delimited = retValue.split('.'); - retValue = delimited[0]; - millis = delimited[1]; - } - - var values3 = retValue.split(":"); - - if (values3.length === 3) { - hour = values3[0]; - minute = values3[1]; - second = values3[2]; - - return { - time: retValue, - hour: hour, - minute: minute, - second: second, - millis: millis - }; - } else { - return { - time: "", - hour: "", - minute: "", - second: "", - millis: "" - }; - } - }; - - return { - date: function (value, format) { - /* - value = new java.util.Date() - 2009-12-18 10:54:50.546 - */ - try { - var date = null; - var year = null; - var month = null; - var dayOfMonth = null; - var dayOfWeek = null; - var time = null; - if (typeof value == "number"){ - return this.date(new Date(value), format); - } else if (typeof value.getFullYear == "function") { - year = value.getFullYear(); - month = value.getMonth() + 1; - dayOfMonth = value.getDate(); - dayOfWeek = value.getDay(); - time = parseTime(value.toTimeString()); - } else if (value.search(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.?\d{0,3}[Z\-+]?(\d{2}:?\d{2})?/) != -1) { - /* 2009-04-19T16:11:05+02:00 || 2009-04-19T16:11:05Z */ - var values = value.split(/[T\+-]/); - year = values[0]; - month = values[1]; - dayOfMonth = values[2]; - time = parseTime(values[3].split(".")[0]); - date = new Date(year, month - 1, dayOfMonth); - dayOfWeek = date.getDay(); - } else { - var values = value.split(" "); - switch (values.length) { - case 6: - /* Wed Jan 13 10:43:41 CET 2010 */ - year = values[5]; - month = parseMonth(values[1]); - dayOfMonth = values[2]; - time = parseTime(values[3]); - date = new Date(year, month - 1, dayOfMonth); - dayOfWeek = date.getDay(); - break; - case 2: - /* 2009-12-18 10:54:50.546 */ - var values2 = values[0].split("-"); - year = values2[0]; - month = values2[1]; - dayOfMonth = values2[2]; - time = parseTime(values[1]); - date = new Date(year, month - 1, dayOfMonth); - dayOfWeek = date.getDay(); - break; - case 7: - /* Tue Mar 01 2011 12:01:42 GMT-0800 (PST) */ - case 9: - /*added by Larry, for Fri Apr 08 2011 00:00:00 GMT+0800 (China Standard Time) */ - case 10: - /* added by Larry, for Fri Apr 08 2011 00:00:00 GMT+0200 (W. Europe Daylight Time) */ - year = values[3]; - month = parseMonth(values[1]); - dayOfMonth = values[2]; - time = parseTime(values[4]); - date = new Date(year, month - 1, dayOfMonth); - dayOfWeek = date.getDay(); - break; - case 1: - /* added by Jonny, for 2012-02-07CET00:00:00 (Doctrine Entity -> Json Serializer) */ - var values2 = values[0].split(""); - year=values2[0]+values2[1]+values2[2]+values2[3]; - month= values2[5]+values2[6]; - dayOfMonth = values2[8]+values2[9]; - time = parseTime(values2[13]+values2[14]+values2[15]+values2[16]+values2[17]+values2[18]+values2[19]+values2[20]) - date = new Date(year, month - 1, dayOfMonth); - dayOfWeek = date.getDay(); - break; - default: - return value; - } - } - - var pattern = ""; - var retValue = ""; - var unparsedRest = ""; - /* - Issue 1 - variable scope issue in format.date - Thanks jakemonO - */ - for (var i = 0; i < format.length; i++) { - var currentPattern = format.charAt(i); - pattern += currentPattern; - unparsedRest = ""; - switch (pattern) { - case "ddd": - retValue += strDay(dayOfWeek); - pattern = ""; - break; - case "dd": - if (format.charAt(i + 1) == "d") { - break; - } - if (String(dayOfMonth).length === 1) { - dayOfMonth = '0' + dayOfMonth; - } - retValue += dayOfMonth; - pattern = ""; - break; - case "d": - if (format.charAt(i + 1) == "d") { - break; - } - retValue += parseInt(dayOfMonth, 10); - pattern = ""; - break; - case "MMMM": - retValue += strLongMonth(month); - pattern = ""; - break; - case "MMM": - if (format.charAt(i + 1) === "M") { - break; - } - retValue += strMonth(month); - pattern = ""; - break; - case "MM": - if (format.charAt(i + 1) == "M") { - break; - } - if (String(month).length === 1) { - month = '0' + month; - } - retValue += month; - pattern = ""; - break; - case "M": - if (format.charAt(i + 1) == "M") { - break; - } - retValue += parseInt(month, 10); - pattern = ""; - break; - case "yyyy": - retValue += year; - pattern = ""; - break; - case "yy": - if (format.charAt(i + 1) == "y" && - format.charAt(i + 2) == "y") { - break; - } - retValue += String(year).slice(-2); - pattern = ""; - break; - case "HH": - retValue += time.hour; - pattern = ""; - break; - case "hh": - /* time.hour is "00" as string == is used instead of === */ - var hour = (time.hour == 0 ? 12 : time.hour < 13 ? time.hour : time.hour - 12); - hour = String(hour).length == 1 ? '0' + hour : hour; - retValue += hour; - pattern = ""; - break; - case "h": - if (format.charAt(i + 1) == "h") { - break; - } - var hour = (time.hour == 0 ? 12 : time.hour < 13 ? time.hour : time.hour - 12); - retValue += parseInt(hour, 10); - // Fixing issue https://github.com/phstc/jquery-dateFormat/issues/21 - // retValue = parseInt(retValue, 10); - pattern = ""; - break; - case "mm": - retValue += time.minute; - pattern = ""; - break; - case "ss": - /* ensure only seconds are added to the return string */ - retValue += time.second.substring(0, 2); - pattern = ""; - break; - case "SSS": - retValue += time.millis.substring(0, 3); - pattern = ""; - break; - case "a": - retValue += time.hour >= 12 ? "PM" : "AM"; - pattern = ""; - break; - case " ": - retValue += currentPattern; - pattern = ""; - break; - case "/": - retValue += currentPattern; - pattern = ""; - break; - case ":": - retValue += currentPattern; - pattern = ""; - break; - default: - if (pattern.length === 2 && pattern.indexOf("y") !== 0 && pattern != "SS") { - retValue += pattern.substring(0, 1); - pattern = pattern.substring(1, 2); - } else if ((pattern.length === 3 && pattern.indexOf("yyy") === -1)) { - pattern = ""; - } else { - unparsedRest = pattern; - } - } - } - retValue += unparsedRest; - return retValue; - } catch (e) { - console.log(e); - return value; - } - } - }; - }()); -}(jQuery)); - -jQuery.format.date.defaultShortDateFormat = "dd/MM/yyyy"; -jQuery.format.date.defaultLongDateFormat = "dd/MM/yyyy hh:mm:ss"; - -jQuery(document).ready(function () { - jQuery(".shortDateFormat").each(function (idx, elem) { - if (jQuery(elem).is(":input")) { - jQuery(elem).val(jQuery.format.date(jQuery(elem).val(), jQuery.format.date.defaultShortDateFormat)); - } else { - jQuery(elem).text(jQuery.format.date(jQuery(elem).text(), jQuery.format.date.defaultShortDateFormat)); - } - }); - jQuery(".longDateFormat").each(function (idx, elem) { - if (jQuery(elem).is(":input")) { - jQuery(elem).val(jQuery.format.date(jQuery(elem).val(), jQuery.format.date.defaultLongDateFormat)); - } else { - jQuery(elem).text(jQuery.format.date(jQuery(elem).text(), jQuery.format.date.defaultLongDateFormat)); - } - }); -}); \ No newline at end of file diff --git a/bower.json b/bower.json index 8252bdf..c46dbe4 100644 --- a/bower.json +++ b/bower.json @@ -19,28 +19,30 @@ "archive.org", "music" ], - "license": "MIT", + "license": "GPL-2.0", "repository": { "type": "git", "url": "https://github.com/tsquillario/Jamstash.git" }, "main": "app/index.html", "dependencies": { - "angular": "~1.2.0", - "angular-route": "~1.2.0", - "angular-sanitize": "~1.2.0", - "angular-cookies": "~1.2.0", - "angular-resource": "~1.2.0", + "angular": "~1.3.15", + "angular-route": "~1.3.15", + "angular-sanitize": "~1.3.15", + "angular-cookies": "~1.3.15", + "angular-resource": "~1.3.15", "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", + "jquery-dateFormat": "~1.0.2", + "underscore": "~1.8.3", "angular-underscore": "~0.5.0", "angular-locker": "~1.2.0", - "angular-ui-utils": "bower-keypress" + "angular-ui-utils": "bower-keypress", + "open-iconic": "~1.1.1" }, "overrides": { "fancybox": { @@ -51,8 +53,8 @@ } }, "devDependencies": { - "angular-mocks": "~1.2.0", - "jasmine-promise-matchers": "~0.0.3", + "angular-mocks": "~1.3.15", + "jasmine-promise-matchers": "~1.1.1", "jasmine-fixture": "~1.2.2" }, "ignore": [ diff --git a/karma.conf.js b/karma.conf.js index e4c75ba..67b8e34 100644 --- a/karma.conf.js +++ b/karma.conf.js @@ -30,6 +30,7 @@ module.exports = function (config) { 'bower_components/fancybox/source/jquery.fancybox.js', 'bower_components/notify.js/notify.js', 'bower_components/jquery.scrollTo/jquery.scrollTo.js', + 'bower_components/jquery-dateFormat/dist/jquery-dateFormat.js', 'bower_components/underscore/underscore.js', 'bower_components/angular-underscore/angular-underscore.js', 'bower_components/angular-locker/dist/angular-locker.min.js', @@ -39,14 +40,21 @@ module.exports = function (config) { 'bower_components/jasmine-fixture/dist/jasmine-fixture.js', // endbower 'app/**/*.js', - 'app/**/*_test.js' + 'app/**/*_test.js', + 'app/**/*.html' ], // list of files / patterns to exclude // exclude: ['app/vendor/**/*.js'], preprocessors: { - 'app/**/!(*_test).js': ['coverage'] + 'app/**/!(*_test).js': ['coverage'], + 'app/**/*.html': ['ng-html2js'] + }, + + ngHtml2JsPreprocessor: { + stripPrefix: 'app/', + moduleName: 'templates' }, // web server port @@ -60,18 +68,7 @@ module.exports = function (config) { // - Safari (only Mac) // - PhantomJS // - IE (only Windows) - browsers: [ - 'PhantomJS' - ], - - // Which plugins to enable - plugins: [ - 'karma-chrome-launcher', - 'karma-phantomjs-launcher', - 'karma-jasmine', - 'karma-coverage', - 'karma-notify-reporter' - ], + browsers: [], // Continuous Integration mode // if true, it capture browsers, run tests and exit diff --git a/package.json b/package.json index 3e20657..a7f8083 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ "x37v (https://github.com/x37v)", "Hyzual (https://github.com/Hyzual)" ], - "license": "MIT", + "license": "GPL-2.0", "homepage": "http://jamstash.com", "keywords": [ "subsonic", @@ -31,30 +31,29 @@ "grunt": "^0.4.5", "grunt-bump": "^0.3.0", "grunt-contrib-clean": "^0.6.0", - "grunt-contrib-concat": "^0.5.0", - "grunt-contrib-connect": "^0.8.0", - "grunt-contrib-copy": "^0.7.0", - "grunt-contrib-cssmin": "^0.10.0", - "grunt-contrib-htmlmin": "^0.3.0", - "grunt-contrib-imagemin": "^0.9.1", - "grunt-contrib-jshint": "^0.10.0", - "grunt-contrib-uglify": "^0.6.0", + "grunt-contrib-concat": "^0.5.1", + "grunt-contrib-connect": "^0.10.1", + "grunt-contrib-copy": "^0.8.0", + "grunt-contrib-cssmin": "^0.12.2", + "grunt-contrib-htmlmin": "^0.4.0", + "grunt-contrib-imagemin": "^0.9.4", + "grunt-contrib-uglify": "^0.9.1", "grunt-contrib-watch": "^0.6.1", - "grunt-filerev": "^2.1.1", - "grunt-karma": "^0.9.0", + "grunt-filerev": "^2.2.0", + "grunt-karma": "^0.10.1", "grunt-notify": "^0.4.1", - "grunt-ssh": "^0.12.0", - "grunt-usemin": "^2.6.0", - "grunt-wiredep": "^1.9.0", - "jit-grunt": "^0.9.0", - "jshint-stylish": "^1.0.0", - "karma": "^0.12.32", - "karma-chrome-launcher": "^0.1.5", - "karma-coverage": "^0.2.6", - "karma-jasmine": "^0.3.0", + "grunt-ssh": "^0.12.2", + "grunt-svg-sprite": "^1.1.0", + "grunt-usemin": "^3.0.0", + "grunt-wiredep": "^2.0.0", + "jit-grunt": "^0.9.1", + "karma": "^0.12.31", + "karma-chrome-launcher": "^0.1.7", + "karma-coverage": "^0.2.7", + "karma-jasmine": "^0.3.5", + "karma-ng-html2js-preprocessor": "^0.1.2", "karma-notify-reporter": "^0.1.1", - "karma-phantomjs-launcher": "^0.1.4", - "time-grunt": "^1.0.0" + "time-grunt": "^1.1.0" }, "engines": { "node": ">=0.10.0"