This is part 2 in a series of posts about creating a mobile web app for browsing music databases. Part 1 can be found here.
The first task in building the front end was testing my API to make sure I knew what was being returned by Rovi and that it had everything I wanted. I added some test JavaScript to the default MVC view that would call my API. It was a bit of trial and error going through the data and seeing where I needed to adjust my requests on the back end. My plan was to simply copy the test code into the official script files. The Rovi service itself is easy to use and well documented.
Next was setting up the base AngularJS implementation. I fired up Google to try and find a good online example of how to structure the app. The web site has a tutorial so I started stepping through it. But as I began to have questions on how to do certain things, and what the best practices are, I noticed the code I found online differed from what the tutorial was doing. More searching uncovered tools like angular-seed and angular-enterprise-seed. They were comprehensive but included way too much stuff to absorb for someone just learning the framework. They seem to be more for large scale web applications. I eventually came up with what looked like a good way to set up my module, controller, and service declarations, along with the source file structure to use. I followed suggestions from places like yeoman.io and various others found online. I don’t know if it’s exactly what is considered good by the Angular community but it’s close. My main app script ended up looking like this:
1 2 3 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 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 |
'use strict'; // The name of the module for the main app must match the ng-app attribute of the <html> tag in the // startup page. The contents of the array are dependencies for the app: // ngRoute: Provides routing support // ngAnimate: Provides animation support // ngSanitize: Sanitizes text that contains markup before binding it to a view, needed // for artist bios and album reviews which need to have line breaks and // possibly hyperlinks to other views // jmdobry.angular-cache: Custom caching implementation for use with the $http service // musicBrowserControllers: Module which will hold all controllers for the app var musicBrowserApp = angular.module('MusicBrowserApp', [ 'ngRoute', 'ngAnimate', 'ngSanitize', 'ui.bootstrap', 'jmdobry.angular-cache', 'musicBrowserControllers' ]); var musicBrowserControllers = angular.module('musicBrowserControllers', []); // / Home page, currently redirected to the search page // /search Page that shows the search UI // /search/artist/<query> Shows the results of an artist search for <query> // /search/album/<query> Shows the results of an album search for <query> // /search/song/<query> Shows the results of a song search for <query> // /artist/<id> Shows the details for the artist represented by <id> // /artist/<id>/full-bio Shows only the complete bio for the artist represented by <id> // /album/<id> Shows the details for the album represented by <id> // /album/<id>/full-review Shows only the complete review for the album represented by <id> // /genre/<id> Shows the details for the genre represented by <id> // /style/<id> Shows the details for the style represented by <id> // /options Page for changing app options musicBrowserApp.config(['$routeProvider', '$provide', function ($routeProvider, $provide) { $routeProvider.when('/', { templateUrl: 'views/search.html', controller: 'SearchCtrl', title: "Search" }); $routeProvider.when('/search', { templateUrl: 'views/search.html', controller: 'SearchCtrl', title: "Search" }); $routeProvider.when('/search/artist/:searchTerm', { templateUrl: 'views/artistSearch.html', controller: 'ArtistSearchCtrl', title: "Artist Search" }); $routeProvider.when('/search/album/:searchTerm', { templateUrl: 'views/albumSearch.html', controller: 'AlbumSearchCtrl', title: "Album Search" }); $routeProvider.when('/search/song/:searchTerm', { templateUrl: 'views/songSearch.html', controller: 'SongSearchCtrl', title: "Song Search" }); $routeProvider.when('/artist/:id', { templateUrl: 'views/artist.html', controller: 'ArtistLookupCtrl', title: "Artist" }); $routeProvider.when('/artist/:id/full-bio', { templateUrl: 'views/artistBio.html', controller: 'ArtistLookupCtrl', title: "Artist Bio" }); $routeProvider.when('/album/:id', { templateUrl: 'views/album.html', controller: 'AlbumLookupCtrl', title: "Album" }); $routeProvider.when('/album/:id/full-review', { templateUrl: 'views/albumReview.html', controller: 'AlbumLookupCtrl', title: "Album Review" }); $routeProvider.when('/style/:id', { templateUrl: 'views/style.html', controller: 'StyleLookupCtrl', title: "Style" }); $routeProvider.when('/genre/:id', { templateUrl: 'views/genre.html', controller: 'GenreLookupCtrl', title: "Genre" }); $routeProvider.when('/options', { templateUrl: 'views/options.html', controller: 'OptionsCtrl', title: "Options" }); $routeProvider.otherwise({ redirectTo: '/' }); }]); // Any startup code needed by the app should go here musicBrowserApp.run(['$rootScope', '$http', '$angularCacheFactory', function ($rootScope, $http, $angularCacheFactory) { $http.defaults.headers.common["Accept-Encoding"] = "gzip,deflate"; $rootScope.$on("$routeChangeSuccess", function (event, currentRoute, previousRoute) { // Change page title based on the current route $rootScope.title = currentRoute.title; }); // Create a custom cache for our data, and set the $http service to use it for its caching $angularCacheFactory('dataCache', { // Items added to this cache expire after 60 minutes maxAge: 3600000, // This cache will clear itself every two hours cacheFlushInterval: 12000000, // Items will be deleted from this cache right when they expire deleteOnExpire: 'aggressive' }); $http.defaults.cache = $angularCacheFactory.get('dataCache'); }]); |
What to say about Angular? I like it. The framework strives to make it easy to separate your app logic from your markup from your data access, and largely succeeds. It includes tons of built-in stuff to further that goal. I ended up creating two custom Angular services, one for common code and one for data access. The latter looked like this:
1 2 3 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 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 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 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 |
musicBrowserApp.factory('mbData', ['mbCommon', '$http', function (mbCommon, $http) { var curInstance = this; Object.defineProperty(curInstance, "maxShortDescriptionLength", { value: 150, writable: false, enumerable: true, configurable: true }); curInstance.searchForArtist = function (query, size, offset) { var url = "api/search/artist/" + encodeURIComponent(query) + "?size=" + size + "&offset=" + offset; return $http.get(url, { cache: true }). success(function (data, status, headers, config) { var result = JSON.parse(data.Content); if (result.searchResponse) { data.searchResult = result.searchResponse.results.map(function (element) { setPrimaryImage(element, mbCommon.placeholderImageMedium); return element; }); } }); }; curInstance.lookupArtist = function(id) { var url = "api/artist/" + encodeURIComponent(id); return $http.get(url, { cache: true }). success(function (data, status, headers, config) { var result = JSON.parse(data.Content); var primaryImage; var formattedBioText; var mbConfig = mbCommon.getConfiguration(); var styleIndex; for (var i = 0; i < result.name.musicGenres.length; i++) { styleIndex = getIndexOfId(result.name.musicStyles, result.name.musicGenres[i].id); if (styleIndex > -1) { result.name.musicStyles.splice(styleIndex, 1); } } if (result.name.discography) { if (mbConfig && mbConfig.albumChrono) { result.name.discography.reverse(); } for (var i = 0; i < result.name.discography.length; i++) { if (result.name.discography[i].year) { result.name.discography[i].year = mbCommon.formatDate(result.name.discography[i].year, true); } if (result.name.discography[i].images && result.name.discography[i].images.length > 0) { primaryImage = result.name.discography[i].images[0].url; } else { primaryImage = mbCommon.placeholderImageSmall; } result.name.discography[i].primaryImage = primaryImage; result.name.discography[i].formattedType = "Album" if (result.name.discography[i].flags && result.name.discography[i].flags.indexOf("Compilation") > -1) { result.name.discography[i].formattedType = "Compilation"; } if (result.name.discography[i].type === "Single" || result.name.discography[i].type === "EP") { result.name.discography[i].formattedType = "SingleOrEP"; } } } if (result.name.isGroup) { result.name.originLabel = "Formed:"; result.name.endLabel = "Disbanded:" } else { result.name.originLabel = "Born:"; result.name.endLabel = "Died:" } if (result.name.active) { for (var i = 0; i < result.name.active.length; i++) { result.name.active[i] = mbCommon.formatDate(result.name.active[i], true); } } if (result.name.birth) { var newDate = mbCommon.formatDate(result.name.birth.date, false, true); if (newDate === "") { result.name.birth.date = "N/A"; } else { result.name.birth.date = newDate; } } if (result.name.death) { var newDate = mbCommon.formatDate(result.name.death.date, false, true); if (newDate != "") { result.name.death.date = newDate; } } setPrimaryImage(result, mbCommon.placeholderImageLarge); if (result.name.musicBio) { formattedBioText = replaceRoviLinks(result.name.musicBio.text); formattedBioText = formattedBioText.split("\r\n").join("<br /><br />"); result.name.musicBioFormatted = formattedBioText; } if (result.name.headlineBio) { result.name.headlineBioFormatted = replaceRoviLinks(result.name.headlineBio); } else { if (result.name.musicBio) { result.name.headlineBioFormatted = getShortDescription(result.name.musicBioFormatted); } } data.lookupResult = result.name; }) }; curInstance.searchForAlbum = function (query, size, offset) { var url = "api/search/album/" + encodeURIComponent(query) + "?size=" + size + "&offset=" + offset; return $http.get(url, { cache: true }). success(function (data, status, headers, config) { var result = JSON.parse(data.Content); if (result.searchResponse) { data.searchResult = result.searchResponse.results.map(function (element) { if (element.album.images && element.album.images.length > 0) { element.album.primaryImage = element.album.images[0].url; } else { element.album.primaryImage = mbCommon.placeholderImageMedium; } return element; }); } }) }; curInstance.lookupAlbum = function (id) { var url = "api/album/" + encodeURIComponent(id); return $http.get(url, { cache: true }). success(function (data, status, headers, config) { var result = JSON.parse(data.Content); var primaryImage; var formattedReviewText; if (result.album.originalReleaseDate) { result.album.originalReleaseDate = mbCommon.formatDate(result.album.originalReleaseDate, false, true); } if (result.album.duration) { result.album.durationFormatted = mbCommon.formatDuration(result.album.duration); } if (result.album.images && result.album.images.length > 0) { primaryImage = result.album.images[0].url; } else { primaryImage = mbCommon.placeholderImageLarge; } result.album.primaryImage = primaryImage; if (result.album.primaryReview) { formattedReviewText = replaceRoviLinks(result.album.primaryReview.text); formattedBioText = formattedReviewText.split("\r\n").join("<br /><br />"); result.album.primaryReviewFormatted = formattedReviewText; } if (result.album.headlineReview) { result.album.headlineReviewFormatted = replaceRoviLinks(result.album.headlineReview.text); } else { if (result.album.primaryReview) { result.album.headlineReviewFormatted = getShortDescription(result.album.primaryReviewFormatted); } } if (result.album.tracks && result.album.tracks.length > 0) { for (var i = 0; i < result.album.tracks.length; i++) { result.album.tracks[i].durationFormatted = mbCommon.formatDuration(result.album.tracks[i].duration); } } data.lookupResult = result.album; }) }; curInstance.searchForSong = function (query, size, offset) { var url = "api/search/song/" + encodeURIComponent(query) + "?size=" + size + "&offset=" + offset; return $http.get(url, { cache: true }). success(function (data, status, headers, config) { var result = JSON.parse(data.Content); if (result.searchResponse) { data.searchResult = result.searchResponse.results.map(function (element) { if (element.song.images && element.song.images.length > 0) { element.song.primaryImage = element.song.images[0].url; } else { element.song.primaryImage = mbCommon.placeholderImageMedium; } return element; }); } }) }; curInstance.lookupStyle = function (id) { var url = "api/style/" + encodeURIComponent(id); return $http.get(url, { cache: true }). success(function (data, status, headers, config) { var result = JSON.parse(data.Content); var items; if (result.styles) { items = result.styles; } else { items = result.subgenres; } data.lookupResult = items.map(function (element) { element.formattedDescription = replaceRoviLinks(element.description); return element; }); }) } curInstance.lookupGenre = function (id) { var url = "api/genre/" + encodeURIComponent(id); return $http.get(url, { cache: true }). success(function (data, status, headers, config) { var result = JSON.parse(data.Content); data.lookupResult = result.genres.map(function (element) { element.formattedDescription = replaceRoviLinks(element.description); return element; }); }) } return curInstance; }]); |
The data service uses the built-in Angular $http service and does any required massaging of the data before handing it off to the controller that called it. The controllers then set various properties of the current scope as needed.
I created several different views based on what needed to be shown; one for artist search results, one for data on a specific artist, etc. Whenever I ran into a case where I needed the markup to be different based on the data, I was pleasantly surprised to find an Angular directive that would allow it to be driven by the model. Things like ng-show and ng-href were invaluable. The general rule in the Angular world is that you shouldn’t make any changes to the DOM in your controllers, and if you find yourself reaching for jQuery you might be doing something wrong. I’m happy to say I didn’t have any need to use jQuery to manipulate the DOM.
One of my favorite aspects of some directives is that Angular will react based on whether the expression in the directive is truthy. It makes me wonder if Stephen Colbert had some influence on the framework’s design, or at least the documentation.
Next is incorporating animations for view transitions, adding something to the options page, and filling out some missing features.
Leave a Comment