├── .gitignore ├── README.md ├── app.js ├── assets └── buttons.psd ├── bower.json ├── bower_components ├── angular-route │ ├── .bower.json │ ├── README.md │ ├── angular-route.js │ ├── angular-route.min.js │ ├── angular-route.min.js.map │ └── bower.json └── angular │ ├── .bower.json │ ├── README.md │ ├── angular-csp.css │ ├── angular.js │ ├── angular.min.js │ ├── angular.min.js.gzip │ ├── angular.min.js.map │ └── bower.json ├── callback.html ├── controllers ├── album.js ├── artist.js ├── browse.js ├── browsecategory.js ├── home.js ├── login.js ├── player.js ├── playlist.js ├── playqueue.js ├── searchresults.js ├── user.js └── usertracks.js ├── directives ├── contextmenu.js ├── focusme.js ├── playlistcover.js └── responsivecover.js ├── filters ├── displaytime.js └── timeago.js ├── images ├── btn-next.png ├── btn-pause.png ├── btn-play.png ├── btn-prev.png └── placeholder-playlist.png ├── index.html ├── partials ├── album.html ├── artist.html ├── browse.html ├── browsecategory.html ├── home.html ├── playlist.html ├── playqueue.html ├── searchresults.html ├── user.html └── usertracks.html ├── readme-img └── webapi-player-example.jpg ├── services ├── api.js ├── auth.js ├── playback.js └── playqueue.js └── style.css /.gitignore: -------------------------------------------------------------------------------- 1 | *.log 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Spotify Web API Player Example 2 | ================== 3 | 4 | This is a Web Player built using the [Spotify Web API](https://developer.spotify.com/web-api/). For 5 | trying it out, you can navigate to [http://lab.possan.se/thirtify/](http://lab.possan.se/thirtify/) 6 | or clone the project and run it locally. 7 | 8 | ![Web API Player Example Screenshot](https://raw.githubusercontent.com/possan/webapi-player-example/master/readme-img/webapi-player-example.jpg) 9 | 10 | Note that you will need a Spotify account (either free or premium) to log in to the site. 11 | 12 | ## How to Run 13 | You will need to run a server. The example is ready to work in the port 8000, so you can do: 14 | 15 | $ python -m SimpleHTTPServer 8000 16 | 17 | and open `http://localhost:8000` in a browser. (This requires python to be installed on your machine.) 18 | 19 | ## Features 20 | 21 | Most of the functionality offered through the Spotify Web API endpoints is implemented in this player: 22 | 23 | - Play 30 second audio previews 24 | - Render track, album and artist information 25 | - Render new releases in Spotify and featured playlists 26 | - Search for tracks 27 | - Fetch user's playlists, rename then and change their visibility 28 | - Delete track from playlist 29 | - Fetch user's saved tracks and save a tracks 30 | - Follow and unfollow artists or users 31 | - Check if the user is following an artist or user -------------------------------------------------------------------------------- /app.js: -------------------------------------------------------------------------------- 1 | (function() { 2 | 3 | var app = angular.module('PlayerApp', ['ngRoute']); 4 | 5 | app.config(function($routeProvider) { 6 | $routeProvider. 7 | when('/', { 8 | templateUrl: 'partials/browse.html', 9 | controller: 'BrowseController' 10 | }). 11 | when('/playqueue', { 12 | templateUrl: 'partials/playqueue.html', 13 | controller: 'PlayQueueController' 14 | }). 15 | when('/users/:username', { 16 | templateUrl: 'partials/user.html', 17 | controller: 'UserController' 18 | }). 19 | when('/users/:username/tracks', { 20 | templateUrl: 'partials/usertracks.html', 21 | controller: 'UserTracksController' 22 | }). 23 | when('/users/:username/playlists/:playlist', { 24 | templateUrl: 'partials/playlist.html', 25 | controller: 'PlaylistController' 26 | }). 27 | when('/artists/:artist', { 28 | templateUrl: 'partials/artist.html', 29 | controller: 'ArtistController' 30 | }). 31 | when('/albums/:album', { 32 | templateUrl: 'partials/album.html', 33 | controller: 'AlbumController' 34 | }). 35 | when('/search', { 36 | templateUrl: 'partials/searchresults.html', 37 | controller: 'SearchResultsController' 38 | }). 39 | when('/category/:categoryid', { 40 | templateUrl: 'partials/browsecategory.html', 41 | controller: 'BrowseCategoryController' 42 | }). 43 | otherwise({ 44 | redirectTo: '/' 45 | }); 46 | }); 47 | 48 | app.controller('AppController', function($scope, Auth, API, $location) { 49 | console.log('in AppController'); 50 | 51 | console.log(location); 52 | 53 | function checkUser(redirectToLogin) { 54 | API.getMe().then(function(userInfo) { 55 | Auth.setUsername(userInfo.id); 56 | Auth.setUserCountry(userInfo.country); 57 | if (redirectToLogin) { 58 | $scope.$emit('login'); 59 | $location.replace(); 60 | } 61 | }, function(err) { 62 | $scope.showplayer = false; 63 | $scope.showlogin = true; 64 | $location.replace(); 65 | }); 66 | } 67 | 68 | window.addEventListener("message", function(event) { 69 | console.log('got postmessage', event); 70 | var hash = JSON.parse(event.data); 71 | if (hash.type == 'access_token') { 72 | Auth.setAccessToken(hash.access_token, hash.expires_in || 60); 73 | checkUser(true); 74 | } 75 | }, false); 76 | 77 | $scope.isLoggedIn = (Auth.getAccessToken() != ''); 78 | $scope.showplayer = $scope.isLoggedIn; 79 | $scope.showlogin = !$scope.isLoggedIn; 80 | 81 | $scope.$on('login', function() { 82 | $scope.showplayer = true; 83 | $scope.showlogin = false; 84 | $location.path('/').replace().reload(); 85 | }); 86 | 87 | $scope.$on('logout', function() { 88 | $scope.showplayer = false; 89 | $scope.showlogin = true; 90 | }); 91 | 92 | $scope.getClass = function(path) { 93 | if ($location.path().substr(0, path.length) == path) { 94 | return 'active'; 95 | } else { 96 | return ''; 97 | } 98 | }; 99 | 100 | $scope.focusInput = false; 101 | $scope.menuOptions = function(playlist) { 102 | 103 | var visibilityEntry = [playlist.public ? 'Make secret' : 'Make public', function ($itemScope) { 104 | API.changePlaylistDetails(playlist.username, playlist.id, {public: !playlist.public}) 105 | .then(function() { 106 | playlist.public = !playlist.public; 107 | }); 108 | }]; 109 | 110 | var own = playlist.username === Auth.getUsername(); 111 | if (own) { 112 | return [ 113 | visibilityEntry, 114 | null, 115 | ['Rename', function ($itemScope) { 116 | playlist.editing = true; 117 | $scope.focusInput = true; 118 | }] 119 | ]; 120 | } else { 121 | return [ visibilityEntry ]; 122 | } 123 | }; 124 | 125 | $scope.playlistNameKeyUp = function(event, playlist) { 126 | if (event.which === 13) { 127 | // enter 128 | var newName = event.target.value; 129 | API.changePlaylistDetails(playlist.username, playlist.id, {name: newName}) 130 | .then(function() { 131 | playlist.name = newName; 132 | playlist.editing = false; 133 | $scope.focusInput = false; 134 | }); 135 | } 136 | 137 | if (event.which === 27) { 138 | // escape 139 | playlist.editing = false; 140 | $scope.focusInput = false; 141 | } 142 | }; 143 | 144 | $scope.playlistNameBlur = function(playlist) { 145 | playlist.editing = false; 146 | $scope.focusInput = false; 147 | }; 148 | 149 | checkUser(); 150 | }); 151 | 152 | })(); 153 | -------------------------------------------------------------------------------- /assets/buttons.psd: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/possan/webapi-player-example/47d3e78fcdd8baa8c89b0e760e0d72fabf9b1386/assets/buttons.psd -------------------------------------------------------------------------------- /bower.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "player", 3 | "version": "0.0.0", 4 | "homepage": "https://github.com/possan/webapi-player-example", 5 | "license": "MIT", 6 | "ignore": [ 7 | "**/.*", 8 | "node_modules", 9 | "bower_components", 10 | "test", 11 | "tests" 12 | ], 13 | "dependencies": { 14 | "angular-route": "~1.2.16" 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /bower_components/angular-route/.bower.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "angular-route", 3 | "version": "1.2.16", 4 | "main": "./angular-route.js", 5 | "dependencies": { 6 | "angular": "1.2.16" 7 | }, 8 | "homepage": "https://github.com/angular/bower-angular-route", 9 | "_release": "1.2.16", 10 | "_resolution": { 11 | "type": "version", 12 | "tag": "v1.2.16", 13 | "commit": "ed0e2b796077d953f518cb81cc7af981cf695a45" 14 | }, 15 | "_source": "git://github.com/angular/bower-angular-route.git", 16 | "_target": "~1.2.16", 17 | "_originalSource": "angular-route" 18 | } -------------------------------------------------------------------------------- /bower_components/angular-route/README.md: -------------------------------------------------------------------------------- 1 | # bower-angular-route 2 | 3 | This repo is for distribution on `bower`. The source for this module is in the 4 | [main AngularJS repo](https://github.com/angular/angular.js/tree/master/src/ngRoute). 5 | Please file issues and pull requests against that repo. 6 | 7 | ## Install 8 | 9 | Install with `bower`: 10 | 11 | ```shell 12 | bower install angular-route 13 | ``` 14 | 15 | Add a ` 19 | ``` 20 | 21 | And add `ngRoute` as a dependency for your app: 22 | 23 | ```javascript 24 | angular.module('myApp', ['ngRoute']); 25 | ``` 26 | 27 | ## Documentation 28 | 29 | Documentation is available on the 30 | [AngularJS docs site](http://docs.angularjs.org/api/ngRoute). 31 | 32 | ## License 33 | 34 | The MIT License 35 | 36 | Copyright (c) 2010-2012 Google, Inc. http://angularjs.org 37 | 38 | Permission is hereby granted, free of charge, to any person obtaining a copy 39 | of this software and associated documentation files (the "Software"), to deal 40 | in the Software without restriction, including without limitation the rights 41 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 42 | copies of the Software, and to permit persons to whom the Software is 43 | furnished to do so, subject to the following conditions: 44 | 45 | The above copyright notice and this permission notice shall be included in 46 | all copies or substantial portions of the Software. 47 | 48 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 49 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 50 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 51 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 52 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 53 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 54 | THE SOFTWARE. 55 | -------------------------------------------------------------------------------- /bower_components/angular-route/angular-route.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @license AngularJS v1.2.16 3 | * (c) 2010-2014 Google, Inc. http://angularjs.org 4 | * License: MIT 5 | */ 6 | (function(window, angular, undefined) {'use strict'; 7 | 8 | /** 9 | * @ngdoc module 10 | * @name ngRoute 11 | * @description 12 | * 13 | * # ngRoute 14 | * 15 | * The `ngRoute` module provides routing and deeplinking services and directives for angular apps. 16 | * 17 | * ## Example 18 | * See {@link ngRoute.$route#example $route} for an example of configuring and using `ngRoute`. 19 | * 20 | * 21 | *
22 | */ 23 | /* global -ngRouteModule */ 24 | var ngRouteModule = angular.module('ngRoute', ['ng']). 25 | provider('$route', $RouteProvider); 26 | 27 | /** 28 | * @ngdoc provider 29 | * @name $routeProvider 30 | * @function 31 | * 32 | * @description 33 | * 34 | * Used for configuring routes. 35 | * 36 | * ## Example 37 | * See {@link ngRoute.$route#example $route} for an example of configuring and using `ngRoute`. 38 | * 39 | * ## Dependencies 40 | * Requires the {@link ngRoute `ngRoute`} module to be installed. 41 | */ 42 | function $RouteProvider(){ 43 | function inherit(parent, extra) { 44 | return angular.extend(new (angular.extend(function() {}, {prototype:parent}))(), extra); 45 | } 46 | 47 | var routes = {}; 48 | 49 | /** 50 | * @ngdoc method 51 | * @name $routeProvider#when 52 | * 53 | * @param {string} path Route path (matched against `$location.path`). If `$location.path` 54 | * contains redundant trailing slash or is missing one, the route will still match and the 55 | * `$location.path` will be updated to add or drop the trailing slash to exactly match the 56 | * route definition. 57 | * 58 | * * `path` can contain named groups starting with a colon: e.g. `:name`. All characters up 59 | * to the next slash are matched and stored in `$routeParams` under the given `name` 60 | * when the route matches. 61 | * * `path` can contain named groups starting with a colon and ending with a star: 62 | * e.g.`:name*`. All characters are eagerly stored in `$routeParams` under the given `name` 63 | * when the route matches. 64 | * * `path` can contain optional named groups with a question mark: e.g.`:name?`. 65 | * 66 | * For example, routes like `/color/:color/largecode/:largecode*\/edit` will match 67 | * `/color/brown/largecode/code/with/slashes/edit` and extract: 68 | * 69 | * * `color: brown` 70 | * * `largecode: code/with/slashes`. 71 | * 72 | * 73 | * @param {Object} route Mapping information to be assigned to `$route.current` on route 74 | * match. 75 | * 76 | * Object properties: 77 | * 78 | * - `controller` – `{(string|function()=}` – Controller fn that should be associated with 79 | * newly created scope or the name of a {@link angular.Module#controller registered 80 | * controller} if passed as a string. 81 | * - `controllerAs` – `{string=}` – A controller alias name. If present the controller will be 82 | * published to scope under the `controllerAs` name. 83 | * - `template` – `{string=|function()=}` – html template as a string or a function that 84 | * returns an html template as a string which should be used by {@link 85 | * ngRoute.directive:ngView ngView} or {@link ng.directive:ngInclude ngInclude} directives. 86 | * This property takes precedence over `templateUrl`. 87 | * 88 | * If `template` is a function, it will be called with the following parameters: 89 | * 90 | * - `{Array.}` - route parameters extracted from the current 91 | * `$location.path()` by applying the current route 92 | * 93 | * - `templateUrl` – `{string=|function()=}` – path or function that returns a path to an html 94 | * template that should be used by {@link ngRoute.directive:ngView ngView}. 95 | * 96 | * If `templateUrl` is a function, it will be called with the following parameters: 97 | * 98 | * - `{Array.}` - route parameters extracted from the current 99 | * `$location.path()` by applying the current route 100 | * 101 | * - `resolve` - `{Object.=}` - An optional map of dependencies which should 102 | * be injected into the controller. If any of these dependencies are promises, the router 103 | * will wait for them all to be resolved or one to be rejected before the controller is 104 | * instantiated. 105 | * If all the promises are resolved successfully, the values of the resolved promises are 106 | * injected and {@link ngRoute.$route#$routeChangeSuccess $routeChangeSuccess} event is 107 | * fired. If any of the promises are rejected the 108 | * {@link ngRoute.$route#$routeChangeError $routeChangeError} event is fired. The map object 109 | * is: 110 | * 111 | * - `key` – `{string}`: a name of a dependency to be injected into the controller. 112 | * - `factory` - `{string|function}`: If `string` then it is an alias for a service. 113 | * Otherwise if function, then it is {@link auto.$injector#invoke injected} 114 | * and the return value is treated as the dependency. If the result is a promise, it is 115 | * resolved before its value is injected into the controller. Be aware that 116 | * `ngRoute.$routeParams` will still refer to the previous route within these resolve 117 | * functions. Use `$route.current.params` to access the new route parameters, instead. 118 | * 119 | * - `redirectTo` – {(string|function())=} – value to update 120 | * {@link ng.$location $location} path with and trigger route redirection. 121 | * 122 | * If `redirectTo` is a function, it will be called with the following parameters: 123 | * 124 | * - `{Object.}` - route parameters extracted from the current 125 | * `$location.path()` by applying the current route templateUrl. 126 | * - `{string}` - current `$location.path()` 127 | * - `{Object}` - current `$location.search()` 128 | * 129 | * The custom `redirectTo` function is expected to return a string which will be used 130 | * to update `$location.path()` and `$location.search()`. 131 | * 132 | * - `[reloadOnSearch=true]` - {boolean=} - reload route when only `$location.search()` 133 | * or `$location.hash()` changes. 134 | * 135 | * If the option is set to `false` and url in the browser changes, then 136 | * `$routeUpdate` event is broadcasted on the root scope. 137 | * 138 | * - `[caseInsensitiveMatch=false]` - {boolean=} - match routes without being case sensitive 139 | * 140 | * If the option is set to `true`, then the particular route can be matched without being 141 | * case sensitive 142 | * 143 | * @returns {Object} self 144 | * 145 | * @description 146 | * Adds a new route definition to the `$route` service. 147 | */ 148 | this.when = function(path, route) { 149 | routes[path] = angular.extend( 150 | {reloadOnSearch: true}, 151 | route, 152 | path && pathRegExp(path, route) 153 | ); 154 | 155 | // create redirection for trailing slashes 156 | if (path) { 157 | var redirectPath = (path[path.length-1] == '/') 158 | ? path.substr(0, path.length-1) 159 | : path +'/'; 160 | 161 | routes[redirectPath] = angular.extend( 162 | {redirectTo: path}, 163 | pathRegExp(redirectPath, route) 164 | ); 165 | } 166 | 167 | return this; 168 | }; 169 | 170 | /** 171 | * @param path {string} path 172 | * @param opts {Object} options 173 | * @return {?Object} 174 | * 175 | * @description 176 | * Normalizes the given path, returning a regular expression 177 | * and the original path. 178 | * 179 | * Inspired by pathRexp in visionmedia/express/lib/utils.js. 180 | */ 181 | function pathRegExp(path, opts) { 182 | var insensitive = opts.caseInsensitiveMatch, 183 | ret = { 184 | originalPath: path, 185 | regexp: path 186 | }, 187 | keys = ret.keys = []; 188 | 189 | path = path 190 | .replace(/([().])/g, '\\$1') 191 | .replace(/(\/)?:(\w+)([\?\*])?/g, function(_, slash, key, option){ 192 | var optional = option === '?' ? option : null; 193 | var star = option === '*' ? option : null; 194 | keys.push({ name: key, optional: !!optional }); 195 | slash = slash || ''; 196 | return '' 197 | + (optional ? '' : slash) 198 | + '(?:' 199 | + (optional ? slash : '') 200 | + (star && '(.+?)' || '([^/]+)') 201 | + (optional || '') 202 | + ')' 203 | + (optional || ''); 204 | }) 205 | .replace(/([\/$\*])/g, '\\$1'); 206 | 207 | ret.regexp = new RegExp('^' + path + '$', insensitive ? 'i' : ''); 208 | return ret; 209 | } 210 | 211 | /** 212 | * @ngdoc method 213 | * @name $routeProvider#otherwise 214 | * 215 | * @description 216 | * Sets route definition that will be used on route change when no other route definition 217 | * is matched. 218 | * 219 | * @param {Object} params Mapping information to be assigned to `$route.current`. 220 | * @returns {Object} self 221 | */ 222 | this.otherwise = function(params) { 223 | this.when(null, params); 224 | return this; 225 | }; 226 | 227 | 228 | this.$get = ['$rootScope', 229 | '$location', 230 | '$routeParams', 231 | '$q', 232 | '$injector', 233 | '$http', 234 | '$templateCache', 235 | '$sce', 236 | function($rootScope, $location, $routeParams, $q, $injector, $http, $templateCache, $sce) { 237 | 238 | /** 239 | * @ngdoc service 240 | * @name $route 241 | * @requires $location 242 | * @requires $routeParams 243 | * 244 | * @property {Object} current Reference to the current route definition. 245 | * The route definition contains: 246 | * 247 | * - `controller`: The controller constructor as define in route definition. 248 | * - `locals`: A map of locals which is used by {@link ng.$controller $controller} service for 249 | * controller instantiation. The `locals` contain 250 | * the resolved values of the `resolve` map. Additionally the `locals` also contain: 251 | * 252 | * - `$scope` - The current route scope. 253 | * - `$template` - The current route template HTML. 254 | * 255 | * @property {Object} routes Object with all route configuration Objects as its properties. 256 | * 257 | * @description 258 | * `$route` is used for deep-linking URLs to controllers and views (HTML partials). 259 | * It watches `$location.url()` and tries to map the path to an existing route definition. 260 | * 261 | * Requires the {@link ngRoute `ngRoute`} module to be installed. 262 | * 263 | * You can define routes through {@link ngRoute.$routeProvider $routeProvider}'s API. 264 | * 265 | * The `$route` service is typically used in conjunction with the 266 | * {@link ngRoute.directive:ngView `ngView`} directive and the 267 | * {@link ngRoute.$routeParams `$routeParams`} service. 268 | * 269 | * @example 270 | * This example shows how changing the URL hash causes the `$route` to match a route against the 271 | * URL, and the `ngView` pulls in the partial. 272 | * 273 | * Note that this example is using {@link ng.directive:script inlined templates} 274 | * to get it working on jsfiddle as well. 275 | * 276 | * 278 | * 279 | *
280 | * Choose: 281 | * Moby | 282 | * Moby: Ch1 | 283 | * Gatsby | 284 | * Gatsby: Ch4 | 285 | * Scarlet Letter
286 | * 287 | *
288 | * 289 | *
290 | * 291 | *
$location.path() = {{$location.path()}}
292 | *
$route.current.templateUrl = {{$route.current.templateUrl}}
293 | *
$route.current.params = {{$route.current.params}}
294 | *
$route.current.scope.name = {{$route.current.scope.name}}
295 | *
$routeParams = {{$routeParams}}
296 | *
297 | *
298 | * 299 | * 300 | * controller: {{name}}
301 | * Book Id: {{params.bookId}}
302 | *
303 | * 304 | * 305 | * controller: {{name}}
306 | * Book Id: {{params.bookId}}
307 | * Chapter Id: {{params.chapterId}} 308 | *
309 | * 310 | * 311 | * angular.module('ngRouteExample', ['ngRoute']) 312 | * 313 | * .controller('MainController', function($scope, $route, $routeParams, $location) { 314 | * $scope.$route = $route; 315 | * $scope.$location = $location; 316 | * $scope.$routeParams = $routeParams; 317 | * }) 318 | * 319 | * .controller('BookController', function($scope, $routeParams) { 320 | * $scope.name = "BookController"; 321 | * $scope.params = $routeParams; 322 | * }) 323 | * 324 | * .controller('ChapterController', function($scope, $routeParams) { 325 | * $scope.name = "ChapterController"; 326 | * $scope.params = $routeParams; 327 | * }) 328 | * 329 | * .config(function($routeProvider, $locationProvider) { 330 | * $routeProvider 331 | * .when('/Book/:bookId', { 332 | * templateUrl: 'book.html', 333 | * controller: 'BookController', 334 | * resolve: { 335 | * // I will cause a 1 second delay 336 | * delay: function($q, $timeout) { 337 | * var delay = $q.defer(); 338 | * $timeout(delay.resolve, 1000); 339 | * return delay.promise; 340 | * } 341 | * } 342 | * }) 343 | * .when('/Book/:bookId/ch/:chapterId', { 344 | * templateUrl: 'chapter.html', 345 | * controller: 'ChapterController' 346 | * }); 347 | * 348 | * // configure html5 to get links working on jsfiddle 349 | * $locationProvider.html5Mode(true); 350 | * }); 351 | * 352 | * 353 | * 354 | * 355 | * it('should load and compile correct template', function() { 356 | * element(by.linkText('Moby: Ch1')).click(); 357 | * var content = element(by.css('[ng-view]')).getText(); 358 | * expect(content).toMatch(/controller\: ChapterController/); 359 | * expect(content).toMatch(/Book Id\: Moby/); 360 | * expect(content).toMatch(/Chapter Id\: 1/); 361 | * 362 | * element(by.partialLinkText('Scarlet')).click(); 363 | * 364 | * content = element(by.css('[ng-view]')).getText(); 365 | * expect(content).toMatch(/controller\: BookController/); 366 | * expect(content).toMatch(/Book Id\: Scarlet/); 367 | * }); 368 | * 369 | *
370 | */ 371 | 372 | /** 373 | * @ngdoc event 374 | * @name $route#$routeChangeStart 375 | * @eventType broadcast on root scope 376 | * @description 377 | * Broadcasted before a route change. At this point the route services starts 378 | * resolving all of the dependencies needed for the route change to occur. 379 | * Typically this involves fetching the view template as well as any dependencies 380 | * defined in `resolve` route property. Once all of the dependencies are resolved 381 | * `$routeChangeSuccess` is fired. 382 | * 383 | * @param {Object} angularEvent Synthetic event object. 384 | * @param {Route} next Future route information. 385 | * @param {Route} current Current route information. 386 | */ 387 | 388 | /** 389 | * @ngdoc event 390 | * @name $route#$routeChangeSuccess 391 | * @eventType broadcast on root scope 392 | * @description 393 | * Broadcasted after a route dependencies are resolved. 394 | * {@link ngRoute.directive:ngView ngView} listens for the directive 395 | * to instantiate the controller and render the view. 396 | * 397 | * @param {Object} angularEvent Synthetic event object. 398 | * @param {Route} current Current route information. 399 | * @param {Route|Undefined} previous Previous route information, or undefined if current is 400 | * first route entered. 401 | */ 402 | 403 | /** 404 | * @ngdoc event 405 | * @name $route#$routeChangeError 406 | * @eventType broadcast on root scope 407 | * @description 408 | * Broadcasted if any of the resolve promises are rejected. 409 | * 410 | * @param {Object} angularEvent Synthetic event object 411 | * @param {Route} current Current route information. 412 | * @param {Route} previous Previous route information. 413 | * @param {Route} rejection Rejection of the promise. Usually the error of the failed promise. 414 | */ 415 | 416 | /** 417 | * @ngdoc event 418 | * @name $route#$routeUpdate 419 | * @eventType broadcast on root scope 420 | * @description 421 | * 422 | * The `reloadOnSearch` property has been set to false, and we are reusing the same 423 | * instance of the Controller. 424 | */ 425 | 426 | var forceReload = false, 427 | $route = { 428 | routes: routes, 429 | 430 | /** 431 | * @ngdoc method 432 | * @name $route#reload 433 | * 434 | * @description 435 | * Causes `$route` service to reload the current route even if 436 | * {@link ng.$location $location} hasn't changed. 437 | * 438 | * As a result of that, {@link ngRoute.directive:ngView ngView} 439 | * creates new scope, reinstantiates the controller. 440 | */ 441 | reload: function() { 442 | forceReload = true; 443 | $rootScope.$evalAsync(updateRoute); 444 | } 445 | }; 446 | 447 | $rootScope.$on('$locationChangeSuccess', updateRoute); 448 | 449 | return $route; 450 | 451 | ///////////////////////////////////////////////////// 452 | 453 | /** 454 | * @param on {string} current url 455 | * @param route {Object} route regexp to match the url against 456 | * @return {?Object} 457 | * 458 | * @description 459 | * Check if the route matches the current url. 460 | * 461 | * Inspired by match in 462 | * visionmedia/express/lib/router/router.js. 463 | */ 464 | function switchRouteMatcher(on, route) { 465 | var keys = route.keys, 466 | params = {}; 467 | 468 | if (!route.regexp) return null; 469 | 470 | var m = route.regexp.exec(on); 471 | if (!m) return null; 472 | 473 | for (var i = 1, len = m.length; i < len; ++i) { 474 | var key = keys[i - 1]; 475 | 476 | var val = 'string' == typeof m[i] 477 | ? decodeURIComponent(m[i]) 478 | : m[i]; 479 | 480 | if (key && val) { 481 | params[key.name] = val; 482 | } 483 | } 484 | return params; 485 | } 486 | 487 | function updateRoute() { 488 | var next = parseRoute(), 489 | last = $route.current; 490 | 491 | if (next && last && next.$$route === last.$$route 492 | && angular.equals(next.pathParams, last.pathParams) 493 | && !next.reloadOnSearch && !forceReload) { 494 | last.params = next.params; 495 | angular.copy(last.params, $routeParams); 496 | $rootScope.$broadcast('$routeUpdate', last); 497 | } else if (next || last) { 498 | forceReload = false; 499 | $rootScope.$broadcast('$routeChangeStart', next, last); 500 | $route.current = next; 501 | if (next) { 502 | if (next.redirectTo) { 503 | if (angular.isString(next.redirectTo)) { 504 | $location.path(interpolate(next.redirectTo, next.params)).search(next.params) 505 | .replace(); 506 | } else { 507 | $location.url(next.redirectTo(next.pathParams, $location.path(), $location.search())) 508 | .replace(); 509 | } 510 | } 511 | } 512 | 513 | $q.when(next). 514 | then(function() { 515 | if (next) { 516 | var locals = angular.extend({}, next.resolve), 517 | template, templateUrl; 518 | 519 | angular.forEach(locals, function(value, key) { 520 | locals[key] = angular.isString(value) ? 521 | $injector.get(value) : $injector.invoke(value); 522 | }); 523 | 524 | if (angular.isDefined(template = next.template)) { 525 | if (angular.isFunction(template)) { 526 | template = template(next.params); 527 | } 528 | } else if (angular.isDefined(templateUrl = next.templateUrl)) { 529 | if (angular.isFunction(templateUrl)) { 530 | templateUrl = templateUrl(next.params); 531 | } 532 | templateUrl = $sce.getTrustedResourceUrl(templateUrl); 533 | if (angular.isDefined(templateUrl)) { 534 | next.loadedTemplateUrl = templateUrl; 535 | template = $http.get(templateUrl, {cache: $templateCache}). 536 | then(function(response) { return response.data; }); 537 | } 538 | } 539 | if (angular.isDefined(template)) { 540 | locals['$template'] = template; 541 | } 542 | return $q.all(locals); 543 | } 544 | }). 545 | // after route change 546 | then(function(locals) { 547 | if (next == $route.current) { 548 | if (next) { 549 | next.locals = locals; 550 | angular.copy(next.params, $routeParams); 551 | } 552 | $rootScope.$broadcast('$routeChangeSuccess', next, last); 553 | } 554 | }, function(error) { 555 | if (next == $route.current) { 556 | $rootScope.$broadcast('$routeChangeError', next, last, error); 557 | } 558 | }); 559 | } 560 | } 561 | 562 | 563 | /** 564 | * @returns {Object} the current active route, by matching it against the URL 565 | */ 566 | function parseRoute() { 567 | // Match a route 568 | var params, match; 569 | angular.forEach(routes, function(route, path) { 570 | if (!match && (params = switchRouteMatcher($location.path(), route))) { 571 | match = inherit(route, { 572 | params: angular.extend({}, $location.search(), params), 573 | pathParams: params}); 574 | match.$$route = route; 575 | } 576 | }); 577 | // No route matched; fallback to "otherwise" route 578 | return match || routes[null] && inherit(routes[null], {params: {}, pathParams:{}}); 579 | } 580 | 581 | /** 582 | * @returns {string} interpolation of the redirect path with the parameters 583 | */ 584 | function interpolate(string, params) { 585 | var result = []; 586 | angular.forEach((string||'').split(':'), function(segment, i) { 587 | if (i === 0) { 588 | result.push(segment); 589 | } else { 590 | var segmentMatch = segment.match(/(\w+)(.*)/); 591 | var key = segmentMatch[1]; 592 | result.push(params[key]); 593 | result.push(segmentMatch[2] || ''); 594 | delete params[key]; 595 | } 596 | }); 597 | return result.join(''); 598 | } 599 | }]; 600 | } 601 | 602 | ngRouteModule.provider('$routeParams', $RouteParamsProvider); 603 | 604 | 605 | /** 606 | * @ngdoc service 607 | * @name $routeParams 608 | * @requires $route 609 | * 610 | * @description 611 | * The `$routeParams` service allows you to retrieve the current set of route parameters. 612 | * 613 | * Requires the {@link ngRoute `ngRoute`} module to be installed. 614 | * 615 | * The route parameters are a combination of {@link ng.$location `$location`}'s 616 | * {@link ng.$location#search `search()`} and {@link ng.$location#path `path()`}. 617 | * The `path` parameters are extracted when the {@link ngRoute.$route `$route`} path is matched. 618 | * 619 | * In case of parameter name collision, `path` params take precedence over `search` params. 620 | * 621 | * The service guarantees that the identity of the `$routeParams` object will remain unchanged 622 | * (but its properties will likely change) even when a route change occurs. 623 | * 624 | * Note that the `$routeParams` are only updated *after* a route change completes successfully. 625 | * This means that you cannot rely on `$routeParams` being correct in route resolve functions. 626 | * Instead you can use `$route.current.params` to access the new route's parameters. 627 | * 628 | * @example 629 | * ```js 630 | * // Given: 631 | * // URL: http://server.com/index.html#/Chapter/1/Section/2?search=moby 632 | * // Route: /Chapter/:chapterId/Section/:sectionId 633 | * // 634 | * // Then 635 | * $routeParams ==> {chapterId:1, sectionId:2, search:'moby'} 636 | * ``` 637 | */ 638 | function $RouteParamsProvider() { 639 | this.$get = function() { return {}; }; 640 | } 641 | 642 | ngRouteModule.directive('ngView', ngViewFactory); 643 | ngRouteModule.directive('ngView', ngViewFillContentFactory); 644 | 645 | 646 | /** 647 | * @ngdoc directive 648 | * @name ngView 649 | * @restrict ECA 650 | * 651 | * @description 652 | * # Overview 653 | * `ngView` is a directive that complements the {@link ngRoute.$route $route} service by 654 | * including the rendered template of the current route into the main layout (`index.html`) file. 655 | * Every time the current route changes, the included view changes with it according to the 656 | * configuration of the `$route` service. 657 | * 658 | * Requires the {@link ngRoute `ngRoute`} module to be installed. 659 | * 660 | * @animations 661 | * enter - animation is used to bring new content into the browser. 662 | * leave - animation is used to animate existing content away. 663 | * 664 | * The enter and leave animation occur concurrently. 665 | * 666 | * @scope 667 | * @priority 400 668 | * @param {string=} onload Expression to evaluate whenever the view updates. 669 | * 670 | * @param {string=} autoscroll Whether `ngView` should call {@link ng.$anchorScroll 671 | * $anchorScroll} to scroll the viewport after the view is updated. 672 | * 673 | * - If the attribute is not set, disable scrolling. 674 | * - If the attribute is set without value, enable scrolling. 675 | * - Otherwise enable scrolling only if the `autoscroll` attribute value evaluated 676 | * as an expression yields a truthy value. 677 | * @example 678 | 681 | 682 |
683 | Choose: 684 | Moby | 685 | Moby: Ch1 | 686 | Gatsby | 687 | Gatsby: Ch4 | 688 | Scarlet Letter
689 | 690 |
691 |
692 |
693 |
694 | 695 |
$location.path() = {{main.$location.path()}}
696 |
$route.current.templateUrl = {{main.$route.current.templateUrl}}
697 |
$route.current.params = {{main.$route.current.params}}
698 |
$route.current.scope.name = {{main.$route.current.scope.name}}
699 |
$routeParams = {{main.$routeParams}}
700 |
701 |
702 | 703 | 704 |
705 | controller: {{book.name}}
706 | Book Id: {{book.params.bookId}}
707 |
708 |
709 | 710 | 711 |
712 | controller: {{chapter.name}}
713 | Book Id: {{chapter.params.bookId}}
714 | Chapter Id: {{chapter.params.chapterId}} 715 |
716 |
717 | 718 | 719 | .view-animate-container { 720 | position:relative; 721 | height:100px!important; 722 | position:relative; 723 | background:white; 724 | border:1px solid black; 725 | height:40px; 726 | overflow:hidden; 727 | } 728 | 729 | .view-animate { 730 | padding:10px; 731 | } 732 | 733 | .view-animate.ng-enter, .view-animate.ng-leave { 734 | -webkit-transition:all cubic-bezier(0.250, 0.460, 0.450, 0.940) 1.5s; 735 | transition:all cubic-bezier(0.250, 0.460, 0.450, 0.940) 1.5s; 736 | 737 | display:block; 738 | width:100%; 739 | border-left:1px solid black; 740 | 741 | position:absolute; 742 | top:0; 743 | left:0; 744 | right:0; 745 | bottom:0; 746 | padding:10px; 747 | } 748 | 749 | .view-animate.ng-enter { 750 | left:100%; 751 | } 752 | .view-animate.ng-enter.ng-enter-active { 753 | left:0; 754 | } 755 | .view-animate.ng-leave.ng-leave-active { 756 | left:-100%; 757 | } 758 | 759 | 760 | 761 | angular.module('ngViewExample', ['ngRoute', 'ngAnimate']) 762 | .config(['$routeProvider', '$locationProvider', 763 | function($routeProvider, $locationProvider) { 764 | $routeProvider 765 | .when('/Book/:bookId', { 766 | templateUrl: 'book.html', 767 | controller: 'BookCtrl', 768 | controllerAs: 'book' 769 | }) 770 | .when('/Book/:bookId/ch/:chapterId', { 771 | templateUrl: 'chapter.html', 772 | controller: 'ChapterCtrl', 773 | controllerAs: 'chapter' 774 | }); 775 | 776 | // configure html5 to get links working on jsfiddle 777 | $locationProvider.html5Mode(true); 778 | }]) 779 | .controller('MainCtrl', ['$route', '$routeParams', '$location', 780 | function($route, $routeParams, $location) { 781 | this.$route = $route; 782 | this.$location = $location; 783 | this.$routeParams = $routeParams; 784 | }]) 785 | .controller('BookCtrl', ['$routeParams', function($routeParams) { 786 | this.name = "BookCtrl"; 787 | this.params = $routeParams; 788 | }]) 789 | .controller('ChapterCtrl', ['$routeParams', function($routeParams) { 790 | this.name = "ChapterCtrl"; 791 | this.params = $routeParams; 792 | }]); 793 | 794 | 795 | 796 | 797 | it('should load and compile correct template', function() { 798 | element(by.linkText('Moby: Ch1')).click(); 799 | var content = element(by.css('[ng-view]')).getText(); 800 | expect(content).toMatch(/controller\: ChapterCtrl/); 801 | expect(content).toMatch(/Book Id\: Moby/); 802 | expect(content).toMatch(/Chapter Id\: 1/); 803 | 804 | element(by.partialLinkText('Scarlet')).click(); 805 | 806 | content = element(by.css('[ng-view]')).getText(); 807 | expect(content).toMatch(/controller\: BookCtrl/); 808 | expect(content).toMatch(/Book Id\: Scarlet/); 809 | }); 810 | 811 |
812 | */ 813 | 814 | 815 | /** 816 | * @ngdoc event 817 | * @name ngView#$viewContentLoaded 818 | * @eventType emit on the current ngView scope 819 | * @description 820 | * Emitted every time the ngView content is reloaded. 821 | */ 822 | ngViewFactory.$inject = ['$route', '$anchorScroll', '$animate']; 823 | function ngViewFactory( $route, $anchorScroll, $animate) { 824 | return { 825 | restrict: 'ECA', 826 | terminal: true, 827 | priority: 400, 828 | transclude: 'element', 829 | link: function(scope, $element, attr, ctrl, $transclude) { 830 | var currentScope, 831 | currentElement, 832 | previousElement, 833 | autoScrollExp = attr.autoscroll, 834 | onloadExp = attr.onload || ''; 835 | 836 | scope.$on('$routeChangeSuccess', update); 837 | update(); 838 | 839 | function cleanupLastView() { 840 | if(previousElement) { 841 | previousElement.remove(); 842 | previousElement = null; 843 | } 844 | if(currentScope) { 845 | currentScope.$destroy(); 846 | currentScope = null; 847 | } 848 | if(currentElement) { 849 | $animate.leave(currentElement, function() { 850 | previousElement = null; 851 | }); 852 | previousElement = currentElement; 853 | currentElement = null; 854 | } 855 | } 856 | 857 | function update() { 858 | var locals = $route.current && $route.current.locals, 859 | template = locals && locals.$template; 860 | 861 | if (angular.isDefined(template)) { 862 | var newScope = scope.$new(); 863 | var current = $route.current; 864 | 865 | // Note: This will also link all children of ng-view that were contained in the original 866 | // html. If that content contains controllers, ... they could pollute/change the scope. 867 | // However, using ng-view on an element with additional content does not make sense... 868 | // Note: We can't remove them in the cloneAttchFn of $transclude as that 869 | // function is called before linking the content, which would apply child 870 | // directives to non existing elements. 871 | var clone = $transclude(newScope, function(clone) { 872 | $animate.enter(clone, null, currentElement || $element, function onNgViewEnter () { 873 | if (angular.isDefined(autoScrollExp) 874 | && (!autoScrollExp || scope.$eval(autoScrollExp))) { 875 | $anchorScroll(); 876 | } 877 | }); 878 | cleanupLastView(); 879 | }); 880 | 881 | currentElement = clone; 882 | currentScope = current.scope = newScope; 883 | currentScope.$emit('$viewContentLoaded'); 884 | currentScope.$eval(onloadExp); 885 | } else { 886 | cleanupLastView(); 887 | } 888 | } 889 | } 890 | }; 891 | } 892 | 893 | // This directive is called during the $transclude call of the first `ngView` directive. 894 | // It will replace and compile the content of the element with the loaded template. 895 | // We need this directive so that the element content is already filled when 896 | // the link function of another directive on the same element as ngView 897 | // is called. 898 | ngViewFillContentFactory.$inject = ['$compile', '$controller', '$route']; 899 | function ngViewFillContentFactory($compile, $controller, $route) { 900 | return { 901 | restrict: 'ECA', 902 | priority: -400, 903 | link: function(scope, $element) { 904 | var current = $route.current, 905 | locals = current.locals; 906 | 907 | $element.html(locals.$template); 908 | 909 | var link = $compile($element.contents()); 910 | 911 | if (current.controller) { 912 | locals.$scope = scope; 913 | var controller = $controller(current.controller, locals); 914 | if (current.controllerAs) { 915 | scope[current.controllerAs] = controller; 916 | } 917 | $element.data('$ngControllerController', controller); 918 | $element.children().data('$ngControllerController', controller); 919 | } 920 | 921 | link(scope); 922 | } 923 | }; 924 | } 925 | 926 | 927 | })(window, window.angular); 928 | -------------------------------------------------------------------------------- /bower_components/angular-route/angular-route.min.js: -------------------------------------------------------------------------------- 1 | /* 2 | AngularJS v1.2.16 3 | (c) 2010-2014 Google, Inc. http://angularjs.org 4 | License: MIT 5 | */ 6 | (function(n,e,A){'use strict';function x(s,g,k){return{restrict:"ECA",terminal:!0,priority:400,transclude:"element",link:function(a,c,b,f,w){function y(){p&&(p.remove(),p=null);h&&(h.$destroy(),h=null);l&&(k.leave(l,function(){p=null}),p=l,l=null)}function v(){var b=s.current&&s.current.locals;if(e.isDefined(b&&b.$template)){var b=a.$new(),d=s.current;l=w(b,function(d){k.enter(d,null,l||c,function(){!e.isDefined(t)||t&&!a.$eval(t)||g()});y()});h=d.scope=b;h.$emit("$viewContentLoaded");h.$eval(u)}else y()} 7 | var h,l,p,t=b.autoscroll,u=b.onload||"";a.$on("$routeChangeSuccess",v);v()}}}function z(e,g,k){return{restrict:"ECA",priority:-400,link:function(a,c){var b=k.current,f=b.locals;c.html(f.$template);var w=e(c.contents());b.controller&&(f.$scope=a,f=g(b.controller,f),b.controllerAs&&(a[b.controllerAs]=f),c.data("$ngControllerController",f),c.children().data("$ngControllerController",f));w(a)}}}n=e.module("ngRoute",["ng"]).provider("$route",function(){function s(a,c){return e.extend(new (e.extend(function(){}, 8 | {prototype:a})),c)}function g(a,e){var b=e.caseInsensitiveMatch,f={originalPath:a,regexp:a},k=f.keys=[];a=a.replace(/([().])/g,"\\$1").replace(/(\/)?:(\w+)([\?\*])?/g,function(a,e,b,c){a="?"===c?c:null;c="*"===c?c:null;k.push({name:b,optional:!!a});e=e||"";return""+(a?"":e)+"(?:"+(a?e:"")+(c&&"(.+?)"||"([^/]+)")+(a||"")+")"+(a||"")}).replace(/([\/$\*])/g,"\\$1");f.regexp=RegExp("^"+a+"$",b?"i":"");return f}var k={};this.when=function(a,c){k[a]=e.extend({reloadOnSearch:!0},c,a&&g(a,c));if(a){var b= 9 | "/"==a[a.length-1]?a.substr(0,a.length-1):a+"/";k[b]=e.extend({redirectTo:a},g(b,c))}return this};this.otherwise=function(a){this.when(null,a);return this};this.$get=["$rootScope","$location","$routeParams","$q","$injector","$http","$templateCache","$sce",function(a,c,b,f,g,n,v,h){function l(){var d=p(),m=r.current;if(d&&m&&d.$$route===m.$$route&&e.equals(d.pathParams,m.pathParams)&&!d.reloadOnSearch&&!u)m.params=d.params,e.copy(m.params,b),a.$broadcast("$routeUpdate",m);else if(d||m)u=!1,a.$broadcast("$routeChangeStart", 10 | d,m),(r.current=d)&&d.redirectTo&&(e.isString(d.redirectTo)?c.path(t(d.redirectTo,d.params)).search(d.params).replace():c.url(d.redirectTo(d.pathParams,c.path(),c.search())).replace()),f.when(d).then(function(){if(d){var a=e.extend({},d.resolve),c,b;e.forEach(a,function(d,c){a[c]=e.isString(d)?g.get(d):g.invoke(d)});e.isDefined(c=d.template)?e.isFunction(c)&&(c=c(d.params)):e.isDefined(b=d.templateUrl)&&(e.isFunction(b)&&(b=b(d.params)),b=h.getTrustedResourceUrl(b),e.isDefined(b)&&(d.loadedTemplateUrl= 11 | b,c=n.get(b,{cache:v}).then(function(a){return a.data})));e.isDefined(c)&&(a.$template=c);return f.all(a)}}).then(function(c){d==r.current&&(d&&(d.locals=c,e.copy(d.params,b)),a.$broadcast("$routeChangeSuccess",d,m))},function(c){d==r.current&&a.$broadcast("$routeChangeError",d,m,c)})}function p(){var a,b;e.forEach(k,function(f,k){var q;if(q=!b){var g=c.path();q=f.keys;var l={};if(f.regexp)if(g=f.regexp.exec(g)){for(var h=1,p=g.length;h` to your `index.html`: 16 | 17 | ```html 18 | 19 | ``` 20 | 21 | ## Documentation 22 | 23 | Documentation is available on the 24 | [AngularJS docs site](http://docs.angularjs.org/). 25 | 26 | ## License 27 | 28 | The MIT License 29 | 30 | Copyright (c) 2010-2012 Google, Inc. http://angularjs.org 31 | 32 | Permission is hereby granted, free of charge, to any person obtaining a copy 33 | of this software and associated documentation files (the "Software"), to deal 34 | in the Software without restriction, including without limitation the rights 35 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 36 | copies of the Software, and to permit persons to whom the Software is 37 | furnished to do so, subject to the following conditions: 38 | 39 | The above copyright notice and this permission notice shall be included in 40 | all copies or substantial portions of the Software. 41 | 42 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 43 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 44 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 45 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 46 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 47 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 48 | THE SOFTWARE. 49 | -------------------------------------------------------------------------------- /bower_components/angular/angular-csp.css: -------------------------------------------------------------------------------- 1 | /* Include this file in your html if you are using the CSP mode. */ 2 | 3 | @charset "UTF-8"; 4 | 5 | [ng\:cloak], [ng-cloak], [data-ng-cloak], [x-ng-cloak], 6 | .ng-cloak, .x-ng-cloak, 7 | .ng-hide { 8 | display: none !important; 9 | } 10 | 11 | ng\:form { 12 | display: block; 13 | } 14 | 15 | .ng-animate-block-transitions { 16 | transition:0s all!important; 17 | -webkit-transition:0s all!important; 18 | } 19 | -------------------------------------------------------------------------------- /bower_components/angular/angular.min.js.gzip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/possan/webapi-player-example/47d3e78fcdd8baa8c89b0e760e0d72fabf9b1386/bower_components/angular/angular.min.js.gzip -------------------------------------------------------------------------------- /bower_components/angular/bower.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "angular", 3 | "version": "1.2.16", 4 | "main": "./angular.js", 5 | "dependencies": { 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /callback.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 30 | 31 | 32 | 33 | 34 | -------------------------------------------------------------------------------- /controllers/album.js: -------------------------------------------------------------------------------- 1 | (function() { 2 | 3 | var module = angular.module('PlayerApp'); 4 | 5 | module.controller('AlbumController', function($scope, $rootScope, API, PlayQueue, $routeParams) { 6 | $scope.album = $routeParams.album; 7 | $scope.data = null; 8 | $scope.release_year = ''; 9 | $scope.total_duration = 0; 10 | $scope.num_discs = 0; 11 | $scope.tracks = []; 12 | 13 | API.getAlbum($scope.album).then(function(album) { 14 | console.log('got album', album); 15 | $scope.data = album; 16 | 17 | 18 | $scope.release_year = ''; 19 | 20 | if (album.release_date) { 21 | $scope.release_year = album.release_date.substring(0, 4); // så fult! 22 | } 23 | 24 | }); 25 | 26 | API.getAlbumTracks($scope.album).then(function(tracks) { 27 | console.log('got album tracks', tracks); 28 | 29 | // split into discs 30 | var discs = []; 31 | var disc = { disc_number: 1, tracks: [] }; 32 | var tot = 0; 33 | tracks.items.forEach(function(track) { 34 | tot += track.duration_ms; 35 | if (track.disc_number != disc.disc_number) { 36 | discs.push(disc); 37 | disc = { disc_number: track.disc_number, tracks: [] }; 38 | } 39 | disc.tracks.push(track); 40 | 41 | track.popularity = 0; 42 | }); 43 | discs.push(disc); 44 | console.log('discs', discs); 45 | $scope.discs = discs; 46 | $scope.tracks = tracks.items; 47 | $scope.num_discs = discs.length; 48 | $scope.total_duration = tot; 49 | 50 | // find out if they are in the user's collection 51 | var ids = $scope.tracks.map(function(track) { 52 | return track.id; 53 | }); 54 | 55 | 56 | API.getTracks(ids).then(function(results) { 57 | results.tracks.forEach(function(result, index) { 58 | $scope.tracks[index].popularity = result.popularity; 59 | }); 60 | }); 61 | 62 | API.containsUserTracks(ids).then(function(results) { 63 | results.forEach(function(result, index) { 64 | $scope.tracks[index].inYourMusic = result; 65 | }); 66 | }); 67 | 68 | }); 69 | 70 | $scope.currenttrack = PlayQueue.getCurrent(); 71 | $rootScope.$on('playqueuechanged', function() { 72 | $scope.currenttrack = PlayQueue.getCurrent(); 73 | }); 74 | 75 | $scope.play = function(trackuri) { 76 | var trackuris = $scope.tracks.map(function(track) { 77 | return track.uri; 78 | }); 79 | PlayQueue.clear(); 80 | PlayQueue.enqueueList(trackuris); 81 | PlayQueue.playFrom(trackuris.indexOf(trackuri)); 82 | }; 83 | 84 | $scope.playall = function() { 85 | var trackuris = $scope.tracks.map(function(track) { 86 | return track.uri; 87 | }); 88 | PlayQueue.clear(); 89 | PlayQueue.enqueueList(trackuris); 90 | PlayQueue.playFrom(0); 91 | }; 92 | 93 | $scope.toggleFromYourMusic = function(index) { 94 | if ($scope.tracks[index].inYourMusic) { 95 | API.removeFromMyTracks([$scope.tracks[index].id]).then(function(response) { 96 | $scope.tracks[index].inYourMusic = false; 97 | }); 98 | } else { 99 | API.addToMyTracks([$scope.tracks[index].id]).then(function(response) { 100 | $scope.tracks[index].inYourMusic = true; 101 | }); 102 | } 103 | }; 104 | 105 | }); 106 | 107 | })(); 108 | -------------------------------------------------------------------------------- /controllers/artist.js: -------------------------------------------------------------------------------- 1 | (function() { 2 | 3 | var module = angular.module('PlayerApp'); 4 | 5 | module.controller('ArtistController', function($scope, $rootScope, API, PlayQueue, $routeParams, Auth) { 6 | $scope.artist = $routeParams.artist; 7 | $scope.data = null; 8 | $scope.discog = []; 9 | $scope.albums = []; 10 | $scope.singles = []; 11 | $scope.appearson = []; 12 | 13 | $scope.currenttrack = PlayQueue.getCurrent(); 14 | $scope.isFollowing = false; 15 | $scope.isFollowHovered = false; 16 | $rootScope.$on('playqueuechanged', function() { 17 | $scope.currenttrack = PlayQueue.getCurrent(); 18 | }); 19 | 20 | API.getArtist($scope.artist).then(function(artist) { 21 | console.log('got artist', artist); 22 | $scope.data = artist; 23 | }); 24 | 25 | API.getArtistTopTracks($scope.artist, Auth.getUserCountry()).then(function(toptracks) { 26 | console.log('got artist', toptracks); 27 | $scope.toptracks = toptracks.tracks; 28 | 29 | var ids = $scope.toptracks.map(function(track) { 30 | return track.id; 31 | }); 32 | 33 | API.containsUserTracks(ids).then(function(results) { 34 | results.forEach(function(result, index) { 35 | $scope.toptracks[index].inYourMusic = result; 36 | }); 37 | }); 38 | }); 39 | 40 | API.getArtistAlbums($scope.artist, Auth.getUserCountry()).then(function(albums) { 41 | console.log('got artist albums', albums); 42 | $scope.albums = []; 43 | $scope.singles = []; 44 | $scope.appearson = []; 45 | albums.items.forEach(function(album) { 46 | console.log(album); 47 | if (album.album_type == 'album') { 48 | $scope.albums.push(album); 49 | } 50 | if (album.album_type == 'single') { 51 | $scope.singles.push(album); 52 | } 53 | if (album.album_type == 'appears-on') { 54 | $scope.appearson.push(album); 55 | } 56 | }) 57 | }); 58 | 59 | API.isFollowing($scope.artist, "artist").then(function(booleans) { 60 | console.log("Got following status for artist: " + booleans[0]); 61 | $scope.isFollowing = booleans[0]; 62 | }); 63 | 64 | $scope.playtoptrack = function(trackuri) { 65 | var trackuris = $scope.toptracks.map(function(track) { 66 | return track.uri; 67 | }); 68 | PlayQueue.clear(); 69 | PlayQueue.enqueueList(trackuris); 70 | PlayQueue.playFrom(trackuris.indexOf(trackuri)); 71 | }; 72 | 73 | $scope.playall = function(trackuri) { 74 | var trackuris = $scope.toptracks.map(function(track) { 75 | return track.uri; 76 | }); 77 | PlayQueue.clear(); 78 | PlayQueue.enqueueList(trackuris); 79 | PlayQueue.playFrom(0); 80 | }; 81 | 82 | $scope.follow = function(isFollowing) { 83 | if (isFollowing) { 84 | API.unfollow($scope.artist, "artist").then(function() { 85 | $scope.isFollowing = false; 86 | $scope.data.followers.total--; 87 | }); 88 | } else { 89 | API.follow($scope.artist, "artist").then(function() { 90 | $scope.isFollowing = true; 91 | $scope.data.followers.total++; 92 | }); 93 | } 94 | }; 95 | 96 | $scope.toggleFromYourMusic = function(index) { 97 | if ($scope.toptracks[index].inYourMusic) { 98 | API.removeFromMyTracks([$scope.toptracks[index].id]).then(function(response) { 99 | $scope.toptracks[index].inYourMusic = false; 100 | }); 101 | } else { 102 | API.addToMyTracks([$scope.toptracks[index].id]).then(function(response) { 103 | $scope.toptracks[index].inYourMusic = true; 104 | }); 105 | } 106 | }; 107 | 108 | }); 109 | 110 | })(); 111 | -------------------------------------------------------------------------------- /controllers/browse.js: -------------------------------------------------------------------------------- 1 | (function() { 2 | 3 | var module = angular.module('PlayerApp'); 4 | 5 | module.controller('BrowseController', function($scope, API, Auth, $routeParams) { 6 | 7 | function pad(number) { 8 | if ( number < 10 ) { 9 | return '0' + number; 10 | } 11 | return number; 12 | } 13 | 14 | /** 15 | * Returns an ISO string containing the local time for the user, 16 | * clearing minutes and seconds to improve caching 17 | * @param Date date The date to format 18 | * @return string The formatted date 19 | */ 20 | function isoString(date) { 21 | return date.getUTCFullYear() + 22 | '-' + pad( date.getUTCMonth() + 1 ) + 23 | '-' + pad( date.getUTCDate() ) + 24 | 'T' + pad( date.getHours() ) + 25 | ':' + pad( 0 ) + 26 | ':' + pad( 0 ) 27 | } 28 | 29 | API.getFeaturedPlaylists(Auth.getUserCountry(), isoString(new Date())).then(function(results) { 30 | $scope.featuredPlaylists = results.playlists.items; 31 | $scope.message = results.message; 32 | }); 33 | 34 | API.getNewReleases(Auth.getUserCountry()).then(function(results) { 35 | // @todo: description, follower count 36 | $scope.newReleases = results.albums.items; 37 | }); 38 | 39 | API.getBrowseCategories().then(function(results) { 40 | $scope.genresMoods = results.categories.items; 41 | }); 42 | }); 43 | 44 | })(); 45 | -------------------------------------------------------------------------------- /controllers/browsecategory.js: -------------------------------------------------------------------------------- 1 | (function() { 2 | 3 | var module = angular.module('PlayerApp'); 4 | 5 | module.controller('BrowseCategoryController', function($scope, API, $routeParams, Auth) { 6 | $scope.categoryname = ''; 7 | 8 | API.getBrowseCategory($routeParams.categoryid).then(function(result) { 9 | $scope.categoryname = result.name; 10 | }); 11 | API.getBrowseCategoryPlaylists($routeParams.categoryid, Auth.getUserCountry()).then(function(results) { 12 | $scope.playlists = results.playlists.items; 13 | }); 14 | }); 15 | 16 | })(); 17 | -------------------------------------------------------------------------------- /controllers/home.js: -------------------------------------------------------------------------------- 1 | (function() { 2 | 3 | var module = angular.module('PlayerApp'); 4 | 5 | module.controller('HomeController', function($scope, $routeParams) { 6 | $scope.id = $routeParams.id; 7 | }); 8 | 9 | })(); 10 | -------------------------------------------------------------------------------- /controllers/login.js: -------------------------------------------------------------------------------- 1 | (function() { 2 | 3 | var module = angular.module('PlayerApp'); 4 | 5 | module.controller('LoginController', function($scope, Auth) { 6 | $scope.isLoggedIn = false; 7 | 8 | $scope.login = function() { 9 | // do login! 10 | console.log('do login...'); 11 | 12 | Auth.openLogin(); 13 | // $scope.$emit('login'); 14 | } 15 | }); 16 | 17 | })(); 18 | -------------------------------------------------------------------------------- /controllers/player.js: -------------------------------------------------------------------------------- 1 | (function() { 2 | 3 | var module = angular.module('PlayerApp'); 4 | 5 | module.controller('PlayerController', function($scope, $rootScope, Auth, API, PlayQueue, Playback, $location) { 6 | $scope.view = 'welcome'; 7 | $scope.profileUsername = Auth.getUsername(); 8 | $scope.playlists = []; 9 | $scope.playing = false; 10 | $scope.progress = 0; 11 | $scope.duration = 4000; 12 | $scope.trackdata = null; 13 | $scope.currenttrack = ''; 14 | 15 | function updatePlaylists() { 16 | if ($scope.profileUsername != '') { 17 | API.getPlaylists(Auth.getUsername()).then(function(items) { 18 | $scope.playlists = items.map(function(pl) { 19 | return { 20 | id: pl.id, 21 | name: pl.name, 22 | uri: pl.uri, 23 | username: pl.owner.id, 24 | collaborative: pl.collaborative, 25 | 'public': pl['public'] 26 | }; 27 | }); 28 | }); 29 | } 30 | } 31 | 32 | updatePlaylists(); 33 | 34 | // subscribe to an event 35 | $rootScope.$on('playlistsubscriptionchange', function() { 36 | updatePlaylists(); 37 | }); 38 | 39 | $scope.logout = function() { 40 | // do login! 41 | console.log('do logout...'); 42 | Auth.setUsername(''); 43 | Auth.setAccessToken('', 0); 44 | $scope.$emit('logout'); 45 | }; 46 | 47 | $scope.resume = function() { 48 | Playback.resume(); 49 | }; 50 | 51 | $scope.pause = function() { 52 | Playback.pause(); 53 | }; 54 | 55 | $scope.next = function() { 56 | PlayQueue.next(); 57 | Playback.startPlaying(PlayQueue.getCurrent()); 58 | }; 59 | 60 | $scope.prev = function() { 61 | PlayQueue.prev(); 62 | Playback.startPlaying(PlayQueue.getCurrent()); 63 | }; 64 | 65 | $scope.queue = function(trackuri) { 66 | PlayQueue.enqueue(trackuri); 67 | }; 68 | 69 | $scope.showhome = function() { 70 | console.log('load home view'); 71 | }; 72 | 73 | $scope.showplayqueue = function() { 74 | console.log('load playqueue view'); 75 | }; 76 | 77 | $scope.showplaylist = function(playlisturi) { 78 | console.log('load playlist view', playlisturi); 79 | }; 80 | 81 | $scope.query = ''; 82 | 83 | $scope.loadsearch = function() { 84 | console.log('search for', $scope.query); 85 | $location.path('/search').search({ q: $scope.query }).replace(); 86 | }; 87 | 88 | 89 | $scope.volume = Playback.getVolume(); 90 | 91 | $scope.changevolume = function() { 92 | Playback.setVolume($scope.volume); 93 | }; 94 | 95 | $scope.changeprogress = function() { 96 | Playback.setProgress($scope.progress); 97 | }; 98 | 99 | $rootScope.$on('login', function() { 100 | $scope.profileUsername = Auth.getUsername(); 101 | updatePlaylists(); 102 | }); 103 | 104 | $rootScope.$on('playqueuechanged', function() { 105 | console.log('PlayerController: play queue changed.'); 106 | // $scope.duration = Playback.getDuration(); 107 | }); 108 | 109 | $rootScope.$on('playerchanged', function() { 110 | console.log('PlayerController: player changed.'); 111 | $scope.currenttrack = Playback.getTrack(); 112 | $scope.playing = Playback.isPlaying(); 113 | $scope.trackdata = Playback.getTrackData(); 114 | }); 115 | 116 | $rootScope.$on('endtrack', function() { 117 | console.log('PlayerController: end track.'); 118 | $scope.currenttrack = Playback.getTrack(); 119 | $scope.trackdata = Playback.getTrackData(); 120 | $scope.playing = Playback.isPlaying(); 121 | PlayQueue.next(); 122 | Playback.startPlaying(PlayQueue.getCurrent()); 123 | $scope.duration = Playback.getDuration(); 124 | }); 125 | 126 | $rootScope.$on('trackprogress', function() { 127 | console.log('PlayerController: trackprogress.'); 128 | $scope.progress = Playback.getProgress(); 129 | $scope.duration = Playback.getDuration(); 130 | }); 131 | }); 132 | 133 | })(); 134 | -------------------------------------------------------------------------------- /controllers/playlist.js: -------------------------------------------------------------------------------- 1 | (function() { 2 | 3 | var module = angular.module('PlayerApp'); 4 | 5 | module.controller('PlaylistController', function($scope, $rootScope, API, PlayQueue, $routeParams, Auth, $sce) { 6 | $scope.playlist = $routeParams.playlist; 7 | $scope.username = $routeParams.username; 8 | $scope.name = ''; 9 | $scope.tracks = []; 10 | $scope.data = null; 11 | $scope.total_duration = 0; 12 | 13 | $scope.currenttrack = PlayQueue.getCurrent(); 14 | $scope.isFollowing = false; 15 | $scope.isFollowHovered = false; 16 | 17 | $rootScope.$on('playqueuechanged', function() { 18 | $scope.currenttrack = PlayQueue.getCurrent(); 19 | }); 20 | 21 | API.getPlaylist($scope.username, $scope.playlist).then(function(list) { 22 | console.log('got playlist', list); 23 | $scope.name = list.name; 24 | $scope.data = list; 25 | $scope.playlistDescription = $sce.trustAsHtml(list.description); 26 | }); 27 | 28 | API.getPlaylistTracks($scope.username, $scope.playlist).then(function(list) { 29 | console.log('got playlist tracks', list); 30 | var tot = 0; 31 | list.items.forEach(function(track) { 32 | tot += track.track.duration_ms; 33 | }); 34 | $scope.tracks = list.items; 35 | console.log('tot', tot); 36 | $scope.total_duration = tot; 37 | 38 | // find out if they are in the user's collection 39 | var ids = $scope.tracks.map(function(track) { 40 | return track.track.id; 41 | }); 42 | 43 | var i, j, temparray, chunk = 20; 44 | for (i = 0, j = ids.length; i < j; i += chunk) { 45 | temparray = ids.slice(i, i + chunk); 46 | var firstIndex = i; 47 | (function(firstIndex){ 48 | API.containsUserTracks(temparray).then(function(results) { 49 | results.forEach(function(result, index) { 50 | $scope.tracks[firstIndex + index].track.inYourMusic = result; 51 | }); 52 | }); 53 | })(firstIndex); 54 | } 55 | }); 56 | 57 | API.isFollowingPlaylist($scope.username, $scope.playlist).then(function(booleans) { 58 | console.log("Got following status for playlist: " + booleans[0]); 59 | $scope.isFollowing = booleans[0]; 60 | }); 61 | 62 | $scope.follow = function(isFollowing) { 63 | if (isFollowing) { 64 | API.unfollowPlaylist($scope.username, $scope.playlist).then(function() { 65 | $scope.isFollowing = false; 66 | $rootScope.$emit('playlistsubscriptionchange'); 67 | }); 68 | } else { 69 | API.followPlaylist($scope.username, $scope.playlist).then(function() { 70 | $scope.isFollowing = true; 71 | $rootScope.$emit('playlistsubscriptionchange'); 72 | }); 73 | } 74 | }; 75 | 76 | $scope.play = function(trackuri) { 77 | var trackuris = $scope.tracks.map(function(track) { 78 | return track.track.uri; 79 | }); 80 | PlayQueue.clear(); 81 | PlayQueue.enqueueList(trackuris); 82 | PlayQueue.playFrom(trackuris.indexOf(trackuri)); 83 | }; 84 | 85 | $scope.playall = function() { 86 | var trackuris = $scope.tracks.map(function(track) { 87 | return track.track.uri; 88 | }); 89 | PlayQueue.clear(); 90 | PlayQueue.enqueueList(trackuris); 91 | PlayQueue.playFrom(0); 92 | }; 93 | 94 | $scope.toggleFromYourMusic = function(index) { 95 | if ($scope.tracks[index].track.inYourMusic) { 96 | API.removeFromMyTracks([$scope.tracks[index].track.id]).then(function(response) { 97 | $scope.tracks[index].track.inYourMusic = false; 98 | }); 99 | } else { 100 | API.addToMyTracks([$scope.tracks[index].track.id]).then(function(response) { 101 | $scope.tracks[index].track.inYourMusic = true; 102 | }); 103 | } 104 | }; 105 | 106 | $scope.menuOptionsPlaylistTrack = function() { 107 | if ($scope.username === Auth.getUsername()) { 108 | return [[ 109 | 'Delete', 110 | function ($itemScope) { 111 | var position = $itemScope.$index; 112 | API.removeTrackFromPlaylist( 113 | $scope.username, 114 | $scope.playlist, 115 | $itemScope.t.track, position).then(function() { 116 | $scope.tracks.splice(position, 1); 117 | }); 118 | }]] 119 | } else { 120 | return null; 121 | } 122 | }; 123 | 124 | }); 125 | 126 | })(); 127 | -------------------------------------------------------------------------------- /controllers/playqueue.js: -------------------------------------------------------------------------------- 1 | (function() { 2 | 3 | var module = angular.module('PlayerApp'); 4 | 5 | module.controller('PlayQueueController', function($scope, $rootScope, $routeParams, PlayQueue) { 6 | 7 | function refresh() { 8 | $scope.current = PlayQueue.getCurrent(); 9 | $scope.position = PlayQueue.getPosition(); 10 | $scope.queue = PlayQueue.getQueue(); 11 | } 12 | 13 | $rootScope.$on('playqueuechanged', function() { 14 | refresh(); 15 | }); 16 | 17 | refresh(); 18 | }); 19 | 20 | })(); 21 | -------------------------------------------------------------------------------- /controllers/searchresults.js: -------------------------------------------------------------------------------- 1 | (function() { 2 | 3 | var module = angular.module('PlayerApp'); 4 | 5 | module.controller('SearchResultsController', function($scope, API, $location, PlayQueue, $routeParams) { 6 | $scope.query = $location.search().q || ''; 7 | $scope.tracks = []; 8 | 9 | API.getSearchResults($scope.query).then(function(results) { 10 | console.log('got search results', results); 11 | $scope.tracks = results.tracks.items; 12 | $scope.playlists = results.playlists.items; 13 | 14 | // find out if they are in the user's collection 15 | var ids = $scope.tracks.map(function(track) { 16 | return track.id; 17 | }); 18 | 19 | API.containsUserTracks(ids).then(function(results) { 20 | results.forEach(function(result, index) { 21 | $scope.tracks[index].inYourMusic = result; 22 | }); 23 | }); 24 | 25 | }); 26 | 27 | $scope.play = function(trackuri) { 28 | var trackuris = $scope.tracks.map(function(track) { 29 | return track.uri; 30 | }); 31 | PlayQueue.clear(); 32 | PlayQueue.enqueueList(trackuris); 33 | PlayQueue.playFrom(trackuris.indexOf(trackuri)); 34 | }; 35 | 36 | $scope.toggleFromYourMusic = function(index) { 37 | if ($scope.tracks[index].inYourMusic) { 38 | API.removeFromMyTracks([$scope.tracks[index].id]).then(function(response) { 39 | $scope.tracks[index].inYourMusic = false; 40 | }); 41 | } else { 42 | API.addToMyTracks([$scope.tracks[index].id]).then(function(response) { 43 | $scope.tracks[index].inYourMusic = true; 44 | }); 45 | } 46 | }; 47 | }); 48 | 49 | })(); 50 | -------------------------------------------------------------------------------- /controllers/user.js: -------------------------------------------------------------------------------- 1 | (function() { 2 | 3 | var module = angular.module('PlayerApp'); 4 | 5 | module.controller('UserController', function($scope, $routeParams, API) { 6 | $scope.profileUsername = $routeParams.username; 7 | $scope.data = null; 8 | $scope.playlistError = null; 9 | $scope.isFollowing = false; 10 | $scope.isFollowHovered = false; 11 | 12 | API.getUser($scope.profileUsername).then(function(user) { 13 | console.log('got user', user); 14 | $scope.data = user; 15 | }); 16 | 17 | API.getPlaylists($scope.profileUsername).then(function(userplaylists){ 18 | console.log('got user playlists', userplaylists); 19 | $scope.userplaylists = userplaylists; 20 | }, function(reason){ 21 | console.log("got error", reason); 22 | $scope.playlistError = true; 23 | }); 24 | 25 | API.isFollowing($scope.profileUsername, "user").then(function(booleans) { 26 | console.log("Got following status for user: " + booleans[0]); 27 | $scope.isFollowing = booleans[0]; 28 | }); 29 | 30 | $scope.follow = function(isFollowing) { 31 | if (isFollowing) { 32 | API.unfollow($scope.profileUsername, "user").then(function() { 33 | $scope.isFollowing = false; 34 | $scope.data.followers.total--; 35 | }); 36 | } else { 37 | API.follow($scope.profileUsername, "user").then(function() { 38 | $scope.isFollowing = true; 39 | $scope.data.followers.total++; 40 | }); 41 | } 42 | }; 43 | 44 | }); 45 | 46 | })(); 47 | -------------------------------------------------------------------------------- /controllers/usertracks.js: -------------------------------------------------------------------------------- 1 | (function() { 2 | 3 | var module = angular.module('PlayerApp'); 4 | 5 | module.controller('UserTracksController', function($scope, $routeParams, API, PlayQueue) { 6 | $scope.username = $routeParams.username; 7 | $scope.tracks = []; 8 | 9 | API.getMyTracks().then(function(tracks) { 10 | console.log('got user tracks', tracks); 11 | $scope.tracks = tracks.items; 12 | }); 13 | 14 | $scope.play = function(trackuri) { 15 | var trackuris = $scope.tracks.map(function(track) { 16 | return track.track.uri; 17 | }); 18 | PlayQueue.clear(); 19 | PlayQueue.enqueueList(trackuris); 20 | PlayQueue.playFrom(trackuris.indexOf(trackuri)); 21 | }; 22 | 23 | $scope.removeFromYourMusic = function(index) { 24 | API.removeFromMyTracks([$scope.tracks[index].track.id]).then(function(response) { 25 | $scope.tracks.splice(index, 1); 26 | }); 27 | }; 28 | 29 | }); 30 | 31 | })(); 32 | -------------------------------------------------------------------------------- /directives/contextmenu.js: -------------------------------------------------------------------------------- 1 | (function() { 2 | 3 | var module = angular.module('PlayerApp'); 4 | module.directive('ngContextMenu', function ($parse) { 5 | var renderContextMenu = function ($scope, event, options) { 6 | if (!$) { var $ = angular.element; } 7 | $(event.target).addClass('context'); 8 | var $contextMenu = $('
'); 9 | var $list = $('
'); 10 | $list.addClass('context-menu'); 11 | $list.attr({ 'role': 'menu' }); 12 | $list.css({ 13 | display: 'block', 14 | position: 'absolute', 15 | left: event.pageX + 'px', 16 | top: event.pageY + 'px' 17 | }); 18 | angular.forEach(options, function (item, i) { 19 | var $item = $('
'); 20 | if (item === null) { 21 | $item.addClass('divider'); 22 | } else { 23 | $item.addClass('item'); 24 | $a = $(''); 25 | $a.attr({ tabindex: '-1', href: '#' }); 26 | $a.text(item[0]); 27 | $item.append($a); 28 | $item.on('click', function () { 29 | $scope.$apply(function() { 30 | item[1].call($scope, $scope); 31 | }); 32 | }); 33 | } 34 | $list.append($item); 35 | }); 36 | $contextMenu.append($list); 37 | $contextMenu.css({ 38 | width: '100%', 39 | height: '100%', 40 | position: 'absolute', 41 | top: 0, 42 | left: 0, 43 | zIndex: 9999 44 | }); 45 | $(document).find('body').append($contextMenu); 46 | $contextMenu.on("click", function (e) { 47 | $(event.target).removeClass('context'); 48 | $contextMenu.remove(); 49 | e.preventDefault(); 50 | }).on('contextmenu', function (event) { 51 | $(event.target).removeClass('context'); 52 | event.preventDefault(); 53 | $contextMenu.remove(); 54 | }); 55 | }; 56 | return function ($scope, element, attrs) { 57 | element.on('contextmenu', function (event) { 58 | $scope.$apply(function () { 59 | event.preventDefault(); 60 | var options = $scope.$eval(attrs.ngContextMenu); 61 | if (options === null) return; 62 | if (options instanceof Array) { 63 | renderContextMenu($scope, event, options); 64 | } else { 65 | throw '"' + attrs.ngContextMenu + '" not an array'; 66 | } 67 | }); 68 | }); 69 | }; 70 | }); 71 | })(); 72 | -------------------------------------------------------------------------------- /directives/focusme.js: -------------------------------------------------------------------------------- 1 | (function() { 2 | var module = angular.module('PlayerApp'); 3 | module.directive('focusMe', function($timeout) { 4 | return { 5 | link: function(scope, element, attrs) { 6 | scope.$watch(attrs.focusMe, function(value) { 7 | if(value === true) { 8 | console.log('value=',value); 9 | $timeout(function() { 10 | element[0].focus(); 11 | element[0].select(); 12 | scope[attrs.focusMe] = false; 13 | }); 14 | } 15 | }); 16 | } 17 | }; 18 | }); 19 | })(); 20 | -------------------------------------------------------------------------------- /directives/playlistcover.js: -------------------------------------------------------------------------------- 1 | (function() { 2 | 3 | var module = angular.module('PlayerApp'); 4 | 5 | module.directive('playlistCover', function() { 6 | return { 7 | restrict: 'E', 8 | scope: { 9 | playlistData: '=ngModel' 10 | }, 11 | replace: false, 12 | compile: function (tElem, tAttrs) { 13 | var linkFunction = function($scope, element, attributes) { 14 | $scope.$watch('playlistData', function() { 15 | if ($scope.playlistData) { 16 | if ($scope.playlistData.images.length && $scope.playlistData.images[0]) { 17 | tElem.append('
'); 18 | } else { 19 | 20 | var selectedAlbums = [], 21 | selectedImages = []; 22 | 23 | var multiple = false; 24 | if ($scope.playlistData.tracks.items.length >= 4) { 25 | $scope.playlistData.tracks.items.some(function(t) { 26 | if (selectedAlbums.indexOf(t.track.album.id) === -1) { 27 | selectedAlbums.push(t.track.album.id); 28 | selectedImages.push(t.track.album.images[0].url); 29 | if (selectedAlbums.length === 4) { 30 | return true; 31 | } 32 | } 33 | return false; 34 | }); 35 | if (selectedAlbums.length === 4) { 36 | multiple = true; 37 | } 38 | } 39 | 40 | if (multiple) { 41 | selectedImages.forEach(function(selectedImage) { 42 | tElem.append('
'); 43 | }); 44 | } else { 45 | if ($scope.playlistData.tracks.items.length) { 46 | var images = $scope.playlistData.tracks.items[0].track.album.images; 47 | tElem.append('
'); 48 | } 49 | } 50 | } 51 | } 52 | }); 53 | }; 54 | return linkFunction; 55 | } 56 | }; 57 | }); 58 | 59 | })(); 60 | -------------------------------------------------------------------------------- /directives/responsivecover.js: -------------------------------------------------------------------------------- 1 | (function() { 2 | 3 | var module = angular.module('PlayerApp'); 4 | 5 | module.directive('responsiveCover', function() { 6 | return { 7 | restrict: 'E', 8 | scope: { 9 | imagesData: '=ngModel' 10 | }, 11 | replace: true, 12 | compile: function (tElem, tAttrs) { 13 | var linkFunction = function($scope, element, attributes) { 14 | var elem = element[0]; 15 | 16 | var interval = null; 17 | $scope.$watch('imagesData', function() { 18 | if (interval) { 19 | clearInterval(interval); 20 | interval = null; 21 | } 22 | 23 | if ($scope.imagesData) { 24 | try { 25 | var images = $scope.imagesData; 26 | if (!images.length) { return; } 27 | 28 | // todo: once we have loaded a large one maybe it doesn't make sense to try to load 29 | // a smaller one, since that incurs in an extra request and the quality won't be 30 | // better than the one provided by the larger image 31 | 32 | var findRightImage = function() { 33 | var targetWidth = elem.offsetWidth * window.devicePixelRatio, 34 | targetHeight = elem.offsetHeight * window.devicePixelRatio; 35 | 36 | if (targetWidth === 0 || targetHeight === 0) { 37 | return; 38 | } 39 | 40 | var cover = images[0].url; 41 | for (var i=1; i= targetWidth && images[i].height >= targetHeight) { 43 | cover = images[i].url; 44 | } 45 | } 46 | 47 | elem.style.backgroundImage = 'url(' + cover + ')'; 48 | 49 | }.bind(this); 50 | interval = setInterval(findRightImage, 200); 51 | findRightImage(); 52 | } catch (e) { 53 | console.error(e); 54 | } 55 | } 56 | }); 57 | }; 58 | return linkFunction; 59 | } 60 | }; 61 | }); 62 | 63 | })(); 64 | -------------------------------------------------------------------------------- /filters/displaytime.js: -------------------------------------------------------------------------------- 1 | (function() { 2 | 3 | var module = angular.module('PlayerApp'); 4 | 5 | module.filter('displaytime', function() { 6 | return function(input) { 7 | function twodigit(n) { 8 | if (n < 10) { 9 | return '0' + n; 10 | } else { 11 | return n; 12 | } 13 | } 14 | 15 | function format(input) { 16 | if (input) { 17 | var secs = Math.round((0 + input) / 1000); 18 | var mins = Math.floor(secs / 60); 19 | secs -= mins * 60; 20 | var hours = Math.floor(mins / 60); 21 | mins -= hours * 60; 22 | if (hours > 0) { 23 | return hours + ':' + twodigit(mins) + ':' + twodigit(secs); 24 | } 25 | else { 26 | return mins + ':' + twodigit(secs); 27 | } 28 | } else { 29 | return ''; 30 | } 31 | } 32 | 33 | return format(input); 34 | }; 35 | }); 36 | 37 | })(); 38 | -------------------------------------------------------------------------------- /filters/timeago.js: -------------------------------------------------------------------------------- 1 | (function() { 2 | 3 | var module = angular.module('PlayerApp'); 4 | module.filter('timeago', function() { 5 | return function(input) { 6 | 7 | if (input == null) { 8 | return '-'; 9 | } 10 | 11 | var substitute = function (string, number) { 12 | return string.replace(/%d/i, number); 13 | }, 14 | nowTime = (new Date()).getTime(), 15 | date = (new Date(input)).getTime(), 16 | //refreshMillis= 6e4, //A minute 17 | strings = { 18 | suffixAgo: "ago", 19 | seconds: "less than a minute", 20 | minute: "about a minute", 21 | minutes: "%d minutes", 22 | hour: "about an hour", 23 | hours: "about %d hours", 24 | day: "a day", 25 | days: "%d days", 26 | month: "about a month", 27 | months: "%d months", 28 | year: "about a year", 29 | years: "%d years" 30 | }, 31 | dateDifference = nowTime - date, 32 | words, 33 | seconds = Math.abs(dateDifference) / 1000, 34 | minutes = seconds / 60, 35 | hours = minutes / 60, 36 | days = hours / 24, 37 | years = days / 365, 38 | separator = strings.wordSeparator === undefined ? " " : strings.wordSeparator, 39 | suffix = strings.suffixAgo; 40 | 41 | words = seconds < 45 && substitute(strings.seconds, Math.round(seconds), strings) || 42 | seconds < 90 && substitute(strings.minute, 1, strings) || 43 | minutes < 45 && substitute(strings.minutes, Math.round(minutes), strings) || 44 | minutes < 90 && substitute(strings.hour, 1, strings) || 45 | hours < 24 && substitute(strings.hours, Math.round(hours), strings) || 46 | hours < 42 && substitute(strings.day, 1, strings) || 47 | days < 30 && substitute(strings.days, Math.round(days), strings) || 48 | days < 45 && substitute(strings.month, 1, strings) || 49 | days < 365 && substitute(strings.months, Math.round(days / 30), strings) || 50 | years < 1.5 && substitute(strings.year, 1, strings) || 51 | substitute(strings.years, Math.round(years), strings); 52 | 53 | return [words, suffix].join(separator).trim(); 54 | } 55 | }); 56 | })(); 57 | -------------------------------------------------------------------------------- /images/btn-next.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/possan/webapi-player-example/47d3e78fcdd8baa8c89b0e760e0d72fabf9b1386/images/btn-next.png -------------------------------------------------------------------------------- /images/btn-pause.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/possan/webapi-player-example/47d3e78fcdd8baa8c89b0e760e0d72fabf9b1386/images/btn-pause.png -------------------------------------------------------------------------------- /images/btn-play.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/possan/webapi-player-example/47d3e78fcdd8baa8c89b0e760e0d72fabf9b1386/images/btn-play.png -------------------------------------------------------------------------------- /images/btn-prev.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/possan/webapi-player-example/47d3e78fcdd8baa8c89b0e760e0d72fabf9b1386/images/btn-prev.png -------------------------------------------------------------------------------- /images/placeholder-playlist.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/possan/webapi-player-example/47d3e78fcdd8baa8c89b0e760e0d72fabf9b1386/images/placeholder-playlist.png -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | Thirtify 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 |
34 |
35 | 47 |
48 | 80 |
81 |
82 |
83 |
84 |
85 |
86 |
87 | 88 |
89 | 90 |
91 | 92 | 93 |
94 | 95 |
96 | 97 |
98 | 99 |
100 | 101 |
102 |
103 |
104 |
{{ progress | displaytime }}
105 |
{{ duration | displaytime }}
106 |
107 | 108 |
109 |
110 |
111 | 115 |
116 |
117 |
118 |
119 | 120 |
121 |
122 |
123 |
124 |

Thirtify

125 |

126 | This demo explores the possibilities with the Web API. To view this demo you need to sign in with your spotify account. 127 |

128 | login 129 |
130 |
131 |
132 |
133 | 134 | 135 | -------------------------------------------------------------------------------- /partials/album.html: -------------------------------------------------------------------------------- 1 |
2 |
3 | 4 |
5 | 6 |

Single

7 |

Album

8 |

Compilation

9 |

{{data.name}}

10 | 11 |
12 | PLAY ALL 13 |
14 |
15 | 16 |
17 |

18 | By {{data.artists[0].name}} 19 | · 20 | {{release_year}} 21 | · 22 | {{data.tracks.total}} Songs 23 | · 24 | Total {{total_duration | displaytime}} 25 |

26 |
27 |
28 | 29 |
30 |
31 |

32 | CD {{ d.disc_number }} 33 |

34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 48 | 51 | 52 | 58 | 59 | 60 |
#TRACKTIMEPOPULARITY
{{t.track_number}} 46 | 47 | 49 | {{t.name}} 50 | {{t.duration_ms | displaytime}} 53 |
54 |
55 |
56 |
57 |
61 |
62 |
63 |
64 | 65 |
66 | 67 | 80 | -------------------------------------------------------------------------------- /partials/artist.html: -------------------------------------------------------------------------------- 1 |
2 |
3 | 4 |
5 | 6 |

ARTIST

7 |

{{data.name}}

8 |
{{data.followers.total | number}} followers
9 | 10 | 14 |
15 | 16 |
17 |
18 | 19 |

Popular tracks

20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 35 | 36 | 37 | 38 | 39 | 40 |
#SONGALBUMTIME
{{$index + 1}} 33 | 34 | {{t.name}}{{t.album.name}}{{t.duration_ms | displaytime}}
41 | 42 |
43 |
44 | 45 |
46 |

Albums

47 | 57 |
58 |
59 |
60 | 61 |
62 |

Singles

63 | 73 |
74 |
75 |
76 | 77 |
78 |

Appears on

79 | 89 |
90 |
91 |
92 | 93 | 119 | -------------------------------------------------------------------------------- /partials/browse.html: -------------------------------------------------------------------------------- 1 |

Browse

2 |

Overview

3 |
4 |

{{message}}

5 | 15 |
16 |
17 |
18 | 19 |
20 |

Genres & Moods

21 | 31 |
32 |
33 |
34 | 35 |
36 |

New releases

37 | 47 |
48 |
49 |
50 | -------------------------------------------------------------------------------- /partials/browsecategory.html: -------------------------------------------------------------------------------- 1 |

{{categoryname}}

2 |
3 | 13 |
14 | -------------------------------------------------------------------------------- /partials/home.html: -------------------------------------------------------------------------------- 1 |

Welcome {{username}}!

2 | -------------------------------------------------------------------------------- /partials/playlist.html: -------------------------------------------------------------------------------- 1 |
2 | 3 |

COLLABORATIVE PLAYLIST

4 |

PLAYLIST

5 |

{{data.name}}

6 |

7 |
{{data.followers.total | number}} followers
8 | 9 | 13 |
14 | 15 |
16 |

Created by: {{data.owner.id}} · {{data.tracks.total}} songs · {{ total_duration | displaytime }}

17 | 18 |
19 |
20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 36 | 39 | 42 | 45 | 48 | 51 | 54 | 55 | 56 |
TRACKARTISTTIMEALBUMADDEDUSER
34 | 35 | 37 | {{t.track.name}} 38 | 40 | {{t.track.artists[0].name}} 41 | 43 | {{ t.track.duration_ms | displaytime }} 44 | 46 | {{t.track.album.name}} 47 | 49 | {{t.added_at | timeago}} 50 | 52 | {{t.added_by.id}} 53 |
57 |
58 | 59 |
60 | 61 | 74 | -------------------------------------------------------------------------------- /partials/playqueue.html: -------------------------------------------------------------------------------- 1 | playqueue.html current playqueue 2 |
3 | 4 | position={{position}} current={{current}} 5 |
6 | 7 |
    8 |
  • 9 | queued track {{ t }} **CURRENT** 10 |
  • 11 |
12 |
13 | -------------------------------------------------------------------------------- /partials/searchresults.html: -------------------------------------------------------------------------------- 1 |

Showing results for {{query}}

2 | 3 |
4 |
5 | 6 | 18 | 19 |

TRACKS

20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 34 | 37 | 40 | 43 | 46 | 52 | 53 | 54 |
SONGARTISTALBUMTIMEPOPULARITY
32 | 33 | 35 | {{t.name}} 36 | 38 | {{t.artists[0].name}} 39 | 41 | {{t.album.name}} 42 | 44 | {{ t.duration_ms | displaytime }} 45 | 47 |
48 |
49 |
50 |
51 |
55 | 56 |
57 | -------------------------------------------------------------------------------- /partials/user.html: -------------------------------------------------------------------------------- 1 |
2 |
3 | 4 |
5 | 6 |

USER

7 |

{{data.display_name}}

8 |
{{data.followers.total | number}} followers
9 | 10 | 13 | 14 |
15 | 16 |
17 |

Public Playlists

18 | 28 |
29 |
30 |
31 |
32 |

There was an error retrieving playlists

33 |
Please try again later
34 |
35 | 36 | 43 | -------------------------------------------------------------------------------- /partials/usertracks.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 15 | 18 | 21 | 22 | 25 | 28 | 29 | 30 |
TRACKARTISTTIMEALBUMADDED
13 | 14 | 16 | {{t.track.name}} 17 | 19 | {{t.track.artists[0].name}} 20 | {{t.track.duration_ms | displaytime}} 23 | {{t.track.album.name}} 24 | 26 | {{t.added_at | timeago}} 27 |
31 | -------------------------------------------------------------------------------- /readme-img/webapi-player-example.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/possan/webapi-player-example/47d3e78fcdd8baa8c89b0e760e0d72fabf9b1386/readme-img/webapi-player-example.jpg -------------------------------------------------------------------------------- /services/api.js: -------------------------------------------------------------------------------- 1 | (function() { 2 | 3 | var module = angular.module('PlayerApp'); 4 | 5 | module.factory('API', function(Auth, $q, $http) { 6 | 7 | var baseUrl = 'https://api.spotify.com/v1'; 8 | 9 | return { 10 | 11 | getMe: function() { 12 | var ret = $q.defer(); 13 | $http.get(baseUrl + '/me', { 14 | headers: { 15 | 'Authorization': 'Bearer ' + Auth.getAccessToken() 16 | } 17 | }).success(function(r) { 18 | console.log('got userinfo', r); 19 | ret.resolve(r); 20 | }).error(function(err) { 21 | console.log('failed to get userinfo', err); 22 | ret.reject(err); 23 | }); 24 | return ret.promise; 25 | }, 26 | 27 | getMyUsername: function() { 28 | var ret = $q.defer(); 29 | $http.get(baseUrl + '/me', { 30 | headers: { 31 | 'Authorization': 'Bearer ' + Auth.getAccessToken() 32 | } 33 | }).success(function(r) { 34 | console.log('got userinfo', r); 35 | //ret.resolve(r.id); 36 | ret.resolve('test_1'); 37 | }).error(function(err) { 38 | console.log('failed to get userinfo', err); 39 | //ret.reject(err); 40 | // 41 | ret.resolve('test_1'); 42 | }); 43 | return ret.promise; 44 | }, 45 | 46 | getMyTracks: function() { 47 | var ret = $q.defer(); 48 | $http.get(baseUrl + '/me/tracks', { 49 | headers: { 50 | 'Authorization': 'Bearer ' + Auth.getAccessToken() 51 | } 52 | }).success(function(r) { 53 | console.log('got user tracks', r); 54 | ret.resolve(r); 55 | }); 56 | return ret.promise; 57 | }, 58 | 59 | containsUserTracks: function(ids) { 60 | var ret = $q.defer(); 61 | $http.get(baseUrl + '/me/tracks/contains?ids=' + encodeURIComponent(ids.join(',')), { 62 | headers: { 63 | 'Authorization': 'Bearer ' + Auth.getAccessToken() 64 | } 65 | }).success(function(r) { 66 | console.log('got contains user tracks', r); 67 | ret.resolve(r); 68 | }); 69 | return ret.promise; 70 | }, 71 | 72 | addToMyTracks: function(ids) { 73 | var ret = $q.defer(); 74 | $http.put(baseUrl + '/me/tracks?ids=' + encodeURIComponent(ids.join(',')), {}, { 75 | headers: { 76 | 'Authorization': 'Bearer ' + Auth.getAccessToken() 77 | } 78 | }).success(function(r) { 79 | console.log('got response from adding to my albums', r); 80 | ret.resolve(r); 81 | }); 82 | return ret.promise; 83 | }, 84 | 85 | removeFromMyTracks: function(ids) { 86 | var ret = $q.defer(); 87 | $http.delete(baseUrl + '/me/tracks?ids=' + encodeURIComponent(ids.join(',')), { 88 | headers: { 89 | 'Authorization': 'Bearer ' + Auth.getAccessToken() 90 | } 91 | }).success(function(r) { 92 | console.log('got response from removing from my tracks', r); 93 | ret.resolve(r); 94 | }); 95 | return ret.promise; 96 | }, 97 | 98 | getPlaylists: function(username) { 99 | var limit = 50; 100 | var ret = $q.defer(); 101 | var playlists = []; 102 | 103 | $http.get(baseUrl + '/users/' + encodeURIComponent(username) + '/playlists', { 104 | params: { 105 | limit: limit 106 | }, 107 | headers: { 108 | 'Authorization': 'Bearer ' + Auth.getAccessToken() 109 | } 110 | }).success(function(r) { 111 | playlists = playlists.concat(r.items); 112 | 113 | var promises = [], 114 | total = r.total, 115 | offset = r.offset; 116 | 117 | while (total > limit + offset) { 118 | promises.push( 119 | $http.get(baseUrl + '/users/' + encodeURIComponent(username) + '/playlists', { 120 | params: { 121 | limit: limit, 122 | offset: offset + limit 123 | }, 124 | headers: { 125 | 'Authorization': 'Bearer ' + Auth.getAccessToken() 126 | } 127 | }) 128 | ); 129 | offset += limit; 130 | }; 131 | 132 | $q.all(promises).then(function(results) { 133 | results.forEach(function(result) { 134 | playlists = playlists.concat(result.data.items); 135 | }) 136 | console.log('got playlists', playlists); 137 | ret.resolve(playlists); 138 | }); 139 | 140 | }).error(function(data, status, headers, config) { 141 | ret.reject(status); 142 | }); 143 | return ret.promise; 144 | }, 145 | 146 | getPlaylist: function(username, playlist) { 147 | var ret = $q.defer(); 148 | $http.get(baseUrl + '/users/' + encodeURIComponent(username) + '/playlists/' + encodeURIComponent(playlist), { 149 | headers: { 150 | 'Authorization': 'Bearer ' + Auth.getAccessToken() 151 | } 152 | }).success(function(r) { 153 | console.log('got playlists', r); 154 | ret.resolve(r); 155 | }); 156 | return ret.promise; 157 | }, 158 | 159 | getPlaylistTracks: function(username, playlist) { 160 | var ret = $q.defer(); 161 | $http.get(baseUrl + '/users/' + encodeURIComponent(username) + '/playlists/' + encodeURIComponent(playlist) + '/tracks', { 162 | headers: { 163 | 'Authorization': 'Bearer ' + Auth.getAccessToken() 164 | } 165 | }).success(function(r) { 166 | console.log('got playlist tracks', r); 167 | ret.resolve(r); 168 | }); 169 | return ret.promise; 170 | }, 171 | 172 | changePlaylistDetails: function(username, playlist, options) { 173 | var ret = $q.defer(); 174 | $http.put(baseUrl + '/users/' + encodeURIComponent(username) + '/playlists/' + encodeURIComponent(playlist), options, { 175 | headers: { 176 | 'Authorization': 'Bearer ' + Auth.getAccessToken() 177 | } 178 | }).success(function(r) { 179 | console.log('got response after changing playlist details', r); 180 | ret.resolve(r); 181 | }); 182 | return ret.promise; 183 | }, 184 | 185 | removeTrackFromPlaylist: function(username, playlist, track, position) { 186 | var ret = $q.defer(); 187 | $http.delete(baseUrl + '/users/' + encodeURIComponent(username) + '/playlists/' + encodeURIComponent(playlist) + '/tracks', 188 | { 189 | data: { 190 | tracks: [{ 191 | uri: track.uri, 192 | position: position 193 | }] 194 | }, 195 | headers: { 196 | 'Authorization': 'Bearer ' + Auth.getAccessToken() 197 | } 198 | }).success(function(r) { 199 | console.log('remove track from playlist', r); 200 | ret.resolve(r); 201 | }); 202 | return ret.promise; 203 | }, 204 | 205 | getTrack: function(trackid) { 206 | var ret = $q.defer(); 207 | $http.get(baseUrl + '/tracks/' + encodeURIComponent(trackid), { 208 | headers: { 209 | 'Authorization': 'Bearer ' + Auth.getAccessToken() 210 | } 211 | }).success(function(r) { 212 | console.log('got track', r); 213 | ret.resolve(r); 214 | }); 215 | return ret.promise; 216 | }, 217 | 218 | getTracks: function(trackids) { 219 | var ret = $q.defer(); 220 | $http.get(baseUrl + '/tracks/?ids=' + encodeURIComponent(trackids.join(',')), { 221 | headers: { 222 | 'Authorization': 'Bearer ' + Auth.getAccessToken() 223 | } 224 | }).success(function(r) { 225 | console.log('got tracks', r); 226 | ret.resolve(r); 227 | }); 228 | return ret.promise; 229 | }, 230 | 231 | getAlbum: function(albumid) { 232 | var ret = $q.defer(); 233 | $http.get(baseUrl + '/albums/' + encodeURIComponent(albumid), { 234 | headers: { 235 | 'Authorization': 'Bearer ' + Auth.getAccessToken() 236 | } 237 | }).success(function(r) { 238 | console.log('got album', r); 239 | ret.resolve(r); 240 | }); 241 | return ret.promise; 242 | }, 243 | 244 | getAlbumTracks: function(albumid) { 245 | var ret = $q.defer(); 246 | $http.get(baseUrl + '/albums/' + encodeURIComponent(albumid) + '/tracks', { 247 | headers: { 248 | 'Authorization': 'Bearer ' + Auth.getAccessToken() 249 | } 250 | }).success(function(r) { 251 | console.log('got album tracks', r); 252 | ret.resolve(r); 253 | }); 254 | return ret.promise; 255 | }, 256 | 257 | getArtist: function(artistid) { 258 | var ret = $q.defer(); 259 | $http.get(baseUrl + '/artists/' + encodeURIComponent(artistid), { 260 | headers: { 261 | 'Authorization': 'Bearer ' + Auth.getAccessToken() 262 | } 263 | }).success(function(r) { 264 | console.log('got artist', r); 265 | ret.resolve(r); 266 | }); 267 | return ret.promise; 268 | }, 269 | 270 | getArtistAlbums: function(artistid, country) { 271 | var ret = $q.defer(); 272 | $http.get(baseUrl + '/artists/' + encodeURIComponent(artistid) + '/albums?country=' + encodeURIComponent(country), { 273 | headers: { 274 | 'Authorization': 'Bearer ' + Auth.getAccessToken() 275 | } 276 | }).success(function(r) { 277 | console.log('got artist albums', r); 278 | ret.resolve(r); 279 | }); 280 | return ret.promise; 281 | }, 282 | 283 | getArtistTopTracks: function(artistid, country) { 284 | var ret = $q.defer(); 285 | $http.get(baseUrl + '/artists/' + encodeURIComponent(artistid) + '/top-tracks?country=' + encodeURIComponent(country), { 286 | headers: { 287 | 'Authorization': 'Bearer ' + Auth.getAccessToken() 288 | } 289 | }).success(function(r) { 290 | console.log('got artist top tracks', r); 291 | ret.resolve(r); 292 | }); 293 | return ret.promise; 294 | }, 295 | 296 | getSearchResults: function(query) { 297 | var ret = $q.defer(); 298 | $http.get(baseUrl + '/search?type=track,playlist&q=' + encodeURIComponent(query) + '&market=from_token', { 299 | headers: { 300 | 'Authorization': 'Bearer ' + Auth.getAccessToken() 301 | } 302 | }).success(function(r) { 303 | console.log('got search results', r); 304 | ret.resolve(r); 305 | }); 306 | return ret.promise; 307 | }, 308 | 309 | getNewReleases: function(country) { 310 | var ret = $q.defer(); 311 | $http.get(baseUrl + '/browse/new-releases?country=' + encodeURIComponent(country), { 312 | headers: { 313 | 'Authorization': 'Bearer ' + Auth.getAccessToken() 314 | } 315 | }).success(function(r) { 316 | console.log('got new releases results', r); 317 | ret.resolve(r); 318 | }); 319 | return ret.promise; 320 | }, 321 | 322 | getFeaturedPlaylists: function(country, timestamp) { 323 | var ret = $q.defer(); 324 | $http.get(baseUrl + '/browse/featured-playlists?country=' + 325 | encodeURIComponent(country) + 326 | '×tamp=' + encodeURIComponent(timestamp), { 327 | headers: { 328 | 'Authorization': 'Bearer ' + Auth.getAccessToken() 329 | } 330 | }).success(function(r) { 331 | console.log('got featured playlists results', r); 332 | ret.resolve(r); 333 | }); 334 | return ret.promise; 335 | }, 336 | 337 | getUser: function(username) { 338 | var ret = $q.defer(); 339 | $http.get(baseUrl + '/users/' + 340 | encodeURIComponent(username), 341 | { 342 | headers: { 343 | 'Authorization': 'Bearer ' + Auth.getAccessToken() 344 | } 345 | }).success(function(r) { 346 | console.log('got userinfo', r); 347 | ret.resolve(r); 348 | }).error(function(err) { 349 | console.log('failed to get userinfo', err); 350 | ret.reject(err); 351 | }); 352 | return ret.promise; 353 | }, 354 | 355 | isFollowing: function(id, type) { 356 | var ret = $q.defer(); 357 | $http.get(baseUrl + '/me/following/contains?' + 358 | 'type=' + encodeURIComponent(type) + 359 | '&ids=' + encodeURIComponent(id), 360 | { 361 | headers: { 362 | 'Authorization': 'Bearer ' + Auth.getAccessToken() 363 | } 364 | }).success(function(r) { 365 | console.log('got following', r); 366 | ret.resolve(r); 367 | }).error(function(err) { 368 | console.log('failed to get following', err); 369 | ret.reject(err); 370 | }); 371 | 372 | return ret.promise; 373 | }, 374 | 375 | follow: function(id, type) { 376 | var ret = $q.defer(); 377 | $http.put(baseUrl + '/me/following?' + 378 | 'type=' + encodeURIComponent(type), 379 | { ids : [ id ] }, 380 | { headers: { 'Authorization': 'Bearer ' + Auth.getAccessToken() } 381 | }).success(function(r) { 382 | console.log('followed', r); 383 | ret.resolve(r); 384 | }).error(function(err) { 385 | console.log('failed to follow', err); 386 | ret.reject(err); 387 | }); 388 | 389 | return ret.promise; 390 | }, 391 | 392 | unfollow: function(id, type) { 393 | var ret = $q.defer(); 394 | $http.delete(baseUrl + '/me/following?' + 395 | 'type=' + encodeURIComponent(type), 396 | { data: { 397 | ids: [ id ] 398 | }, 399 | headers: { 400 | 'Authorization': 'Bearer ' + Auth.getAccessToken() 401 | } 402 | }).success(function(r) { 403 | console.log('unfollowed', r); 404 | ret.resolve(r); 405 | }).error(function(err) { 406 | console.log('failed to unfollow', err); 407 | ret.reject(err); 408 | }); 409 | 410 | return ret.promise; 411 | }, 412 | 413 | followPlaylist: function(username, playlist) { 414 | var ret = $q.defer(); 415 | $http.put(baseUrl + '/users/' + encodeURIComponent(username) + '/playlists/' + 416 | encodeURIComponent(playlist) + '/followers', 417 | {}, 418 | { headers: { 'Authorization': 'Bearer ' + Auth.getAccessToken() } 419 | }).success(function(r) { 420 | console.log('followed playlist', r); 421 | ret.resolve(r); 422 | }).error(function(err) { 423 | console.log('failed to follow playlist', err); 424 | ret.reject(err); 425 | }); 426 | 427 | return ret.promise; 428 | }, 429 | 430 | unfollowPlaylist: function(username, playlist) { 431 | var ret = $q.defer(); 432 | $http.delete(baseUrl + '/users/' + encodeURIComponent(username) + '/playlists/' + 433 | encodeURIComponent(playlist) + '/followers', 434 | { headers: { 'Authorization': 'Bearer ' + Auth.getAccessToken() } 435 | }).success(function(r) { 436 | console.log('unfollowed playlist', r); 437 | ret.resolve(r); 438 | }).error(function(err) { 439 | console.log('failed to unfollow playlist', err); 440 | ret.reject(err); 441 | }); 442 | 443 | return ret.promise; 444 | }, 445 | 446 | isFollowingPlaylist: function(username, playlist) { 447 | var ret = $q.defer(); 448 | $http.get(baseUrl + '/users/' + encodeURIComponent(username) + '/playlists/' + 449 | encodeURIComponent(playlist) + '/followers/contains', { 450 | params: { 451 | ids: [Auth.getUsername()] 452 | }, 453 | headers: { 'Authorization': 'Bearer ' + Auth.getAccessToken() 454 | } 455 | }).success(function(r) { 456 | console.log('check if playlist is followed', r); 457 | ret.resolve(r); 458 | }).error(function(err) { 459 | console.log('failed to check if playlist is followed', err); 460 | ret.reject(err); 461 | }); 462 | 463 | return ret.promise; 464 | }, 465 | 466 | getBrowseCategories: function() { 467 | var ret = $q.defer(); 468 | $http.get(baseUrl + '/browse/categories', { 469 | headers: { 'Authorization': 'Bearer ' + Auth.getAccessToken() } 470 | }).success(function(r) { 471 | console.log('got browse categories', r); 472 | ret.resolve(r); 473 | }).error(function(err) { 474 | console.log('failed to get browse categories', err); 475 | ret.reject(err); 476 | }); 477 | return ret.promise; 478 | }, 479 | 480 | getBrowseCategory: function(categoryId) { 481 | var ret = $q.defer(); 482 | $http.get(baseUrl + '/browse/categories/' + categoryId, { 483 | headers: { 'Authorization': 'Bearer ' + Auth.getAccessToken() } 484 | }).success(function(r) { 485 | console.log('got browse category', r); 486 | ret.resolve(r); 487 | }).error(function(err) { 488 | console.log('failed to get browse category', err); 489 | ret.reject(err); 490 | }); 491 | return ret.promise; 492 | }, 493 | 494 | getBrowseCategoryPlaylists: function(categoryId, country) { 495 | var ret = $q.defer(); 496 | $http.get(baseUrl + '/browse/categories/' + categoryId + '/playlists?country=' + encodeURIComponent(country), { 497 | headers: { 'Authorization': 'Bearer ' + Auth.getAccessToken() } 498 | }).success(function(r) { 499 | console.log('got browse category playlists', r); 500 | ret.resolve(r); 501 | }).error(function(err) { 502 | console.log('failed to get category playlists', err); 503 | ret.reject(err); 504 | }); 505 | return ret.promise; 506 | }, 507 | 508 | }; 509 | }); 510 | 511 | })(); 512 | -------------------------------------------------------------------------------- /services/auth.js: -------------------------------------------------------------------------------- 1 | (function() { 2 | 3 | var module = angular.module('PlayerApp'); 4 | 5 | module.factory('Auth', function() { 6 | 7 | var CLIENT_ID = ''; 8 | var REDIRECT_URI = ''; 9 | 10 | if (location.host == 'localhost:8000') { 11 | CLIENT_ID = '409f070cb44945d9a85e9b4ad8fa3bf1'; 12 | REDIRECT_URI = 'http://localhost:8000/callback.html'; 13 | } else { 14 | CLIENT_ID = '9714921402b84783b2a207f1b6e82612'; 15 | REDIRECT_URI = 'http://lab.possan.se/thirtify/callback.html'; 16 | } 17 | 18 | function getLoginURL(scopes) { 19 | return 'https://accounts.spotify.com/authorize?client_id=' + CLIENT_ID 20 | + '&redirect_uri=' + encodeURIComponent(REDIRECT_URI) 21 | + '&scope=' + encodeURIComponent(scopes.join(' ')) 22 | + '&response_type=token'; 23 | } 24 | 25 | return { 26 | openLogin: function() { 27 | var url = getLoginURL([ 28 | 'user-read-private', 29 | 'playlist-read-private', 30 | 'playlist-modify-public', 31 | 'playlist-modify-private', 32 | 'user-library-read', 33 | 'user-library-modify', 34 | 'user-follow-read', 35 | 'user-follow-modify' 36 | ]); 37 | 38 | var width = 450, 39 | height = 730, 40 | left = (screen.width / 2) - (width / 2), 41 | top = (screen.height / 2) - (height / 2); 42 | 43 | var w = window.open(url, 44 | 'Spotify', 45 | 'menubar=no,location=no,resizable=no,scrollbars=no,status=no, width=' + width + ', height=' + height + ', top=' + top + ', left=' + left 46 | ); 47 | }, 48 | getAccessToken: function() { 49 | var expires = 0 + localStorage.getItem('pa_expires', '0'); 50 | if ((new Date()).getTime() > expires) { 51 | return ''; 52 | } 53 | var token = localStorage.getItem('pa_token', ''); 54 | return token; 55 | }, 56 | setAccessToken: function(token, expires_in) { 57 | localStorage.setItem('pa_token', token); 58 | localStorage.setItem('pa_expires', (new Date()).getTime() + expires_in); 59 | // _token = token; 60 | // _expires = expires_in; 61 | }, 62 | getUsername: function() { 63 | var username = localStorage.getItem('pa_username', ''); 64 | return username; 65 | }, 66 | setUsername: function(username) { 67 | localStorage.setItem('pa_username', username); 68 | }, 69 | getUserCountry: function() { 70 | var userCountry = localStorage.getItem('pa_usercountry', 'US'); 71 | return userCountry; 72 | }, 73 | setUserCountry: function(userCountry) { 74 | localStorage.setItem('pa_usercountry', userCountry); 75 | } 76 | } 77 | }); 78 | 79 | })(); 80 | -------------------------------------------------------------------------------- /services/playback.js: -------------------------------------------------------------------------------- 1 | (function() { 2 | 3 | var module = angular.module('PlayerApp'); 4 | 5 | module.factory('Playback', function($rootScope, API, $interval) { 6 | var _playing = false; 7 | var _track = ''; 8 | var _volume = 100; 9 | var _progress = 0; 10 | var _duration = 0; 11 | var _trackdata = null; 12 | 13 | function tick() { 14 | if (!_playing) { 15 | return; 16 | } 17 | _progress = audiotag.currentTime * 1000.0; 18 | 19 | $rootScope.$emit('trackprogress'); 20 | /* 21 | if (_progress >= 4000) { 22 | console.log('track stopped. end track', _track); 23 | _playing = false; 24 | _track = ''; 25 | // $rootScope.$emit('playerchanged'); 26 | disableTick(); 27 | $rootScope.$emit('endtrack'); 28 | }*/ 29 | } 30 | 31 | var ticktimer = 0; 32 | 33 | function enableTick() { 34 | disableTick(); 35 | ticktimer = $interval(tick, 100); 36 | } 37 | 38 | function disableTick() { 39 | if (ticktimer != 0) { 40 | $interval.cancel(ticktimer); 41 | } 42 | } 43 | 44 | var audiotag = new Audio(); 45 | 46 | function createAndPlayAudio(url, callback, endcallback) { 47 | console.log('createAndPlayAudio', url); 48 | if (audiotag.src != null) { 49 | audiotag.pause(); 50 | audiotag.src = null; 51 | } 52 | audiotag.src = url; 53 | audiotag.addEventListener('loadedmetadata', function() { 54 | console.log('audiotag loadedmetadata'); 55 | _duration = audiotag.duration * 1000.0; 56 | audiotag.volume = _volume / 100.0; 57 | audiotag.play(); 58 | callback(); 59 | }, false); 60 | audiotag.addEventListener('ended', function() { 61 | console.log('audiotag ended'); 62 | _playing = false; 63 | _track = ''; 64 | disableTick(); 65 | $rootScope.$emit('endtrack'); 66 | }, false); 67 | } 68 | 69 | return { 70 | getVolume: function() { 71 | return _volume; 72 | }, 73 | setVolume: function(v) { 74 | _volume = v; 75 | audiotag.volume = _volume / 100.0; 76 | }, 77 | startPlaying: function(trackuri) { 78 | console.log('Playback::startPlaying', trackuri); 79 | _track = trackuri; 80 | _trackdata = null; 81 | _playing = true; 82 | _progress = 0; 83 | var trackid = trackuri.split(':')[2]; 84 | 85 | // workaround to be able to play on mobile 86 | // we need to play as a response to a touch event 87 | // play + immediate pause of an empty song does the trick 88 | // see http://stackoverflow.com/questions/12517000/no-sound-on-ios-6-web-audio-api 89 | audiotag.src=''; 90 | audiotag.play(); 91 | audiotag.pause(); 92 | 93 | API.getTrack(trackid).then(function(trackdata) { 94 | console.log('playback got track', trackdata); 95 | createAndPlayAudio(trackdata.preview_url, function() { 96 | _trackdata = trackdata; 97 | _progress = 0; 98 | $rootScope.$emit('playerchanged'); 99 | $rootScope.$emit('trackprogress'); 100 | enableTick(); 101 | }); 102 | }); 103 | }, 104 | stopPlaying: function() { 105 | _playing = false; 106 | _track = ''; 107 | audiotag.stop(); 108 | _trackdata = null; 109 | $rootScope.$emit('playerchanged'); 110 | }, 111 | pause: function() { 112 | if (_track != '') { 113 | _playing = false; 114 | audiotag.pause(); 115 | $rootScope.$emit('playerchanged'); 116 | disableTick(); 117 | } 118 | }, 119 | resume: function() { 120 | if (_track != '') { 121 | _playing = true; 122 | audiotag.play(); 123 | $rootScope.$emit('playerchanged'); 124 | enableTick(); 125 | } 126 | }, 127 | isPlaying: function() { 128 | return _playing; 129 | }, 130 | getTrack: function() { 131 | return _track; 132 | }, 133 | getTrackData: function() { 134 | return _trackdata; 135 | }, 136 | getProgress: function() { 137 | return _progress; 138 | }, 139 | setProgress: function(pos) { 140 | audiotag.currentTime = pos / 1000.0; 141 | }, 142 | getDuration: function() { 143 | return _duration; 144 | } 145 | } 146 | }); 147 | 148 | })(); 149 | -------------------------------------------------------------------------------- /services/playqueue.js: -------------------------------------------------------------------------------- 1 | (function() { 2 | 3 | var module = angular.module('PlayerApp'); 4 | 5 | module.factory('PlayQueue', function(Playback, $rootScope) { 6 | var _queue = []; 7 | var _position = 0; 8 | return { 9 | play: function(trackuri) { 10 | console.log('Clear queue and play track', trackuri); 11 | _queue = []; 12 | _queue.push(trackuri); 13 | _position = 0; 14 | $rootScope.$emit('playqueuechanged'); 15 | Playback.startPlaying(trackuri); 16 | }, 17 | enqueue: function(trackuri) { 18 | console.log('Enqueue track', trackuri); 19 | _queue.push(trackuri); 20 | $rootScope.$emit('playqueuechanged'); 21 | }, 22 | enqueueList: function(trackuris) { 23 | console.log('Enqueue tracks', trackuris); 24 | trackuris.forEach(function(trackuri) { 25 | _queue.push(trackuri); 26 | }); 27 | $rootScope.$emit('playqueuechanged'); 28 | }, 29 | playFrom: function(index) { 30 | _position = index; 31 | $rootScope.$emit('playqueuechanged'); 32 | Playback.startPlaying(_queue[_position]); 33 | }, 34 | getQueue: function() { 35 | return _queue; 36 | }, 37 | getPosition: function() { 38 | return _position; 39 | }, 40 | getCurrent: function() { 41 | if (_queue.length > 0) { 42 | return _queue[_position]; 43 | } 44 | return ''; 45 | }, 46 | clear: function() { 47 | _queue = []; 48 | _position = 0; 49 | $rootScope.$emit('playqueuechanged'); 50 | }, 51 | next: function() { 52 | console.log('PlayQueue: next'); 53 | _position ++; 54 | if (_position >= _queue.length) { 55 | // TODO: if repeat is on. 56 | _position = 0; 57 | } 58 | $rootScope.$emit('playqueuechanged'); 59 | }, 60 | prev: function() { 61 | console.log('PlayQueue: prev'); 62 | _position --; 63 | if (_position < 0) { 64 | // TODO: if repeat is on. 65 | _position = _queue.length - 1; 66 | } 67 | $rootScope.$emit('playqueuechanged'); 68 | } 69 | } 70 | }); 71 | 72 | })(); 73 | -------------------------------------------------------------------------------- /style.css: -------------------------------------------------------------------------------- 1 | body { 2 | background: #121314; 3 | color: #ddd; 4 | margin: 0; 5 | padding: 0; 6 | border: 0; 7 | font-family: 'Open Sans', sans-serif; 8 | font-size: 8pt; 9 | font-weight: 300; 10 | overflow: hidden; 11 | } 12 | 13 | div.scrollable { 14 | overflow: auto; 15 | width: 100%; 16 | height: 100%; 17 | } 18 | 19 | div.fullview { 20 | width: 100%; 21 | height: 100%; 22 | } 23 | 24 | h1 { 25 | font-family: 'Open Sans', sans-serif; 26 | font-weight: 300; 27 | font-size: 30pt; 28 | padding: 0; 29 | margin: 0 0 10px 0; 30 | } 31 | 32 | h2 { 33 | font-family: 'Open Sans', sans-serif; 34 | font-weight: 300; 35 | font-size: 20pt; 36 | padding: 0; 37 | margin: 0 0 10px 0; 38 | } 39 | 40 | h3 { 41 | font-family: 'Open Sans', sans-serif; 42 | font-weight: 300; 43 | font-size: 15pt; 44 | padding: 0; 45 | margin: 0 0 10px 0; 46 | } 47 | 48 | h4 { 49 | font-family: 'Open Sans', sans-serif; 50 | font-weight: 300; 51 | padding: 0; 52 | margin: 0 0 0 0; 53 | } 54 | 55 | p { 56 | margin: 0 0 10px 0; 57 | } 58 | 59 | a { 60 | color: inherit; 61 | cursor: pointer; 62 | text-decoration: none; 63 | } 64 | 65 | b { 66 | font-weight: inherit; 67 | color: #fff; 68 | } 69 | 70 | hr { 71 | display: block; 72 | border: none; 73 | border-top: 1px solid #222326; 74 | background-color: transparent; 75 | margin: 0 0 10px 0; 76 | clear: both; 77 | } 78 | 79 | .topgroup { 80 | position: absolute; 81 | left: 0px; 82 | top: 0px; 83 | height: 40px; 84 | width: 100%; 85 | background-color: #222326; 86 | border-bottom: 1px solid #393b40; 87 | box-sizing: border-box; 88 | overflow: hidden; 89 | } 90 | 91 | .topgroup .searchbox { 92 | position: absolute; 93 | left: 0px; 94 | top: 0px; 95 | width: 220px; 96 | box-sizing: border-box; 97 | padding: 7px; 98 | } 99 | 100 | .topgroup .searchbox input { 101 | border: 0; 102 | padding: 5px 10px; 103 | border-radius: 20px; 104 | background-color: #ddd; 105 | width: 100%; 106 | outline: none; 107 | } 108 | 109 | .topgroup .userbox { 110 | position: absolute; 111 | right: 0px; 112 | top: 0px; 113 | padding: 12px; 114 | text-align: right; 115 | } 116 | 117 | .topgroup .titlebox { 118 | position: absolute; 119 | left: 150px; 120 | right: 150px; 121 | top: 0px; 122 | text-align: center; 123 | padding: 12px; 124 | } 125 | 126 | .midgroup { 127 | position: absolute; 128 | left: 0px; 129 | top: 40px; 130 | bottom: 40px; 131 | width: 100%; 132 | overflow: hidden; 133 | } 134 | 135 | .bottomgroup { 136 | position: absolute; 137 | box-sizing: border-box; 138 | left: 0px; 139 | width: 100%; 140 | height: 40px; 141 | bottom: 0px; 142 | background-color: #222326; 143 | border-top: 1px solid #393b40; 144 | overflow: hidden; 145 | } 146 | 147 | .bottomgroup .leftcontrols { 148 | position: absolute; 149 | left: 10px; 150 | top: 2px; 151 | width: 400px; 152 | padding: 0px; 153 | } 154 | 155 | .bottomgroup .leftcontrols .prevbutton { 156 | position: absolute; 157 | left: 5px; 158 | } 159 | 160 | .bottomgroup .leftcontrols .playbutton { 161 | position: absolute; 162 | left: 40px; 163 | } 164 | 165 | .bottomgroup .leftcontrols .nextbutton { 166 | position: absolute; 167 | left: 75px; 168 | } 169 | 170 | .bottomgroup .leftcontrols .prevbutton a img, 171 | .bottomgroup .leftcontrols .playbutton a img, 172 | .bottomgroup .leftcontrols .nextbutton a img { 173 | width: 32px; 174 | } 175 | 176 | .bottomgroup .leftcontrols .volume { 177 | position: absolute; 178 | left: 120px; 179 | top: 7px; 180 | width: 80px; 181 | } 182 | 183 | .bottomgroup .leftcontrols .volume input { 184 | width: 100%; 185 | } 186 | 187 | .bottomgroup .seekcontrols { 188 | position: absolute; 189 | top: 8px; 190 | left: 220px; 191 | right: 0px; 192 | padding: 0px; 193 | } 194 | 195 | .bottomgroup .seekcontrols .slider { 196 | position: absolute; 197 | top: 1px; 198 | left: 60px; 199 | right: 60px; 200 | } 201 | 202 | .bottomgroup .seekcontrols input { 203 | width: 100%; 204 | } 205 | 206 | .bottomgroup .seekcontrols div.progress { 207 | width: 60px; 208 | position: absolute; 209 | top: 3px; 210 | left: 0px; 211 | text-align: center; 212 | } 213 | 214 | .bottomgroup .seekcontrols div.duration { 215 | position: absolute; 216 | right: 0px; 217 | top: 3px; 218 | width: 60px; 219 | text-align: center; 220 | } 221 | 222 | .bottomgroup .rightcontrols { 223 | position: absolute; 224 | right: 0px; 225 | top: 0px; 226 | text-align: right; 227 | width: 110px; 228 | padding: 20px; 229 | } 230 | 231 | .menuview { 232 | overflow: hidden; 233 | position: absolute; 234 | left: 0px; 235 | top: 0px; 236 | width: 220px; 237 | background-color: #222326; 238 | bottom: 0px; 239 | } 240 | 241 | .menuview .list { 242 | position: absolute; 243 | left: 0px; 244 | top: 0px; 245 | width: 100%; 246 | height: auto; 247 | bottom: 260px; 248 | padding: 0px; 249 | overflow: auto; 250 | } 251 | 252 | .menuview .preview { 253 | position: absolute; 254 | left: 0px; 255 | bottom: 0px; 256 | height: 260px; 257 | background-color: #222326; 258 | } 259 | 260 | .menuview .preview > p { 261 | display: block; 262 | padding: 5px; 263 | } 264 | 265 | .menuview .preview > p b { 266 | display: block; 267 | font-weight: 300; 268 | } 269 | 270 | .menuview .preview > p a { 271 | display: block; 272 | font-size: 7pt; 273 | } 274 | 275 | .menuview .preview responsive-cover { 276 | width: 220px; 277 | height: 220px; 278 | border: 0; 279 | outline: 0; 280 | border: 1px solid #393b40; 281 | } 282 | 283 | .mainview { 284 | position: absolute; 285 | left: 220px; 286 | top: 0px; 287 | right: 0px; 288 | bottom: 0px; 289 | padding: 20px; 290 | overflow: auto; 291 | color: #88898c; 292 | } 293 | 294 | .mainview a { 295 | color: #fff; 296 | } 297 | 298 | .menuview a { 299 | color: inherit; 300 | text-decoration: none; 301 | } 302 | 303 | .menuview .list > b { 304 | display: block; 305 | margin: 10px 0 5px 10px; 306 | } 307 | 308 | .menuview ul.menuitems { 309 | margin: 0px 0 20px 0px; 310 | padding: 0; 311 | list-style: none; 312 | } 313 | 314 | .menuview ul.menuitems li { 315 | white-space: nowrap; 316 | overflow: hidden; 317 | text-overflow: ellipsis; 318 | padding: 2px 15px; 319 | margin: 0; 320 | cursor: pointer; 321 | } 322 | 323 | ul.menuitems li:hover { 324 | background-color: #555; 325 | } 326 | 327 | div.centered { 328 | width: 100%; 329 | } 330 | 331 | div.centered div.inner { 332 | margin: 100px auto; 333 | width: 400px; 334 | } 335 | 336 | a.button { 337 | display: inline-block; 338 | text-align: center; 339 | padding: 5px 20px; 340 | border-radius: 50px; 341 | } 342 | 343 | a.button.green { 344 | background-color: #070; 345 | color: #fff; 346 | border: 1px solid #070; 347 | } 348 | 349 | a.button.big.green { 350 | display: inline-block; 351 | text-align: center; 352 | background-color: #070; 353 | color: #fff; 354 | padding: 10px 40px; 355 | border-radius: 50px; 356 | } 357 | 358 | a.button.button-add { 359 | color: #fff; 360 | border: 1px solid #070; 361 | } 362 | 363 | .pop-meter { 364 | width: 30px; 365 | height: 9px; 366 | position: relative; 367 | } 368 | 369 | .pop-meter-background, .pop-meter-overlay { 370 | width: 100%; 371 | position: absolute; 372 | top: -1px; 373 | height: 9px; 374 | overflow-x: hidden; 375 | } 376 | 377 | .pop-meter-background:after, .pop-meter-overlay:after { 378 | content: ' '; 379 | display: block; 380 | -webkit-transform: translate(0,.5px); 381 | -moz-transform: translate(0,.5px); 382 | -ms-transform: translate(0,.5px); 383 | -o-transform: translate(0,.5px); 384 | transform: translate(0,.5px); 385 | position: absolute; 386 | left: -4px; 387 | width: 2px; 388 | height: 8px; 389 | top: 0; 390 | } 391 | 392 | .pop-meter-background:after { 393 | box-shadow: 4px 0 0 0 #3e3e40,8px 0 0 0 #3e3e40,12px 0 0 0 #3e3e40,16px 0 0 0 #3e3e40,20px 0 0 0 #3e3e40,24px 0 0 0 #3e3e40,28px 0 0 0 #3e3e40,32px 0 0 0 #3e3e40 394 | } 395 | 396 | .pop-meter-overlay:after { 397 | box-shadow: 4px 0 0 0 #88898c,8px 0 0 0 #88898c,12px 0 0 0 #88898c,16px 0 0 0 #88898c,20px 0 0 0 #88898c,24px 0 0 0 #88898c,28px 0 0 0 #88898c,32px 0 0 0 #88898c; 398 | } 399 | 400 | .searchresult-image { 401 | width: 100px; 402 | height: 100px; 403 | } 404 | 405 | header { 406 | position: relative; 407 | height: 220px; 408 | } 409 | 410 | header div.cover { 411 | position: absolute; 412 | left: 0px; 413 | top: 0px; 414 | width: 200px; 415 | height: 200px; 416 | background-color: #222326; 417 | background-size: cover; 418 | overflow: hidden; 419 | } 420 | 421 | header .playlist-cover { 422 | position: relative; 423 | } 424 | 425 | header .cover-component { 426 | background-size: cover; 427 | position: absolute; 428 | width: 100px; 429 | height: 100px; 430 | } 431 | 432 | header .cover-component:nth-child(1) { 433 | top: 0; 434 | left: 0; 435 | } 436 | 437 | header .cover-component:nth-child(2) { 438 | top: 0; 439 | left: 100px; 440 | } 441 | 442 | header .cover-component:nth-child(3) { 443 | top: 100px; 444 | left: 0px; 445 | } 446 | 447 | header .cover-component:nth-child(4) { 448 | top: 100px; 449 | left: 100px; 450 | } 451 | 452 | header div.buttons { 453 | position: absolute; 454 | left: 210px; 455 | bottom: 20px; 456 | } 457 | 458 | header p { 459 | padding-left: 210px; 460 | } 461 | 462 | header h1 { 463 | margin-top: -5px; 464 | padding-left: 210px; 465 | } 466 | 467 | header h4 { 468 | padding-left: 210px; 469 | } 470 | 471 | header .follower-count { 472 | padding-left: 210px; 473 | } 474 | 475 | table.tracks { 476 | width: 100%; 477 | margin: 0 0px; 478 | border-collapse: collapse; 479 | } 480 | 481 | table tr { 482 | } 483 | 484 | table tr:hover { 485 | background-color: #222326; 486 | } 487 | 488 | table tr.playing { 489 | background-color: #393b40; 490 | } 491 | 492 | table tr td { 493 | font-size: 9pt; 494 | border-top: 1px solid #393b40; 495 | padding: 4px 10px 4px 0; 496 | } 497 | 498 | table tr td i { 499 | font-style: normal; 500 | color: #aaa; 501 | } 502 | 503 | table.tracks tr td i > a { 504 | color: inherit; 505 | } 506 | 507 | table tr td.nowrap { 508 | white-space: nowrap; 509 | } 510 | 511 | table tr th { 512 | text-align: left; 513 | font-weight: 300; 514 | font-size: 9pt; 515 | color: #fff; 516 | padding: 4px 10px 4px 0; 517 | } 518 | 519 | ul.albums, ul.playlists, ul.genres { 520 | margin: 0; 521 | padding: 0; 522 | border: 0; 523 | clear: both; 524 | } 525 | 526 | ul.albums li, ul.playlists li, ul.genres li { 527 | float: left; 528 | position: relative; 529 | width: 160px; 530 | height: 220px; 531 | margin: 0 10px 10px 0; 532 | background-color: #393b40; 533 | } 534 | 535 | ul.albums li responsive-cover, ul.playlists li responsive-cover, ul.genres li img { 536 | width: 160px; 537 | height: 160px; 538 | } 539 | 540 | ul.albums li p, ul.playlists li p, ul.genres li p { 541 | padding: 10px; 542 | } 543 | 544 | responsive-cover { 545 | display:block; 546 | height:100%; 547 | width:100%; 548 | background-size: cover; 549 | } 550 | 551 | responsive-cover.responsive-cover-playlist { 552 | background-image: url('images/placeholder-playlist.png'); 553 | } 554 | 555 | .searchresult { 556 | overflow: hidden; 557 | } 558 | 559 | .position-fixed { 560 | position: fixed; 561 | } 562 | 563 | .context-menu { 564 | padding: 5px 0; 565 | position: absolute; 566 | display: none; 567 | width: auto; 568 | min-width: 160px; 569 | max-width: 460px; 570 | color: #c8c8c8; 571 | background-color: #333; 572 | box-shadow: 0 0 10px rgba(0, 0, 0, 0.8); 573 | } 574 | 575 | .context-menu .item { 576 | position: relative; 577 | white-space: nowrap; 578 | overflow: hidden; 579 | text-overflow: ellipsis; 580 | } 581 | 582 | .context-menu .item:hover { 583 | background-color: #444; 584 | cursor: default; 585 | } 586 | 587 | .context-menu .item a { 588 | padding: 4px 25px 4px 25px; 589 | display: block; 590 | } 591 | 592 | .context-menu .sep { 593 | display: block; 594 | margin: 4px 0; 595 | height: 1px; 596 | background-color: #444; 597 | } 598 | 599 | ::-webkit-scrollbar-track { 600 | background-color: #1c1c1f; 601 | } 602 | ::-webkit-scrollbar { 603 | width: 10px; 604 | background-color: #1c1c1f; 605 | } 606 | ::-webkit-scrollbar:horizontal { 607 | height: 10px; 608 | } 609 | ::-webkit-scrollbar-thumb { 610 | border: 1px solid #1c1c1f; 611 | border-radius: 10px; 612 | background-color: #323438; 613 | } 614 | ::-webkit-scrollbar-corner { 615 | background-color: #1c1c1f; 616 | } 617 | 618 | 619 | --------------------------------------------------------------------------------