From 03f49562c45ec2ac969d83eaec8e0a508e6d9f61 Mon Sep 17 00:00:00 2001
From: listen1 <githublisten1@gmail.com>
Date: Fri, 27 May 2016 17:21:27 +0800
Subject: [PATCH] add keyborad shortcuts, connect to lastfm, rearrange vendor
 files, sync page title for playing song, fix bug for click cancel button on
 favorite dialog show new song dialog

---
 README.md                    |   13 +-
 css/hotkeys.css              |  110 +++
 css/player.css               |    9 +
 js/app.js                    |  174 +++-
 js/lastfm.js                 |  258 ++++++
 js/{ => provider}/netease.js |    0
 js/{ => provider}/qq.js      |    4 +
 js/{ => provider}/xiami.js   |    0
 js/{ => vendor}/aes.js       |    0
 js/{ => vendor}/bigint.js    |    0
 js/vendor/hotkeys.js         | 1661 ++++++++++++++++++++++++++++++++++
 js/vendor/md5.js             |  207 +++++
 js/vendor/timer.js           |  155 ++++
 listen1.html                 |   66 +-
 14 files changed, 2631 insertions(+), 26 deletions(-)
 create mode 100644 css/hotkeys.css
 create mode 100644 js/lastfm.js
 rename js/{ => provider}/netease.js (100%)
 rename js/{ => provider}/qq.js (99%)
 rename js/{ => provider}/xiami.js (100%)
 rename js/{ => vendor}/aes.js (100%)
 rename js/{ => vendor}/bigint.js (100%)
 create mode 100644 js/vendor/hotkeys.js
 create mode 100755 js/vendor/md5.js
 create mode 100644 js/vendor/timer.js

diff --git a/README.md b/README.md
index 96aca6d..fdaa5f2 100644
--- a/README.md
+++ b/README.md
@@ -1,4 +1,4 @@
-Listen 1 (Chrome Extension) (最后更新于5月21日)
+Listen 1 (Chrome Extension) (最后更新于5月27日)
 ==========
 
 [![Software License](https://img.shields.io/badge/license-MIT-brightgreen.svg)](LICENSE)
@@ -17,8 +17,6 @@ Listen 1 (Chrome Extension) (最后更新于5月21日)
 
 Chrome安装
 ----
-不能直接用chrome打开安装,不能直接用chrome打开安装,不能直接用chrome打开安装。重要的话说三遍。
-
 1. 下载项目的zip文件,在右上方有个 `Download ZIP`, 解压到本地
 
 2. chrome右上角的设置按钮下找到更多工具,打开`扩展程序`
@@ -37,6 +35,15 @@ Firefox打包安装
 
 更新日志
 -------
+`2016-05-27`
+
+* 增加快捷键功能(输入?查看快捷键设置)
+* 支持同步播放记录到last.fm
+* 增加搜索loading时的图标(感谢@richdho的提交)
+* 页面标题增加显示当前播放信息
+* 修复了在收藏对话框点击取消出现新建歌单的bug
+* 重新组织代码文件夹结构
+
 `2016-05-21`
 
 * 增加歌单分页加载功能(感谢@wild-flame的提交)
diff --git a/css/hotkeys.css b/css/hotkeys.css
new file mode 100644
index 0000000..55380ab
--- /dev/null
+++ b/css/hotkeys.css
@@ -0,0 +1,110 @@
+/*! 
+ * angular-hotkeys v1.7.0
+ * https://chieffancypants.github.io/angular-hotkeys
+ * Copyright (c) 2016 Wes Cruver
+ * License: MIT
+ */
+.cfp-hotkeys-container {
+  display: table !important;
+  position: fixed;
+  width: 100%;
+  height: 100%;
+  top: 0;
+  left: 0;
+  color: #333;
+  font-size: 1em;
+  background-color: rgba(255,255,255,0.9);
+}
+
+.cfp-hotkeys-container.fade {
+  z-index: -1024;
+  visibility: hidden;
+  opacity: 0;
+  -webkit-transition: opacity 0.15s linear;
+  -moz-transition: opacity 0.15s linear;
+  -o-transition: opacity 0.15s linear;
+  transition: opacity 0.15s linear;
+}
+
+.cfp-hotkeys-container.fade.in {
+  z-index: 10002;
+  visibility: visible;
+  opacity: 1;
+}
+
+.cfp-hotkeys-title {
+  font-weight: bold;
+  text-align: center;
+  font-size: 1.2em;
+}
+
+.cfp-hotkeys {
+  width: 100%;
+  height: 100%;
+  display: table-cell;
+  vertical-align: middle;
+}
+
+.cfp-hotkeys table {
+  margin: auto;
+  color: #333;
+}
+
+.cfp-content {
+  display: table-cell;
+  vertical-align: middle;
+}
+
+.cfp-hotkeys-keys {
+  padding: 5px;
+  text-align: right;
+}
+
+.cfp-hotkeys-key {
+  display: inline-block;
+  color: #fff;
+  background-color: #333;
+  border: 1px solid #333;
+  border-radius: 5px;
+  text-align: center;
+  margin-right: 5px;
+  box-shadow: inset 0 1px 0 #666, 0 1px 0 #bbb;
+  padding: 5px 9px;
+  font-size: 1em;
+}
+
+.cfp-hotkeys-text {
+  padding-left: 10px;
+  font-size: 1em;
+}
+
+.cfp-hotkeys-close {
+  position: fixed;
+  top: 20px;
+  right: 20px;
+  font-size: 2em;
+  font-weight: bold;
+  padding: 5px 10px;
+  border: 1px solid #ddd;
+  border-radius: 5px;
+  min-height: 45px;
+  min-width: 45px;
+  text-align: center;
+}
+
+.cfp-hotkeys-close:hover {
+  background-color: #fff;
+  cursor: pointer;
+}
+
+@media all and (max-width: 500px) {
+  .cfp-hotkeys {
+    font-size: 0.8em;
+  }
+}
+
+@media all and (min-width: 750px) {
+  .cfp-hotkeys {
+    font-size: 1.2em;
+  }
+}
diff --git a/css/player.css b/css/player.css
index 115ff9f..ddb3f97 100644
--- a/css/player.css
+++ b/css/player.css
@@ -975,6 +975,15 @@ li {
     margin-left: 93px;
 }
 
+.dialog-connect-lastfm .buttons {
+    margin-top: 30px;
+}
+
+.dialog-connect-lastfm .confirm-button{
+    margin-left: 40px;
+    margin-right: 48px;
+}
+
 .source-list {
     position: absolute;
     right: -32px;
diff --git a/js/app.js b/js/app.js
index 71b2182..3f37532 100644
--- a/js/app.js
+++ b/js/app.js
@@ -10,7 +10,7 @@
       return value && JSON.parse(value);
   }
 
-  var app = angular.module('listenone', ['angularSoundManager', 'ui-notification', 'loWebManager'])
+  var app = angular.module('listenone', ['angularSoundManager', 'ui-notification', 'loWebManager', 'cfp.hotkeys', 'lastfmClient'])
     .config( [
     '$compileProvider',
     function( $compileProvider )
@@ -31,6 +31,18 @@
     });
   });
 
+  app.config(function(hotkeysProvider) {
+    hotkeysProvider.templateTitle = '快捷键列表';
+    hotkeysProvider.cheatSheetDescription = '显示/隐藏快捷键列表';
+  });
+
+  app.config(function(lastfmProvider) {
+    lastfmProvider.setOptions({
+      apiKey: '6790c00a181128dc7c4ce06cd99d17c8',
+      apiSecret: 'd68f1dfc6ff43044c96a79ae7dfb5c27'
+    });
+  });
+
   app.run(['angularPlayer', 'Notification', 'loWeb', function(angularPlayer, Notification, loWeb) {
     angularPlayer.setBootstrapTrack(
       loWeb.bootstrapTrack(
@@ -63,8 +75,12 @@
   app.controller('NavigationController', ['$scope', '$http',
     '$httpParamSerializerJQLike', '$timeout',
     'angularPlayer', 'Notification', '$rootScope', 'loWeb',
+    'hotkeys', 'lastfm',
     function($scope, $http, $httpParamSerializerJQLike,
-      $timeout, angularPlayer, Notification, $rootScope, loWeb){
+      $timeout, angularPlayer, Notification, $rootScope,
+      loWeb, hotkeys, lastfm) {
+
+    $rootScope.page_title = "Listen 1";
     $scope.window_url_stack = [];
     $scope.current_tag = 2;
     $scope.is_window_hidden = 1;
@@ -78,6 +94,8 @@
     $scope.dialog_title = '';
 
     $scope.isDoubanLogin = false;
+
+    $scope.lastfm = lastfm;
     
     $scope.$on('isdoubanlogin:update', function(event, data) {
       $scope.isDoubanLogin = data;
@@ -201,6 +219,10 @@
         $scope.dialog_cover_img_url = data.cover_img_url;
         $scope.dialog_playlist_title = data.playlist_title;
       }
+      if (dialog_type == 4) {
+        $scope.dialog_title = '连接到Last.fm';
+        $scope.dialog_type = 4;
+      }
     };
 
     $scope.chooseDialogOption = function(option_id) {
@@ -302,6 +324,10 @@
     $scope.closeDialog = function() {
       $scope.is_dialog_hidden = 1;
       $scope.dialog_type = 0;
+      // update lastfm status if not authorized
+      if (lastfm.isAuthRequested()) {
+        lastfm.updateStatus();
+      }
     };
 
     $scope.setCurrentList = function(list_id) {
@@ -430,6 +456,22 @@
       };
       reader.readAsText(fileObject);
     }
+
+    $scope.showShortcuts = function() {
+      hotkeys.toggleCheatSheet();
+    }
+
+    hotkeys.add({
+      combo: 'f',
+      description: '快速搜索',
+      callback: function() {
+        $scope.showTag(3);
+        $timeout(function(){$("#search-input").focus();}, 0);
+      }
+    });
+
+
+
   }]);
 
   app.directive('customOnChange', function() {
@@ -444,9 +486,12 @@
 
   app.controller('PlayController', ['$scope', '$timeout','$log',
     '$anchorScroll', '$location', 'angularPlayer', '$http',
-    '$httpParamSerializerJQLike','$rootScope', 'Notification','loWeb',
-     function($scope, $timeout, $log, $anchorScroll, $location, angularPlayer,
-      $http, $httpParamSerializerJQLike, $rootScope, Notification, loWeb){
+    '$httpParamSerializerJQLike','$rootScope', 'Notification',
+    'loWeb', 'hotkeys', 'lastfm',
+     function($scope, $timeout, $log, $anchorScroll, $location,
+      angularPlayer, $http, $httpParamSerializerJQLike,
+      $rootScope, Notification, loWeb, hotkeys, lastfm){
+
       $scope.menuHidden = true;
       $scope.volume = angularPlayer.getVolume();
       $scope.mute = angularPlayer.getMuteStatus();
@@ -455,6 +500,9 @@
       $scope.lyricLineNumber = -1;
       $scope.lastTrackId = null;
 
+      $scope.scrobbleTrackId = null;
+      $scope.scrobbleTimer = new Timer();
+
       $scope.loadLocalSettings = function() {
         var defaultSettings = {"playmode": 0, "nowplaying_track_id": -1, "volume": 90};
         var localSettings = localStorage.getObject('player-settings');
@@ -599,6 +647,45 @@
         localStorage.setObject('current-playing', data);
       });
 
+
+      $scope.$on('currentTrack:duration', function(event, data) {
+        if (!lastfm.isAuthorized()) {
+          return;
+        }
+        if (data == 0) {
+          return;
+        }
+        if ($scope.scrobbleTrackId == angularPlayer.getCurrentTrack()) {
+          return;
+        }
+        // new song arrives
+        $scope.scrobbleTrackId = angularPlayer.getCurrentTrack();
+        var track = angularPlayer.getTrack($scope.scrobbleTrackId);
+        var startTimestamp = Math.round((new Date()).valueOf() / 1000);
+        $scope.scrobbleTimer.start(function(){
+          lastfm.scrobble(startTimestamp, track.title, track.artist, track.album, function(){});
+        });
+        // according to scrobble rule
+        // http://www.last.fm/api/scrobbling
+        var secondsToScrobble = Math.min(data/1000/2, 60*4);
+        $scope.scrobbleTimer.update(secondsToScrobble);
+      });
+
+      $scope.$on('music:isPlaying', function(event, data) {
+        if (!lastfm.isAuthorized()) {
+          return;
+        }
+        if ($scope.scrobbleTrackId == null) {
+          return;
+        }
+        if (data) {
+          $scope.scrobbleTimer.resume();
+        }
+        else {
+          $scope.scrobbleTimer.pause();
+        };
+      });
+
       function parseLyric(lyric) {
         var lines = lyric.split('\n');
         var result = [];
@@ -679,6 +766,12 @@
         $(".lyric").animate({ scrollTop: "0px" }, 500);
         var url = '/lyric?track_id=' + data;
         var track = angularPlayer.getTrack(data);
+
+        $rootScope.page_title = '▶ ' + track.title + ' - ' + track.artist;
+        if (lastfm.isAuthorized()) {
+          lastfm.sendNowPlaying(track.title, track.artist, function(){});
+        }
+
         if (track.lyric_url != null) {
           url = url + '&lyric_url=' + track.lyric_url;
         }
@@ -713,6 +806,77 @@
         }
       });
 
+      // define keybind
+      hotkeys.add({
+        combo: 'p',
+        description: '播放/暂停',
+        callback: function() {
+          if(angularPlayer.isPlayingStatus()) {
+              //if playing then pause
+              angularPlayer.pause();
+          } else {
+              //else play if not playing
+              angularPlayer.play();
+          }
+        }
+      });
+
+      hotkeys.add({
+        combo: '[',
+        description: '上一首',
+        callback: function() {
+          angularPlayer.prevTrack();
+        }
+      });
+
+      hotkeys.add({
+        combo: ']',
+        description: '下一首',
+        callback: function() {
+          angularPlayer.nextTrack();
+        }
+      });
+
+      hotkeys.add({
+        combo: 'm',
+        description: '静音/取消静音',
+        callback: function() {
+          // mute indeed toggle mute status
+          angularPlayer.mute();
+        }
+      });
+
+      hotkeys.add({
+        combo: 'l',
+        description: '打开/关闭播放列表',
+        callback: function() {
+          $scope.togglePlaylist();
+        }
+      });
+
+      hotkeys.add({
+        combo: 's',
+        description: '切换播放模式(顺序/随机)',
+        callback: function() {
+          $scope.changePlaymode();
+        }
+      });
+
+      hotkeys.add({
+        combo: 'u',
+        description: '音量增加',
+        callback: function() {
+          $timeout(function(){angularPlayer.adjustVolume(true);});
+        }
+      });
+
+      hotkeys.add({
+        combo: 'd',
+        description: '音量减少',
+        callback: function() {
+          $timeout(function(){angularPlayer.adjustVolume(false);});
+        }
+      });
   }]);
 
   app.controller('InstantSearchController', ['$scope', '$http', '$timeout', 'angularPlayer', 'loWeb',
diff --git a/js/lastfm.js b/js/lastfm.js
new file mode 100644
index 0000000..a5f95e5
--- /dev/null
+++ b/js/lastfm.js
@@ -0,0 +1,258 @@
+(function() {
+
+ 'use strict';
+
+  Storage.prototype.setObject = function(key, value) {
+      this.setItem(key, JSON.stringify(value));
+  }
+
+  Storage.prototype.getObject = function(key) {
+      var value = this.getItem(key);
+      return value && JSON.parse(value);
+  }
+
+  angular.module('lastfmClient', []).provider('lastfm', function() {
+    this.options = {
+      apiKey: 'unknown',
+      apiSecret: 'unknown'
+    };
+
+    this.setOptions = function(options) {
+      if (!angular.isObject(options)) throw new Error("Options should be an object!");
+      this.options = angular.extend({}, this.options, options);
+    };
+
+    this.apiUrl = 'https://ws.audioscrobbler.com/2.0/';
+
+    this.$get = ['$http', '$window', function($http, $window) {
+      var options = this.options;
+      var apiUrl = this.apiUrl;
+      var status = 0;
+
+      /**
+       * Computes string for signing request
+       *
+       * See http://www.last.fm/api/authspec#8
+       */
+      function generateSign(params) {
+        var keys = [];
+        var o = '';
+
+        for (var x in params) {
+          if (params.hasOwnProperty(x)) {
+            keys.push(x);
+          }
+        }
+
+        // params has to be ordered alphabetically
+        keys.sort();
+
+        for (var i = 0; i < keys.length; i++) {
+          if (keys[i] == 'format' || keys[i] == 'callback') {
+            continue;
+          }
+
+          o = o + keys[i] + params[keys[i]];
+        }
+
+        // append secret
+        return MD5(o + options.apiSecret);
+      }
+
+      /**
+       * Creates query string from object properties
+       */
+      function createQueryString(params) {
+        var parts = [];
+
+        for (var x in params) {
+          if (params.hasOwnProperty(x)) {
+            parts.push( x + '=' + encodeURIComponent(params[x]));
+          }
+        }
+
+        return parts.join('&');
+      }
+
+      function getAuth(callback){
+        var url = apiUrl + '?method=auth.gettoken&api_key=' + options.apiKey  + '&format=json';
+        $http.get(url).success(function(data) {
+          var token = data.token;
+          localStorage.setObject('lastfmtoken', token);
+          var grant_url = 'http://www.last.fm/api/auth/?api_key=' + options.apiKey + '&token=' + token;
+          $window.open(grant_url, '_blank');
+          status = 1;
+          if (callback != null) {
+            callback();
+          }
+        });
+      };
+
+      function cancelAuth(){
+        localStorage.removeItem('lastfmsession');
+        localStorage.removeItem('lastfmtoken');
+        updateStatus();
+      };
+
+      function _isAuthRequested() {
+        var token = localStorage.getObject('lastfmtoken');
+        return (token != null);
+      }
+
+      function updateStatus() {
+        // auth status
+        // 0: never request for auth
+        // 1: request but fail to success
+        // 2: success auth
+        if (!_isAuthRequested()) {
+          status = 0;
+          return;
+        }
+        getUserInfo(function(data){
+          if (data == null) {
+            status = 1;
+          }
+          else {
+            status = 2;
+          }
+        });
+      }
+
+      function getSession(callback) {
+        // load session info from localStorage
+        var mySession = localStorage.getObject('lastfmsession');
+        if (mySession != null) {
+          return callback(mySession);
+        }
+        // trade session with token
+        var token = localStorage.getObject('lastfmtoken');
+        if (token == null){
+          return callback(null);
+        }
+        // token exists
+        var params = {
+          method: 'auth.getsession',
+          api_key: options.apiKey,
+          token: token
+        };
+        var apiSig = generateSign(params);
+        var url = apiUrl + '?' + createQueryString(params) + '&api_sig=' + apiSig + '&format=json';
+        $http.get(url).success(function(data){
+          mySession = data.session;
+          localStorage.setObject('lastfmsession', mySession);
+          callback(mySession);
+        }).error(function (errResponse, status) {
+          if(status == 403){
+            callback(null);
+          }
+        });
+      };
+
+      function sendNowPlaying(track, artist, callback) {
+        getSession(function(session){
+          var params = {
+            method: 'track.updatenowplaying',
+            track: track,
+            artist: artist,
+            api_key: options.apiKey,
+            sk: session.key
+          };
+
+          params.api_sig = generateSign(params);
+
+          var url = apiUrl + '?' + createQueryString(params) + '&format=json';
+          $http.post(url).success(function(data){
+            if (callback != null) {
+              callback(data);
+            }
+          });
+        });
+      };
+
+      function scrobble(timestamp, track, artist, album, callback) {
+        getSession(function(session){
+          var params = {
+            method: 'track.scrobble',
+            'timestamp[0]': timestamp,
+            'track[0]': track,
+            'artist[0]': artist,
+            api_key: options.apiKey,
+            sk: session.key
+          };
+
+          if ((album !='') && (album != null)) {
+            params['album[0]'] = album;
+          }
+
+          params.api_sig = generateSign(params);
+
+          var url = apiUrl + '?' + createQueryString(params) + '&format=json';
+          $http.post(url).success(function(data){
+            if (callback != null) {
+              callback(data);
+            }
+          });
+        });
+      };
+
+      function getUserInfo(callback) {
+        getSession(function(session){
+          if (session == null) {
+            callback(null);
+            return;
+          }
+          var params = {
+            method: 'user.getinfo',
+            api_key: options.apiKey,
+            sk: session.key
+          };
+
+          params.api_sig = generateSign(params);
+
+          var url = apiUrl + '?' + createQueryString(params) + '&format=json';
+          $http.post(url).success(function(data){
+            if (callback != null) {
+              callback(data);
+            }
+          });
+        });
+      };
+
+      function isAuthorized() {
+        return (status == 2);
+      }
+
+      function isAuthRequested() {
+        return !(status == 0);
+      }
+
+      function getStatusText() {
+        if(status == 0) {
+          return '未连接';
+        }
+        if(status == 1) {
+          return '连接中';
+        }
+        if(status == 2) {
+          return '已连接';
+        }
+      }
+
+      var publicApi = {
+        getAuth               : getAuth,
+        cancelAuth            : cancelAuth,
+        getSession            : getSession,
+        sendNowPlaying        : sendNowPlaying,
+        scrobble              : scrobble,
+        getUserInfo           : getUserInfo,
+        getStatusText         : getStatusText,
+        updateStatus          : updateStatus,
+        isAuthorized          : isAuthorized,
+        isAuthRequested       : isAuthRequested
+      };
+
+      return publicApi;
+    }];
+  });
+}) ();
+
diff --git a/js/netease.js b/js/provider/netease.js
similarity index 100%
rename from js/netease.js
rename to js/provider/netease.js
diff --git a/js/qq.js b/js/provider/qq.js
similarity index 99%
rename from js/qq.js
rename to js/provider/qq.js
index 7444f85..8ef34c7 100644
--- a/js/qq.js
+++ b/js/provider/qq.js
@@ -47,6 +47,9 @@ var qq = (function() {
     }
 
     function qq_get_image_url(qqimgid, img_type) {
+        if (qqimgid == null) {
+            return '';
+        }
         var category = '';
         if(img_type == 'artist') {
             category = 'mid_singer_300'
@@ -54,6 +57,7 @@ var qq = (function() {
         if(img_type == 'album') {
             category = 'mid_album_300';
         }
+
         var s = [category, qqimgid[qqimgid.length - 2], qqimgid[qqimgid.length - 1], qqimgid].join('/');
         var url = 'http://imgcache.qq.com/music/photo/' + s + '.jpg';
         return url;
diff --git a/js/xiami.js b/js/provider/xiami.js
similarity index 100%
rename from js/xiami.js
rename to js/provider/xiami.js
diff --git a/js/aes.js b/js/vendor/aes.js
similarity index 100%
rename from js/aes.js
rename to js/vendor/aes.js
diff --git a/js/bigint.js b/js/vendor/bigint.js
similarity index 100%
rename from js/bigint.js
rename to js/vendor/bigint.js
diff --git a/js/vendor/hotkeys.js b/js/vendor/hotkeys.js
new file mode 100644
index 0000000..7eb55f4
--- /dev/null
+++ b/js/vendor/hotkeys.js
@@ -0,0 +1,1661 @@
+/*! 
+ * angular-hotkeys v1.7.0
+ * https://chieffancypants.github.io/angular-hotkeys
+ * Copyright (c) 2016 Wes Cruver
+ * License: MIT
+ */
+/*
+ * angular-hotkeys
+ *
+ * Automatic keyboard shortcuts for your angular apps
+ *
+ * (c) 2016 Wes Cruver
+ * License: MIT
+ */
+
+(function() {
+
+  'use strict';
+
+  angular.module('cfp.hotkeys', []).provider('hotkeys', ['$injector', function($injector) {
+
+    /**
+     * Configurable setting to disable the cheatsheet entirely
+     * @type {Boolean}
+     */
+    this.includeCheatSheet = true;
+
+    /**
+     * Configurable setting to disable ngRoute hooks
+     * @type {Boolean}
+     */
+    this.useNgRoute = $injector.has('ngViewDirective');
+
+    /**
+     * Configurable setting for the cheat sheet title
+     * @type {String}
+     */
+
+    this.templateTitle = 'Keyboard Shortcuts:';
+
+    /**
+     * Configurable settings for the cheat sheet header and footer.  Both are HTML, and the header
+     * overrides the normal title if specified.
+     * @type {String}
+     */
+    this.templateHeader = null;
+    this.templateFooter = null;
+
+    /**
+     * Cheat sheet template in the event you want to totally customize it.
+     * @type {String}
+     */
+    this.template = '<div class="cfp-hotkeys-container fade" ng-class="{in: helpVisible}" style="display: none;"><div class="cfp-hotkeys">' +
+                      '<h4 class="cfp-hotkeys-title" ng-if="!header">{{ title }}</h4>' +
+                      '<div ng-bind-html="header" ng-if="header"></div>' +
+                      '<table><tbody>' +
+                        '<tr ng-repeat="hotkey in hotkeys | filter:{ description: \'!$$undefined$$\' }">' +
+                          '<td class="cfp-hotkeys-keys">' +
+                            '<span ng-repeat="key in hotkey.format() track by $index" class="cfp-hotkeys-key">{{ key }}</span>' +
+                          '</td>' +
+                          '<td class="cfp-hotkeys-text">{{ hotkey.description }}</td>' +
+                        '</tr>' +
+                      '</tbody></table>' +
+                      '<div ng-bind-html="footer" ng-if="footer"></div>' +
+                      '<div class="cfp-hotkeys-close" ng-click="toggleCheatSheet()">&#215;</div>' +
+                    '</div></div>';
+
+    /**
+     * Configurable setting for the cheat sheet hotkey
+     * @type {String}
+     */
+    this.cheatSheetHotkey = '?';
+
+    /**
+     * Configurable setting for the cheat sheet description
+     * @type {String}
+     */
+    this.cheatSheetDescription = 'Show / hide this help menu';
+
+    this.$get = ['$rootElement', '$rootScope', '$compile', '$window', '$document', function ($rootElement, $rootScope, $compile, $window, $document) {
+
+      var mouseTrapEnabled = true;
+
+      function pause() {
+        mouseTrapEnabled = false;
+      }
+
+      function unpause() {
+        mouseTrapEnabled = true;
+      }
+
+      // monkeypatch Mousetrap's stopCallback() function
+      // this version doesn't return true when the element is an INPUT, SELECT, or TEXTAREA
+      // (instead we will perform this check per-key in the _add() method)
+      Mousetrap.prototype.stopCallback = function(event, element) {
+        if (!mouseTrapEnabled) {
+          return true;
+        }
+
+        // if the element has the class "mousetrap" then no need to stop
+        if ((' ' + element.className + ' ').indexOf(' mousetrap ') > -1) {
+          return false;
+        }
+
+        return (element.contentEditable && element.contentEditable == 'true');
+      };
+
+      /**
+       * Convert strings like cmd into symbols like ⌘
+       * @param  {String} combo Key combination, e.g. 'mod+f'
+       * @return {String}       The key combination with symbols
+       */
+      function symbolize (combo) {
+        var map = {
+          command   : '\u2318',     // ⌘
+          shift     : '\u21E7',     // ⇧
+          left      : '\u2190',     // ←
+          right     : '\u2192',     // →
+          up        : '\u2191',     // ↑
+          down      : '\u2193',     // ↓
+          'return'  : '\u23CE',     // ⏎
+          backspace : '\u232B'      // ⌫
+        };
+        combo = combo.split('+');
+
+        for (var i = 0; i < combo.length; i++) {
+          // try to resolve command / ctrl based on OS:
+          if (combo[i] === 'mod') {
+            if ($window.navigator && $window.navigator.platform.indexOf('Mac') >=0 ) {
+              combo[i] = 'command';
+            } else {
+              combo[i] = 'ctrl';
+            }
+          }
+
+          combo[i] = map[combo[i]] || combo[i];
+        }
+
+        return combo.join(' + ');
+      }
+
+      /**
+       * Hotkey object used internally for consistency
+       *
+       * @param {array}    combo       The keycombo. it's an array to support multiple combos
+       * @param {String}   description Description for the keycombo
+       * @param {Function} callback    function to execute when keycombo pressed
+       * @param {string}   action      the type of event to listen for (for mousetrap)
+       * @param {array}    allowIn     an array of tag names to allow this combo in ('INPUT', 'SELECT', and/or 'TEXTAREA')
+       * @param {Boolean}  persistent  Whether the hotkey persists navigation events
+       */
+      function Hotkey (combo, description, callback, action, allowIn, persistent) {
+        // TODO: Check that the values are sane because we could
+        // be trying to instantiate a new Hotkey with outside dev's
+        // supplied values
+
+        this.combo = combo instanceof Array ? combo : [combo];
+        this.description = description;
+        this.callback = callback;
+        this.action = action;
+        this.allowIn = allowIn;
+        this.persistent = persistent;
+        this._formated = null;
+      }
+
+      /**
+       * Helper method to format (symbolize) the key combo for display
+       *
+       * @return {[Array]} An array of the key combination sequence
+       *   for example: "command+g c i" becomes ["⌘ + g", "c", "i"]
+       *
+       */
+      Hotkey.prototype.format = function() {
+        if (this._formated === null) {
+          // Don't show all the possible key combos, just the first one.  Not sure
+          // of usecase here, so open a ticket if my assumptions are wrong
+          var combo = this.combo[0];
+
+          var sequence = combo.split(/[\s]/);
+          for (var i = 0; i < sequence.length; i++) {
+            sequence[i] = symbolize(sequence[i]);
+          }
+          this._formated = sequence;
+        }
+
+        return this._formated;
+      };
+
+      /**
+       * A new scope used internally for the cheatsheet
+       * @type {$rootScope.Scope}
+       */
+      var scope = $rootScope.$new();
+
+      /**
+       * Holds an array of Hotkey objects currently bound
+       * @type {Array}
+       */
+      scope.hotkeys = [];
+
+      /**
+       * Contains the state of the help's visibility
+       * @type {Boolean}
+       */
+      scope.helpVisible = false;
+
+      /**
+       * Holds the title string for the help menu
+       * @type {String}
+       */
+      scope.title = this.templateTitle;
+
+      /**
+       * Holds the header HTML for the help menu
+       * @type {String}
+       */
+      scope.header = this.templateHeader;
+
+      /**
+       * Holds the footer HTML for the help menu
+       * @type {String}
+       */
+      scope.footer = this.templateFooter;
+
+      /**
+       * Expose toggleCheatSheet to hotkeys scope so we can call it using
+       * ng-click from the template
+       * @type {function}
+       */
+      scope.toggleCheatSheet = toggleCheatSheet;
+
+
+      /**
+       * Holds references to the different scopes that have bound hotkeys
+       * attached.  This is useful to catch when the scopes are `$destroy`d and
+       * then automatically unbind the hotkey.
+       *
+       * @type {Object}
+       */
+      var boundScopes = {};
+
+      if (this.useNgRoute) {
+        $rootScope.$on('$routeChangeSuccess', function (event, route) {
+          purgeHotkeys();
+
+          if (route && route.hotkeys) {
+            angular.forEach(route.hotkeys, function (hotkey) {
+              // a string was given, which implies this is a function that is to be
+              // $eval()'d within that controller's scope
+              // TODO: hotkey here is super confusing.  sometimes a function (that gets turned into an array), sometimes a string
+              var callback = hotkey[2];
+              if (typeof(callback) === 'string' || callback instanceof String) {
+                hotkey[2] = [callback, route];
+              }
+
+              // todo: perform check to make sure not already defined:
+              // this came from a route, so it's likely not meant to be persistent
+              hotkey[5] = false;
+              _add.apply(this, hotkey);
+            });
+          }
+        });
+      }
+
+
+
+      // Auto-create a help menu:
+      if (this.includeCheatSheet) {
+        var document = $document[0];
+        var element = $rootElement[0];
+        var helpMenu = angular.element(this.template);
+        _add(this.cheatSheetHotkey, this.cheatSheetDescription, toggleCheatSheet);
+
+        // If $rootElement is document or documentElement, then body must be used
+        if (element === document || element === document.documentElement) {
+          element = document.body;
+        }
+
+        angular.element(element).append($compile(helpMenu)(scope));
+      }
+
+
+      /**
+       * Purges all non-persistent hotkeys (such as those defined in routes)
+       *
+       * Without this, the same hotkey would get recreated everytime
+       * the route is accessed.
+       */
+      function purgeHotkeys() {
+        var i = scope.hotkeys.length;
+        while (i--) {
+          var hotkey = scope.hotkeys[i];
+          if (hotkey && !hotkey.persistent) {
+            _del(hotkey);
+          }
+        }
+      }
+
+      /**
+       * Toggles the help menu element's visiblity
+       */
+      var previousEsc = false;
+
+      function toggleCheatSheet() {
+        scope.helpVisible = !scope.helpVisible;
+
+        // Bind to esc to remove the cheat sheet.  Ideally, this would be done
+        // as a directive in the template, but that would create a nasty
+        // circular dependency issue that I don't feel like sorting out.
+        if (scope.helpVisible) {
+          previousEsc = _get('esc');
+          _del('esc');
+
+          // Here's an odd way to do this: we're going to use the original
+          // description of the hotkey on the cheat sheet so that it shows up.
+          // without it, no entry for esc will ever show up (#22)
+          _add('esc', previousEsc.description, toggleCheatSheet, null, ['INPUT', 'SELECT', 'TEXTAREA']);
+        } else {
+          _del('esc');
+
+          // restore the previously bound ESC key
+          if (previousEsc !== false) {
+            _add(previousEsc);
+          }
+        }
+      }
+
+      /**
+       * Creates a new Hotkey and creates the Mousetrap binding
+       *
+       * @param {string}   combo       mousetrap key binding
+       * @param {string}   description description for the help menu
+       * @param {Function} callback    method to call when key is pressed
+       * @param {string}   action      the type of event to listen for (for mousetrap)
+       * @param {array}    allowIn     an array of tag names to allow this combo in ('INPUT', 'SELECT', and/or 'TEXTAREA')
+       * @param {boolean}  persistent  if true, the binding is preserved upon route changes
+       */
+      function _add (combo, description, callback, action, allowIn, persistent) {
+
+        // used to save original callback for "allowIn" wrapping:
+        var _callback;
+
+        // these elements are prevented by the default Mousetrap.stopCallback():
+        var preventIn = ['INPUT', 'SELECT', 'TEXTAREA'];
+
+        // Determine if object format was given:
+        var objType = Object.prototype.toString.call(combo);
+
+        if (objType === '[object Object]') {
+          description = combo.description;
+          callback    = combo.callback;
+          action      = combo.action;
+          persistent  = combo.persistent;
+          allowIn     = combo.allowIn;
+          combo       = combo.combo;
+        }
+
+        // no duplicates please
+        _del(combo);
+
+        // description is optional:
+        if (description instanceof Function) {
+          action = callback;
+          callback = description;
+          description = '$$undefined$$';
+        } else if (angular.isUndefined(description)) {
+          description = '$$undefined$$';
+        }
+
+        // any items added through the public API are for controllers
+        // that persist through navigation, and thus undefined should mean
+        // true in this case.
+        if (persistent === undefined) {
+          persistent = true;
+        }
+        // if callback is defined, then wrap it in a function
+        // that checks if the event originated from a form element.
+        // the function blocks the callback from executing unless the element is specified
+        // in allowIn (emulates Mousetrap.stopCallback() on a per-key level)
+        if (typeof callback === 'function') {
+
+          // save the original callback
+          _callback = callback;
+
+          // make sure allowIn is an array
+          if (!(allowIn instanceof Array)) {
+            allowIn = [];
+          }
+
+          // remove anything from preventIn that's present in allowIn
+          var index;
+          for (var i=0; i < allowIn.length; i++) {
+            allowIn[i] = allowIn[i].toUpperCase();
+            index = preventIn.indexOf(allowIn[i]);
+            if (index !== -1) {
+              preventIn.splice(index, 1);
+            }
+          }
+
+          // create the new wrapper callback
+          callback = function(event) {
+            var shouldExecute = true;
+
+            // if the callback is executed directly `hotkey.get('w').callback()`
+            // there will be no event, so just execute the callback.
+            if (event) {
+              var target = event.target || event.srcElement; // srcElement is IE only
+              var nodeName = target.nodeName.toUpperCase();
+
+              // check if the input has a mousetrap class, and skip checking preventIn if so
+              if ((' ' + target.className + ' ').indexOf(' mousetrap ') > -1) {
+                shouldExecute = true;
+              } else {
+                // don't execute callback if the event was fired from inside an element listed in preventIn
+                for (var i=0; i<preventIn.length; i++) {
+                  if (preventIn[i] === nodeName) {
+                    shouldExecute = false;
+                    break;
+                  }
+                }
+              }
+            }
+
+            if (shouldExecute) {
+              wrapApply(_callback.apply(this, arguments));
+            }
+          };
+        }
+
+        if (typeof(action) === 'string') {
+          Mousetrap.bind(combo, wrapApply(callback), action);
+        } else {
+          Mousetrap.bind(combo, wrapApply(callback));
+        }
+
+        var hotkey = new Hotkey(combo, description, callback, action, allowIn, persistent);
+        scope.hotkeys.push(hotkey);
+        return hotkey;
+      }
+
+      /**
+       * delete and unbind a Hotkey
+       *
+       * @param  {mixed} hotkey   Either the bound key or an instance of Hotkey
+       * @return {boolean}        true if successful
+       */
+      function _del (hotkey) {
+        var combo = (hotkey instanceof Hotkey) ? hotkey.combo : hotkey;
+
+        Mousetrap.unbind(combo);
+
+        if (angular.isArray(combo)) {
+          var retStatus = true;
+          var i = combo.length;
+          while (i--) {
+            retStatus = _del(combo[i]) && retStatus;
+          }
+          return retStatus;
+        } else {
+          var index = scope.hotkeys.indexOf(_get(combo));
+
+          if (index > -1) {
+            // if the combo has other combos bound, don't unbind the whole thing, just the one combo:
+            if (scope.hotkeys[index].combo.length > 1) {
+              scope.hotkeys[index].combo.splice(scope.hotkeys[index].combo.indexOf(combo), 1);
+            } else {
+
+              // remove hotkey from bound scopes
+              angular.forEach(boundScopes, function (boundScope) {
+                var scopeIndex = boundScope.indexOf(scope.hotkeys[index]);
+                if (scopeIndex !== -1) {
+                    boundScope.splice(scopeIndex, 1);
+                }
+              });
+
+              scope.hotkeys.splice(index, 1);
+            }
+            return true;
+          }
+        }
+
+        return false;
+
+      }
+
+      /**
+       * Get a Hotkey object by key binding
+       *
+       * @param  {[string]} [combo]  the key the Hotkey is bound to. Returns all key bindings if no key is passed
+       * @return {Hotkey}          The Hotkey object
+       */
+      function _get (combo) {
+
+        if (!combo) {
+          return scope.hotkeys;
+        }
+
+        var hotkey;
+
+        for (var i = 0; i < scope.hotkeys.length; i++) {
+          hotkey = scope.hotkeys[i];
+
+          if (hotkey.combo.indexOf(combo) > -1) {
+            return hotkey;
+          }
+        }
+
+        return false;
+      }
+
+      /**
+       * Binds the hotkey to a particular scope.  Useful if the scope is
+       * destroyed, we can automatically destroy the hotkey binding.
+       *
+       * @param  {Object} scope The scope to bind to
+       */
+      function bindTo (scope) {
+        // Only initialize once to allow multiple calls for same scope.
+        if (!(scope.$id in boundScopes)) {
+
+          // Add the scope to the list of bound scopes
+          boundScopes[scope.$id] = [];
+
+          scope.$on('$destroy', function () {
+            var i = boundScopes[scope.$id].length;
+            while (i--) {
+              _del(boundScopes[scope.$id].pop());
+            }
+          });
+        }
+        // return an object with an add function so we can keep track of the
+        // hotkeys and their scope that we added via this chaining method
+        return {
+          add: function (args) {
+            var hotkey;
+
+            if (arguments.length > 1) {
+              hotkey = _add.apply(this, arguments);
+            } else {
+              hotkey = _add(args);
+            }
+
+            boundScopes[scope.$id].push(hotkey);
+            return this;
+          }
+        };
+      }
+
+      /**
+       * All callbacks sent to Mousetrap are wrapped using this function
+       * so that we can force a $scope.$apply()
+       *
+       * @param  {Function} callback [description]
+       * @return {[type]}            [description]
+       */
+      function wrapApply (callback) {
+        // return mousetrap a function to call
+        return function (event, combo) {
+
+          // if this is an array, it means we provided a route object
+          // because the scope wasn't available yet, so rewrap the callback
+          // now that the scope is available:
+          if (callback instanceof Array) {
+            var funcString = callback[0];
+            var route = callback[1];
+            callback = function (event) {
+              route.scope.$eval(funcString);
+            };
+          }
+
+          // this takes place outside angular, so we'll have to call
+          // $apply() to make sure angular's digest happens
+          $rootScope.$apply(function() {
+            // call the original hotkey callback with the keyboard event
+            callback(event, _get(combo));
+          });
+        };
+      }
+
+      var publicApi = {
+        add                   : _add,
+        del                   : _del,
+        get                   : _get,
+        bindTo                : bindTo,
+        template              : this.template,
+        toggleCheatSheet      : toggleCheatSheet,
+        includeCheatSheet     : this.includeCheatSheet,
+        cheatSheetHotkey      : this.cheatSheetHotkey,
+        cheatSheetDescription : this.cheatSheetDescription,
+        useNgRoute            : this.useNgRoute,
+        purgeHotkeys          : purgeHotkeys,
+        templateTitle         : this.templateTitle,
+        pause                 : pause,
+        unpause               : unpause
+      };
+
+      return publicApi;
+
+    }];
+
+
+  }])
+
+  .directive('hotkey', ['hotkeys', function (hotkeys) {
+    return {
+      restrict: 'A',
+      link: function (scope, el, attrs) {
+        var keys = [],
+            allowIn;
+
+        angular.forEach(scope.$eval(attrs.hotkey), function (func, hotkey) {
+          // split and trim the hotkeys string into array
+          allowIn = typeof attrs.hotkeyAllowIn === "string" ? attrs.hotkeyAllowIn.split(/[\s,]+/) : [];
+
+          keys.push(hotkey);
+
+          hotkeys.add({
+            combo: hotkey,
+            description: attrs.hotkeyDescription,
+            callback: func,
+            action: attrs.hotkeyAction,
+            allowIn: allowIn
+          });
+        });
+
+        // remove the hotkey if the directive is destroyed:
+        el.bind('$destroy', function() {
+          angular.forEach(keys, hotkeys.del);
+        });
+      }
+    };
+  }])
+
+  .run(['hotkeys', function(hotkeys) {
+    // force hotkeys to run by injecting it. Without this, hotkeys only runs
+    // when a controller or something else asks for it via DI.
+  }]);
+
+})();
+
+/*global define:false */
+/**
+ * Copyright 2015 Craig Campbell
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ * Mousetrap is a simple keyboard shortcut library for Javascript with
+ * no external dependencies
+ *
+ * @version 1.5.2
+ * @url craig.is/killing/mice
+ */
+(function(window, document, undefined) {
+
+    /**
+     * mapping of special keycodes to their corresponding keys
+     *
+     * everything in this dictionary cannot use keypress events
+     * so it has to be here to map to the correct keycodes for
+     * keyup/keydown events
+     *
+     * @type {Object}
+     */
+    var _MAP = {
+        8: 'backspace',
+        9: 'tab',
+        13: 'enter',
+        16: 'shift',
+        17: 'ctrl',
+        18: 'alt',
+        20: 'capslock',
+        27: 'esc',
+        32: 'space',
+        33: 'pageup',
+        34: 'pagedown',
+        35: 'end',
+        36: 'home',
+        37: 'left',
+        38: 'up',
+        39: 'right',
+        40: 'down',
+        45: 'ins',
+        46: 'del',
+        91: 'meta',
+        93: 'meta',
+        224: 'meta'
+    };
+
+    /**
+     * mapping for special characters so they can support
+     *
+     * this dictionary is only used incase you want to bind a
+     * keyup or keydown event to one of these keys
+     *
+     * @type {Object}
+     */
+    var _KEYCODE_MAP = {
+        106: '*',
+        107: '+',
+        109: '-',
+        110: '.',
+        111 : '/',
+        186: ';',
+        187: '=',
+        188: ',',
+        189: '-',
+        190: '.',
+        191: '/',
+        192: '`',
+        219: '[',
+        220: '\\',
+        221: ']',
+        222: '\''
+    };
+
+    /**
+     * this is a mapping of keys that require shift on a US keypad
+     * back to the non shift equivelents
+     *
+     * this is so you can use keyup events with these keys
+     *
+     * note that this will only work reliably on US keyboards
+     *
+     * @type {Object}
+     */
+    var _SHIFT_MAP = {
+        '~': '`',
+        '!': '1',
+        '@': '2',
+        '#': '3',
+        '$': '4',
+        '%': '5',
+        '^': '6',
+        '&': '7',
+        '*': '8',
+        '(': '9',
+        ')': '0',
+        '_': '-',
+        '+': '=',
+        ':': ';',
+        '\"': '\'',
+        '<': ',',
+        '>': '.',
+        '?': '/',
+        '|': '\\'
+    };
+
+    /**
+     * this is a list of special strings you can use to map
+     * to modifier keys when you specify your keyboard shortcuts
+     *
+     * @type {Object}
+     */
+    var _SPECIAL_ALIASES = {
+        'option': 'alt',
+        'command': 'meta',
+        'return': 'enter',
+        'escape': 'esc',
+        'plus': '+',
+        'mod': /Mac|iPod|iPhone|iPad/.test(navigator.platform) ? 'meta' : 'ctrl'
+    };
+
+    /**
+     * variable to store the flipped version of _MAP from above
+     * needed to check if we should use keypress or not when no action
+     * is specified
+     *
+     * @type {Object|undefined}
+     */
+    var _REVERSE_MAP;
+
+    /**
+     * loop through the f keys, f1 to f19 and add them to the map
+     * programatically
+     */
+    for (var i = 1; i < 20; ++i) {
+        _MAP[111 + i] = 'f' + i;
+    }
+
+    /**
+     * loop through to map numbers on the numeric keypad
+     */
+    for (i = 0; i <= 9; ++i) {
+        _MAP[i + 96] = i;
+    }
+
+    /**
+     * cross browser add event method
+     *
+     * @param {Element|HTMLDocument} object
+     * @param {string} type
+     * @param {Function} callback
+     * @returns void
+     */
+    function _addEvent(object, type, callback) {
+        if (object.addEventListener) {
+            object.addEventListener(type, callback, false);
+            return;
+        }
+
+        object.attachEvent('on' + type, callback);
+    }
+
+    /**
+     * takes the event and returns the key character
+     *
+     * @param {Event} e
+     * @return {string}
+     */
+    function _characterFromEvent(e) {
+
+        // for keypress events we should return the character as is
+        if (e.type == 'keypress') {
+            var character = String.fromCharCode(e.which);
+
+            // if the shift key is not pressed then it is safe to assume
+            // that we want the character to be lowercase.  this means if
+            // you accidentally have caps lock on then your key bindings
+            // will continue to work
+            //
+            // the only side effect that might not be desired is if you
+            // bind something like 'A' cause you want to trigger an
+            // event when capital A is pressed caps lock will no longer
+            // trigger the event.  shift+a will though.
+            if (!e.shiftKey) {
+                character = character.toLowerCase();
+            }
+
+            return character;
+        }
+
+        // for non keypress events the special maps are needed
+        if (_MAP[e.which]) {
+            return _MAP[e.which];
+        }
+
+        if (_KEYCODE_MAP[e.which]) {
+            return _KEYCODE_MAP[e.which];
+        }
+
+        // if it is not in the special map
+
+        // with keydown and keyup events the character seems to always
+        // come in as an uppercase character whether you are pressing shift
+        // or not.  we should make sure it is always lowercase for comparisons
+        return String.fromCharCode(e.which).toLowerCase();
+    }
+
+    /**
+     * checks if two arrays are equal
+     *
+     * @param {Array} modifiers1
+     * @param {Array} modifiers2
+     * @returns {boolean}
+     */
+    function _modifiersMatch(modifiers1, modifiers2) {
+        return modifiers1.sort().join(',') === modifiers2.sort().join(',');
+    }
+
+    /**
+     * takes a key event and figures out what the modifiers are
+     *
+     * @param {Event} e
+     * @returns {Array}
+     */
+    function _eventModifiers(e) {
+        var modifiers = [];
+
+        if (e.shiftKey) {
+            modifiers.push('shift');
+        }
+
+        if (e.altKey) {
+            modifiers.push('alt');
+        }
+
+        if (e.ctrlKey) {
+            modifiers.push('ctrl');
+        }
+
+        if (e.metaKey) {
+            modifiers.push('meta');
+        }
+
+        return modifiers;
+    }
+
+    /**
+     * prevents default for this event
+     *
+     * @param {Event} e
+     * @returns void
+     */
+    function _preventDefault(e) {
+        if (e.preventDefault) {
+            e.preventDefault();
+            return;
+        }
+
+        e.returnValue = false;
+    }
+
+    /**
+     * stops propogation for this event
+     *
+     * @param {Event} e
+     * @returns void
+     */
+    function _stopPropagation(e) {
+        if (e.stopPropagation) {
+            e.stopPropagation();
+            return;
+        }
+
+        e.cancelBubble = true;
+    }
+
+    /**
+     * determines if the keycode specified is a modifier key or not
+     *
+     * @param {string} key
+     * @returns {boolean}
+     */
+    function _isModifier(key) {
+        return key == 'shift' || key == 'ctrl' || key == 'alt' || key == 'meta';
+    }
+
+    /**
+     * reverses the map lookup so that we can look for specific keys
+     * to see what can and can't use keypress
+     *
+     * @return {Object}
+     */
+    function _getReverseMap() {
+        if (!_REVERSE_MAP) {
+            _REVERSE_MAP = {};
+            for (var key in _MAP) {
+
+                // pull out the numeric keypad from here cause keypress should
+                // be able to detect the keys from the character
+                if (key > 95 && key < 112) {
+                    continue;
+                }
+
+                if (_MAP.hasOwnProperty(key)) {
+                    _REVERSE_MAP[_MAP[key]] = key;
+                }
+            }
+        }
+        return _REVERSE_MAP;
+    }
+
+    /**
+     * picks the best action based on the key combination
+     *
+     * @param {string} key - character for key
+     * @param {Array} modifiers
+     * @param {string=} action passed in
+     */
+    function _pickBestAction(key, modifiers, action) {
+
+        // if no action was picked in we should try to pick the one
+        // that we think would work best for this key
+        if (!action) {
+            action = _getReverseMap()[key] ? 'keydown' : 'keypress';
+        }
+
+        // modifier keys don't work as expected with keypress,
+        // switch to keydown
+        if (action == 'keypress' && modifiers.length) {
+            action = 'keydown';
+        }
+
+        return action;
+    }
+
+    /**
+     * Converts from a string key combination to an array
+     *
+     * @param  {string} combination like "command+shift+l"
+     * @return {Array}
+     */
+    function _keysFromString(combination) {
+        if (combination === '+') {
+            return ['+'];
+        }
+
+        combination = combination.replace(/\+{2}/g, '+plus');
+        return combination.split('+');
+    }
+
+    /**
+     * Gets info for a specific key combination
+     *
+     * @param  {string} combination key combination ("command+s" or "a" or "*")
+     * @param  {string=} action
+     * @returns {Object}
+     */
+    function _getKeyInfo(combination, action) {
+        var keys;
+        var key;
+        var i;
+        var modifiers = [];
+
+        // take the keys from this pattern and figure out what the actual
+        // pattern is all about
+        keys = _keysFromString(combination);
+
+        for (i = 0; i < keys.length; ++i) {
+            key = keys[i];
+
+            // normalize key names
+            if (_SPECIAL_ALIASES[key]) {
+                key = _SPECIAL_ALIASES[key];
+            }
+
+            // if this is not a keypress event then we should
+            // be smart about using shift keys
+            // this will only work for US keyboards however
+            if (action && action != 'keypress' && _SHIFT_MAP[key]) {
+                key = _SHIFT_MAP[key];
+                modifiers.push('shift');
+            }
+
+            // if this key is a modifier then add it to the list of modifiers
+            if (_isModifier(key)) {
+                modifiers.push(key);
+            }
+        }
+
+        // depending on what the key combination is
+        // we will try to pick the best event for it
+        action = _pickBestAction(key, modifiers, action);
+
+        return {
+            key: key,
+            modifiers: modifiers,
+            action: action
+        };
+    }
+
+    function _belongsTo(element, ancestor) {
+        if (element === document) {
+            return false;
+        }
+
+        if (element === ancestor) {
+            return true;
+        }
+
+        return _belongsTo(element.parentNode, ancestor);
+    }
+
+    function Mousetrap(targetElement) {
+        var self = this;
+
+        targetElement = targetElement || document;
+
+        if (!(self instanceof Mousetrap)) {
+            return new Mousetrap(targetElement);
+        }
+
+        /**
+         * element to attach key events to
+         *
+         * @type {Element}
+         */
+        self.target = targetElement;
+
+        /**
+         * a list of all the callbacks setup via Mousetrap.bind()
+         *
+         * @type {Object}
+         */
+        self._callbacks = {};
+
+        /**
+         * direct map of string combinations to callbacks used for trigger()
+         *
+         * @type {Object}
+         */
+        self._directMap = {};
+
+        /**
+         * keeps track of what level each sequence is at since multiple
+         * sequences can start out with the same sequence
+         *
+         * @type {Object}
+         */
+        var _sequenceLevels = {};
+
+        /**
+         * variable to store the setTimeout call
+         *
+         * @type {null|number}
+         */
+        var _resetTimer;
+
+        /**
+         * temporary state where we will ignore the next keyup
+         *
+         * @type {boolean|string}
+         */
+        var _ignoreNextKeyup = false;
+
+        /**
+         * temporary state where we will ignore the next keypress
+         *
+         * @type {boolean}
+         */
+        var _ignoreNextKeypress = false;
+
+        /**
+         * are we currently inside of a sequence?
+         * type of action ("keyup" or "keydown" or "keypress") or false
+         *
+         * @type {boolean|string}
+         */
+        var _nextExpectedAction = false;
+
+        /**
+         * resets all sequence counters except for the ones passed in
+         *
+         * @param {Object} doNotReset
+         * @returns void
+         */
+        function _resetSequences(doNotReset) {
+            doNotReset = doNotReset || {};
+
+            var activeSequences = false,
+                key;
+
+            for (key in _sequenceLevels) {
+                if (doNotReset[key]) {
+                    activeSequences = true;
+                    continue;
+                }
+                _sequenceLevels[key] = 0;
+            }
+
+            if (!activeSequences) {
+                _nextExpectedAction = false;
+            }
+        }
+
+        /**
+         * finds all callbacks that match based on the keycode, modifiers,
+         * and action
+         *
+         * @param {string} character
+         * @param {Array} modifiers
+         * @param {Event|Object} e
+         * @param {string=} sequenceName - name of the sequence we are looking for
+         * @param {string=} combination
+         * @param {number=} level
+         * @returns {Array}
+         */
+        function _getMatches(character, modifiers, e, sequenceName, combination, level) {
+            var i;
+            var callback;
+            var matches = [];
+            var action = e.type;
+
+            // if there are no events related to this keycode
+            if (!self._callbacks[character]) {
+                return [];
+            }
+
+            // if a modifier key is coming up on its own we should allow it
+            if (action == 'keyup' && _isModifier(character)) {
+                modifiers = [character];
+            }
+
+            // loop through all callbacks for the key that was pressed
+            // and see if any of them match
+            for (i = 0; i < self._callbacks[character].length; ++i) {
+                callback = self._callbacks[character][i];
+
+                // if a sequence name is not specified, but this is a sequence at
+                // the wrong level then move onto the next match
+                if (!sequenceName && callback.seq && _sequenceLevels[callback.seq] != callback.level) {
+                    continue;
+                }
+
+                // if the action we are looking for doesn't match the action we got
+                // then we should keep going
+                if (action != callback.action) {
+                    continue;
+                }
+
+                // if this is a keypress event and the meta key and control key
+                // are not pressed that means that we need to only look at the
+                // character, otherwise check the modifiers as well
+                //
+                // chrome will not fire a keypress if meta or control is down
+                // safari will fire a keypress if meta or meta+shift is down
+                // firefox will fire a keypress if meta or control is down
+                if ((action == 'keypress' && !e.metaKey && !e.ctrlKey) || _modifiersMatch(modifiers, callback.modifiers)) {
+
+                    // when you bind a combination or sequence a second time it
+                    // should overwrite the first one.  if a sequenceName or
+                    // combination is specified in this call it does just that
+                    //
+                    // @todo make deleting its own method?
+                    var deleteCombo = !sequenceName && callback.combo == combination;
+                    var deleteSequence = sequenceName && callback.seq == sequenceName && callback.level == level;
+                    if (deleteCombo || deleteSequence) {
+                        self._callbacks[character].splice(i, 1);
+                    }
+
+                    matches.push(callback);
+                }
+            }
+
+            return matches;
+        }
+
+        /**
+         * actually calls the callback function
+         *
+         * if your callback function returns false this will use the jquery
+         * convention - prevent default and stop propogation on the event
+         *
+         * @param {Function} callback
+         * @param {Event} e
+         * @returns void
+         */
+        function _fireCallback(callback, e, combo, sequence) {
+
+            // if this event should not happen stop here
+            if (self.stopCallback(e, e.target || e.srcElement, combo, sequence)) {
+                return;
+            }
+
+            if (callback(e, combo) === false) {
+                _preventDefault(e);
+                _stopPropagation(e);
+            }
+        }
+
+        /**
+         * handles a character key event
+         *
+         * @param {string} character
+         * @param {Array} modifiers
+         * @param {Event} e
+         * @returns void
+         */
+        self._handleKey = function(character, modifiers, e) {
+            var callbacks = _getMatches(character, modifiers, e);
+            var i;
+            var doNotReset = {};
+            var maxLevel = 0;
+            var processedSequenceCallback = false;
+
+            // Calculate the maxLevel for sequences so we can only execute the longest callback sequence
+            for (i = 0; i < callbacks.length; ++i) {
+                if (callbacks[i].seq) {
+                    maxLevel = Math.max(maxLevel, callbacks[i].level);
+                }
+            }
+
+            // loop through matching callbacks for this key event
+            for (i = 0; i < callbacks.length; ++i) {
+
+                // fire for all sequence callbacks
+                // this is because if for example you have multiple sequences
+                // bound such as "g i" and "g t" they both need to fire the
+                // callback for matching g cause otherwise you can only ever
+                // match the first one
+                if (callbacks[i].seq) {
+
+                    // only fire callbacks for the maxLevel to prevent
+                    // subsequences from also firing
+                    //
+                    // for example 'a option b' should not cause 'option b' to fire
+                    // even though 'option b' is part of the other sequence
+                    //
+                    // any sequences that do not match here will be discarded
+                    // below by the _resetSequences call
+                    if (callbacks[i].level != maxLevel) {
+                        continue;
+                    }
+
+                    processedSequenceCallback = true;
+
+                    // keep a list of which sequences were matches for later
+                    doNotReset[callbacks[i].seq] = 1;
+                    _fireCallback(callbacks[i].callback, e, callbacks[i].combo, callbacks[i].seq);
+                    continue;
+                }
+
+                // if there were no sequence matches but we are still here
+                // that means this is a regular match so we should fire that
+                if (!processedSequenceCallback) {
+                    _fireCallback(callbacks[i].callback, e, callbacks[i].combo);
+                }
+            }
+
+            // if the key you pressed matches the type of sequence without
+            // being a modifier (ie "keyup" or "keypress") then we should
+            // reset all sequences that were not matched by this event
+            //
+            // this is so, for example, if you have the sequence "h a t" and you
+            // type "h e a r t" it does not match.  in this case the "e" will
+            // cause the sequence to reset
+            //
+            // modifier keys are ignored because you can have a sequence
+            // that contains modifiers such as "enter ctrl+space" and in most
+            // cases the modifier key will be pressed before the next key
+            //
+            // also if you have a sequence such as "ctrl+b a" then pressing the
+            // "b" key will trigger a "keypress" and a "keydown"
+            //
+            // the "keydown" is expected when there is a modifier, but the
+            // "keypress" ends up matching the _nextExpectedAction since it occurs
+            // after and that causes the sequence to reset
+            //
+            // we ignore keypresses in a sequence that directly follow a keydown
+            // for the same character
+            var ignoreThisKeypress = e.type == 'keypress' && _ignoreNextKeypress;
+            if (e.type == _nextExpectedAction && !_isModifier(character) && !ignoreThisKeypress) {
+                _resetSequences(doNotReset);
+            }
+
+            _ignoreNextKeypress = processedSequenceCallback && e.type == 'keydown';
+        };
+
+        /**
+         * handles a keydown event
+         *
+         * @param {Event} e
+         * @returns void
+         */
+        function _handleKeyEvent(e) {
+
+            // normalize e.which for key events
+            // @see http://stackoverflow.com/questions/4285627/javascript-keycode-vs-charcode-utter-confusion
+            if (typeof e.which !== 'number') {
+                e.which = e.keyCode;
+            }
+
+            var character = _characterFromEvent(e);
+
+            // no character found then stop
+            if (!character) {
+                return;
+            }
+
+            // need to use === for the character check because the character can be 0
+            if (e.type == 'keyup' && _ignoreNextKeyup === character) {
+                _ignoreNextKeyup = false;
+                return;
+            }
+
+            self.handleKey(character, _eventModifiers(e), e);
+        }
+
+        /**
+         * called to set a 1 second timeout on the specified sequence
+         *
+         * this is so after each key press in the sequence you have 1 second
+         * to press the next key before you have to start over
+         *
+         * @returns void
+         */
+        function _resetSequenceTimer() {
+            clearTimeout(_resetTimer);
+            _resetTimer = setTimeout(_resetSequences, 1000);
+        }
+
+        /**
+         * binds a key sequence to an event
+         *
+         * @param {string} combo - combo specified in bind call
+         * @param {Array} keys
+         * @param {Function} callback
+         * @param {string=} action
+         * @returns void
+         */
+        function _bindSequence(combo, keys, callback, action) {
+
+            // start off by adding a sequence level record for this combination
+            // and setting the level to 0
+            _sequenceLevels[combo] = 0;
+
+            /**
+             * callback to increase the sequence level for this sequence and reset
+             * all other sequences that were active
+             *
+             * @param {string} nextAction
+             * @returns {Function}
+             */
+            function _increaseSequence(nextAction) {
+                return function() {
+                    _nextExpectedAction = nextAction;
+                    ++_sequenceLevels[combo];
+                    _resetSequenceTimer();
+                };
+            }
+
+            /**
+             * wraps the specified callback inside of another function in order
+             * to reset all sequence counters as soon as this sequence is done
+             *
+             * @param {Event} e
+             * @returns void
+             */
+            function _callbackAndReset(e) {
+                _fireCallback(callback, e, combo);
+
+                // we should ignore the next key up if the action is key down
+                // or keypress.  this is so if you finish a sequence and
+                // release the key the final key will not trigger a keyup
+                if (action !== 'keyup') {
+                    _ignoreNextKeyup = _characterFromEvent(e);
+                }
+
+                // weird race condition if a sequence ends with the key
+                // another sequence begins with
+                setTimeout(_resetSequences, 10);
+            }
+
+            // loop through keys one at a time and bind the appropriate callback
+            // function.  for any key leading up to the final one it should
+            // increase the sequence. after the final, it should reset all sequences
+            //
+            // if an action is specified in the original bind call then that will
+            // be used throughout.  otherwise we will pass the action that the
+            // next key in the sequence should match.  this allows a sequence
+            // to mix and match keypress and keydown events depending on which
+            // ones are better suited to the key provided
+            for (var i = 0; i < keys.length; ++i) {
+                var isFinal = i + 1 === keys.length;
+                var wrappedCallback = isFinal ? _callbackAndReset : _increaseSequence(action || _getKeyInfo(keys[i + 1]).action);
+                _bindSingle(keys[i], wrappedCallback, action, combo, i);
+            }
+        }
+
+        /**
+         * binds a single keyboard combination
+         *
+         * @param {string} combination
+         * @param {Function} callback
+         * @param {string=} action
+         * @param {string=} sequenceName - name of sequence if part of sequence
+         * @param {number=} level - what part of the sequence the command is
+         * @returns void
+         */
+        function _bindSingle(combination, callback, action, sequenceName, level) {
+
+            // store a direct mapped reference for use with Mousetrap.trigger
+            self._directMap[combination + ':' + action] = callback;
+
+            // make sure multiple spaces in a row become a single space
+            combination = combination.replace(/\s+/g, ' ');
+
+            var sequence = combination.split(' ');
+            var info;
+
+            // if this pattern is a sequence of keys then run through this method
+            // to reprocess each pattern one key at a time
+            if (sequence.length > 1) {
+                _bindSequence(combination, sequence, callback, action);
+                return;
+            }
+
+            info = _getKeyInfo(combination, action);
+
+            // make sure to initialize array if this is the first time
+            // a callback is added for this key
+            self._callbacks[info.key] = self._callbacks[info.key] || [];
+
+            // remove an existing match if there is one
+            _getMatches(info.key, info.modifiers, {type: info.action}, sequenceName, combination, level);
+
+            // add this call back to the array
+            // if it is a sequence put it at the beginning
+            // if not put it at the end
+            //
+            // this is important because the way these are processed expects
+            // the sequence ones to come first
+            self._callbacks[info.key][sequenceName ? 'unshift' : 'push']({
+                callback: callback,
+                modifiers: info.modifiers,
+                action: info.action,
+                seq: sequenceName,
+                level: level,
+                combo: combination
+            });
+        }
+
+        /**
+         * binds multiple combinations to the same callback
+         *
+         * @param {Array} combinations
+         * @param {Function} callback
+         * @param {string|undefined} action
+         * @returns void
+         */
+        self._bindMultiple = function(combinations, callback, action) {
+            for (var i = 0; i < combinations.length; ++i) {
+                _bindSingle(combinations[i], callback, action);
+            }
+        };
+
+        // start!
+        _addEvent(targetElement, 'keypress', _handleKeyEvent);
+        _addEvent(targetElement, 'keydown', _handleKeyEvent);
+        _addEvent(targetElement, 'keyup', _handleKeyEvent);
+    }
+
+    /**
+     * binds an event to mousetrap
+     *
+     * can be a single key, a combination of keys separated with +,
+     * an array of keys, or a sequence of keys separated by spaces
+     *
+     * be sure to list the modifier keys first to make sure that the
+     * correct key ends up getting bound (the last key in the pattern)
+     *
+     * @param {string|Array} keys
+     * @param {Function} callback
+     * @param {string=} action - 'keypress', 'keydown', or 'keyup'
+     * @returns void
+     */
+    Mousetrap.prototype.bind = function(keys, callback, action) {
+        var self = this;
+        keys = keys instanceof Array ? keys : [keys];
+        self._bindMultiple.call(self, keys, callback, action);
+        return self;
+    };
+
+    /**
+     * unbinds an event to mousetrap
+     *
+     * the unbinding sets the callback function of the specified key combo
+     * to an empty function and deletes the corresponding key in the
+     * _directMap dict.
+     *
+     * TODO: actually remove this from the _callbacks dictionary instead
+     * of binding an empty function
+     *
+     * the keycombo+action has to be exactly the same as
+     * it was defined in the bind method
+     *
+     * @param {string|Array} keys
+     * @param {string} action
+     * @returns void
+     */
+    Mousetrap.prototype.unbind = function(keys, action) {
+        var self = this;
+        return self.bind.call(self, keys, function() {}, action);
+    };
+
+    /**
+     * triggers an event that has already been bound
+     *
+     * @param {string} keys
+     * @param {string=} action
+     * @returns void
+     */
+    Mousetrap.prototype.trigger = function(keys, action) {
+        var self = this;
+        if (self._directMap[keys + ':' + action]) {
+            self._directMap[keys + ':' + action]({}, keys);
+        }
+        return self;
+    };
+
+    /**
+     * resets the library back to its initial state.  this is useful
+     * if you want to clear out the current keyboard shortcuts and bind
+     * new ones - for example if you switch to another page
+     *
+     * @returns void
+     */
+    Mousetrap.prototype.reset = function() {
+        var self = this;
+        self._callbacks = {};
+        self._directMap = {};
+        return self;
+    };
+
+    /**
+     * should we stop this event before firing off callbacks
+     *
+     * @param {Event} e
+     * @param {Element} element
+     * @return {boolean}
+     */
+    Mousetrap.prototype.stopCallback = function(e, element) {
+        var self = this;
+
+        // if the element has the class "mousetrap" then no need to stop
+        if ((' ' + element.className + ' ').indexOf(' mousetrap ') > -1) {
+            return false;
+        }
+
+        if (_belongsTo(element, self.target)) {
+            return false;
+        }
+
+        // stop for input, select, and textarea
+        return element.tagName == 'INPUT' || element.tagName == 'SELECT' || element.tagName == 'TEXTAREA' || element.isContentEditable;
+    };
+
+    /**
+     * exposes _handleKey publicly so it can be overwritten by extensions
+     */
+    Mousetrap.prototype.handleKey = function() {
+        var self = this;
+        return self._handleKey.apply(self, arguments);
+    };
+
+    /**
+     * Init the global mousetrap functions
+     *
+     * This method is needed to allow the global mousetrap functions to work
+     * now that mousetrap is a constructor function.
+     */
+    Mousetrap.init = function() {
+        var documentMousetrap = Mousetrap(document);
+        for (var method in documentMousetrap) {
+            if (method.charAt(0) !== '_') {
+                Mousetrap[method] = (function(method) {
+                    return function() {
+                        return documentMousetrap[method].apply(documentMousetrap, arguments);
+                    };
+                } (method));
+            }
+        }
+    };
+
+    Mousetrap.init();
+
+    // expose mousetrap to the global object
+    window.Mousetrap = Mousetrap;
+
+    // expose as a common js module
+    if (typeof module !== 'undefined' && module.exports) {
+        module.exports = Mousetrap;
+    }
+
+    // expose mousetrap as an AMD module
+    if (typeof define === 'function' && define.amd) {
+        define(function() {
+            return Mousetrap;
+        });
+    }
+}) (window, document);
diff --git a/js/vendor/md5.js b/js/vendor/md5.js
new file mode 100755
index 0000000..cfa9225
--- /dev/null
+++ b/js/vendor/md5.js
@@ -0,0 +1,207 @@
+/**
+*
+*  MD5 (Message-Digest Algorithm)
+*  http://www.webtoolkit.info/
+*  refer to: https://github.com/david-sabata/web-scrobbler/blob/master/vendor/md5.js
+**/
+
+function MD5(string) {
+
+	function RotateLeft(lValue, iShiftBits) {
+		return (lValue<<iShiftBits) | (lValue>>>(32-iShiftBits));
+	}
+
+	function AddUnsigned(lX,lY) {
+		var lX4,lY4,lX8,lY8,lResult;
+		lX8 = (lX & 0x80000000);
+		lY8 = (lY & 0x80000000);
+		lX4 = (lX & 0x40000000);
+		lY4 = (lY & 0x40000000);
+		lResult = (lX & 0x3FFFFFFF)+(lY & 0x3FFFFFFF);
+		if (lX4 & lY4) {
+			return (lResult ^ 0x80000000 ^ lX8 ^ lY8);
+		}
+		if (lX4 | lY4) {
+			if (lResult & 0x40000000) {
+				return (lResult ^ 0xC0000000 ^ lX8 ^ lY8);
+			} else {
+				return (lResult ^ 0x40000000 ^ lX8 ^ lY8);
+			}
+		} else {
+			return (lResult ^ lX8 ^ lY8);
+		}
+    }
+
+    function F(x,y,z) { return (x & y) | ((~x) & z); }
+    function G(x,y,z) { return (x & z) | (y & (~z)); }
+    function H(x,y,z) { return (x ^ y ^ z); }
+	function I(x,y,z) { return (y ^ (x | (~z))); }
+
+	function FF(a,b,c,d,x,s,ac) {
+		a = AddUnsigned(a, AddUnsigned(AddUnsigned(F(b, c, d), x), ac));
+		return AddUnsigned(RotateLeft(a, s), b);
+	};
+
+	function GG(a,b,c,d,x,s,ac) {
+		a = AddUnsigned(a, AddUnsigned(AddUnsigned(G(b, c, d), x), ac));
+		return AddUnsigned(RotateLeft(a, s), b);
+	};
+
+	function HH(a,b,c,d,x,s,ac) {
+		a = AddUnsigned(a, AddUnsigned(AddUnsigned(H(b, c, d), x), ac));
+		return AddUnsigned(RotateLeft(a, s), b);
+	};
+
+	function II(a,b,c,d,x,s,ac) {
+		a = AddUnsigned(a, AddUnsigned(AddUnsigned(I(b, c, d), x), ac));
+		return AddUnsigned(RotateLeft(a, s), b);
+	};
+
+	function ConvertToWordArray(string) {
+		var lWordCount;
+		var lMessageLength = string.length;
+		var lNumberOfWords_temp1=lMessageLength + 8;
+		var lNumberOfWords_temp2=(lNumberOfWords_temp1-(lNumberOfWords_temp1 % 64))/64;
+		var lNumberOfWords = (lNumberOfWords_temp2+1)*16;
+		var lWordArray=Array(lNumberOfWords-1);
+		var lBytePosition = 0;
+		var lByteCount = 0;
+		while ( lByteCount < lMessageLength ) {
+			lWordCount = (lByteCount-(lByteCount % 4))/4;
+			lBytePosition = (lByteCount % 4)*8;
+			lWordArray[lWordCount] = (lWordArray[lWordCount] | (string.charCodeAt(lByteCount)<<lBytePosition));
+			lByteCount++;
+		}
+		lWordCount = (lByteCount-(lByteCount % 4))/4;
+		lBytePosition = (lByteCount % 4)*8;
+		lWordArray[lWordCount] = lWordArray[lWordCount] | (0x80<<lBytePosition);
+		lWordArray[lNumberOfWords-2] = lMessageLength<<3;
+		lWordArray[lNumberOfWords-1] = lMessageLength>>>29;
+		return lWordArray;
+	};
+
+	function WordToHex(lValue) {
+		var WordToHexValue="",WordToHexValue_temp="",lByte,lCount;
+		for (lCount = 0;lCount<=3;lCount++) {
+			lByte = (lValue>>>(lCount*8)) & 255;
+			WordToHexValue_temp = "0" + lByte.toString(16);
+			WordToHexValue = WordToHexValue + WordToHexValue_temp.substr(WordToHexValue_temp.length-2,2);
+		}
+		return WordToHexValue;
+	};
+
+	function Utf8Encode(string) {
+		string = string.replace(/\r\n/g,"\n");
+		var utftext = "";
+
+		for (var n = 0; n < string.length; n++) {
+
+			var c = string.charCodeAt(n);
+
+			if (c < 128) {
+				utftext += String.fromCharCode(c);
+			}
+			else if((c > 127) && (c < 2048)) {
+				utftext += String.fromCharCode((c >> 6) | 192);
+				utftext += String.fromCharCode((c & 63) | 128);
+			}
+			else {
+				utftext += String.fromCharCode((c >> 12) | 224);
+				utftext += String.fromCharCode(((c >> 6) & 63) | 128);
+				utftext += String.fromCharCode((c & 63) | 128);
+			}
+
+		}
+
+		return utftext;
+	};
+
+	var x=Array();
+	var k,AA,BB,CC,DD,a,b,c,d;
+	var S11=7, S12=12, S13=17, S14=22;
+	var S21=5, S22=9 , S23=14, S24=20;
+	var S31=4, S32=11, S33=16, S34=23;
+	var S41=6, S42=10, S43=15, S44=21;
+
+	string = Utf8Encode(string);
+
+	x = ConvertToWordArray(string);
+
+	a = 0x67452301; b = 0xEFCDAB89; c = 0x98BADCFE; d = 0x10325476;
+
+	for (k=0;k<x.length;k+=16) {
+		AA=a; BB=b; CC=c; DD=d;
+		a=FF(a,b,c,d,x[k+0], S11,0xD76AA478);
+		d=FF(d,a,b,c,x[k+1], S12,0xE8C7B756);
+		c=FF(c,d,a,b,x[k+2], S13,0x242070DB);
+		b=FF(b,c,d,a,x[k+3], S14,0xC1BDCEEE);
+		a=FF(a,b,c,d,x[k+4], S11,0xF57C0FAF);
+		d=FF(d,a,b,c,x[k+5], S12,0x4787C62A);
+		c=FF(c,d,a,b,x[k+6], S13,0xA8304613);
+		b=FF(b,c,d,a,x[k+7], S14,0xFD469501);
+		a=FF(a,b,c,d,x[k+8], S11,0x698098D8);
+		d=FF(d,a,b,c,x[k+9], S12,0x8B44F7AF);
+		c=FF(c,d,a,b,x[k+10],S13,0xFFFF5BB1);
+		b=FF(b,c,d,a,x[k+11],S14,0x895CD7BE);
+		a=FF(a,b,c,d,x[k+12],S11,0x6B901122);
+		d=FF(d,a,b,c,x[k+13],S12,0xFD987193);
+		c=FF(c,d,a,b,x[k+14],S13,0xA679438E);
+		b=FF(b,c,d,a,x[k+15],S14,0x49B40821);
+		a=GG(a,b,c,d,x[k+1], S21,0xF61E2562);
+		d=GG(d,a,b,c,x[k+6], S22,0xC040B340);
+		c=GG(c,d,a,b,x[k+11],S23,0x265E5A51);
+		b=GG(b,c,d,a,x[k+0], S24,0xE9B6C7AA);
+		a=GG(a,b,c,d,x[k+5], S21,0xD62F105D);
+		d=GG(d,a,b,c,x[k+10],S22,0x2441453);
+		c=GG(c,d,a,b,x[k+15],S23,0xD8A1E681);
+		b=GG(b,c,d,a,x[k+4], S24,0xE7D3FBC8);
+		a=GG(a,b,c,d,x[k+9], S21,0x21E1CDE6);
+		d=GG(d,a,b,c,x[k+14],S22,0xC33707D6);
+		c=GG(c,d,a,b,x[k+3], S23,0xF4D50D87);
+		b=GG(b,c,d,a,x[k+8], S24,0x455A14ED);
+		a=GG(a,b,c,d,x[k+13],S21,0xA9E3E905);
+		d=GG(d,a,b,c,x[k+2], S22,0xFCEFA3F8);
+		c=GG(c,d,a,b,x[k+7], S23,0x676F02D9);
+		b=GG(b,c,d,a,x[k+12],S24,0x8D2A4C8A);
+		a=HH(a,b,c,d,x[k+5], S31,0xFFFA3942);
+		d=HH(d,a,b,c,x[k+8], S32,0x8771F681);
+		c=HH(c,d,a,b,x[k+11],S33,0x6D9D6122);
+		b=HH(b,c,d,a,x[k+14],S34,0xFDE5380C);
+		a=HH(a,b,c,d,x[k+1], S31,0xA4BEEA44);
+		d=HH(d,a,b,c,x[k+4], S32,0x4BDECFA9);
+		c=HH(c,d,a,b,x[k+7], S33,0xF6BB4B60);
+		b=HH(b,c,d,a,x[k+10],S34,0xBEBFBC70);
+		a=HH(a,b,c,d,x[k+13],S31,0x289B7EC6);
+		d=HH(d,a,b,c,x[k+0], S32,0xEAA127FA);
+		c=HH(c,d,a,b,x[k+3], S33,0xD4EF3085);
+		b=HH(b,c,d,a,x[k+6], S34,0x4881D05);
+		a=HH(a,b,c,d,x[k+9], S31,0xD9D4D039);
+		d=HH(d,a,b,c,x[k+12],S32,0xE6DB99E5);
+		c=HH(c,d,a,b,x[k+15],S33,0x1FA27CF8);
+		b=HH(b,c,d,a,x[k+2], S34,0xC4AC5665);
+		a=II(a,b,c,d,x[k+0], S41,0xF4292244);
+		d=II(d,a,b,c,x[k+7], S42,0x432AFF97);
+		c=II(c,d,a,b,x[k+14],S43,0xAB9423A7);
+		b=II(b,c,d,a,x[k+5], S44,0xFC93A039);
+		a=II(a,b,c,d,x[k+12],S41,0x655B59C3);
+		d=II(d,a,b,c,x[k+3], S42,0x8F0CCC92);
+		c=II(c,d,a,b,x[k+10],S43,0xFFEFF47D);
+		b=II(b,c,d,a,x[k+1], S44,0x85845DD1);
+		a=II(a,b,c,d,x[k+8], S41,0x6FA87E4F);
+		d=II(d,a,b,c,x[k+15],S42,0xFE2CE6E0);
+		c=II(c,d,a,b,x[k+6], S43,0xA3014314);
+		b=II(b,c,d,a,x[k+13],S44,0x4E0811A1);
+		a=II(a,b,c,d,x[k+4], S41,0xF7537E82);
+		d=II(d,a,b,c,x[k+11],S42,0xBD3AF235);
+		c=II(c,d,a,b,x[k+2], S43,0x2AD7D2BB);
+		b=II(b,c,d,a,x[k+9], S44,0xEB86D391);
+		a=AddUnsigned(a,AA);
+		b=AddUnsigned(b,BB);
+		c=AddUnsigned(c,CC);
+		d=AddUnsigned(d,DD);
+	}
+
+	var temp = WordToHex(a)+WordToHex(b)+WordToHex(c)+WordToHex(d);
+
+	return temp.toLowerCase();
+}
diff --git a/js/vendor/timer.js b/js/vendor/timer.js
new file mode 100644
index 0000000..2b576e1
--- /dev/null
+++ b/js/vendor/timer.js
@@ -0,0 +1,155 @@
+'use strict';
+
+/**
+ * Timer
+ * https://github.com/david-sabata/web-scrobbler/blob/master/core/background/timer.js
+ */
+function Timer() {
+
+    var callback = null,
+        timeoutId = null,
+        target = null, // target seconds
+        pausedOn = null, // marks pause time in seconds
+        startedOn = null, // marks start time in seconds
+        spentPaused = 0, // sum of paused time in seconds
+        hasTriggered = false; // already triggered callback?
+
+    /**
+     * Returns current time in seconds
+     */
+    function now() {
+        return Math.round((new Date()).valueOf() / 1000);
+    }
+
+    function setTrigger(seconds) {
+        clearTrigger();
+        timeoutId = setTimeout(function() {
+            callback();
+            hasTriggered = true;
+        }, seconds * 1000);
+    }
+
+    /**
+     * Clears internal timeout
+     */
+    function clearTrigger() {
+        if (timeoutId) {
+            clearTimeout(timeoutId);
+        }
+        timeoutId = null;
+    }
+
+
+
+
+    /**
+     * Set timer and define trigger callback.
+     * Use update function to define time to trigger.
+     */
+    this.start = function(cb) {
+        this.reset();
+        startedOn = now();
+        callback = cb;
+    };
+
+    /**
+     * Pause timer
+     */
+    this.pause = function() {
+        // only if timer was started and was running
+        if (startedOn !== null && pausedOn === null) {
+            pausedOn = now();
+            clearTrigger();
+        }
+    };
+
+    /**
+     * Unpause timer
+     */
+    this.resume = function() {
+        // only if timer was started and was paused
+        if (startedOn !== null && pausedOn !== null) {
+            spentPaused += now() - pausedOn;
+            pausedOn = null;
+
+            if (!hasTriggered && target !== null) {
+                setTrigger(target - this.getElapsed());
+            }
+        }
+    };
+
+    /**
+     * Update time for this timer before callback is triggered.
+     * Already elapsed time is not modified and callback
+     * will be triggered immediately if the new time is less than elapsed.
+     *
+     * Pass null to set destination time to 'never' - this prevents the timer from
+     * triggering but still keeps it counting time.
+     *
+     * Intentionally does not check if the callback was already triggered.
+     * This allows to update the timer after it went out once and still
+     * be able to properly trigger the callback for the new timeout.
+     */
+    this.update = function(seconds) {
+        // only if timer was started
+        if (startedOn !== null) {
+            target = seconds;
+
+            if (seconds !== null) {
+                if (pausedOn === null) {
+                    setTrigger(target - this.getElapsed());
+                }
+            } else {
+                clearTrigger();
+            }
+        }
+    };
+
+    /**
+     * Returns seconds passed from the timer was started.
+     * Time spent paused does not count
+     */
+    this.getElapsed = function() {
+        var val = now() - startedOn - spentPaused;
+
+        if (pausedOn !== null) {
+            val -= (now() - pausedOn);
+        }
+
+        return val;
+    };
+
+    /**
+     * Checks if current timer has already triggered its callback
+     */
+    this.hasTriggered = function() {
+        return hasTriggered;
+    };
+
+    /**
+     * Returns remaining (unpaused) seconds or null if no destination time is set
+     */
+    this.getRemainingSeconds = function() {
+        if (target === null) {
+            return null;
+        }
+
+        return target - this.getElapsed();
+    };
+
+    /**
+     * Reset timer
+     */
+    this.reset = function() {
+        target = null;
+        startedOn = null;
+        pausedOn = null;
+        spentPaused = 0;
+        callback = null;
+        hasTriggered = false;
+
+        clearTrigger();
+    };
+
+};
+
diff --git a/listen1.html b/listen1.html
index 88e09aa..5aacfe4 100644
--- a/listen1.html
+++ b/listen1.html
@@ -1,5 +1,5 @@
 <!DOCTYPE html>
-<html lang="en">
+<html lang="en" ng-app="listenone">
   <head>
     <meta charset="utf-8">
     <meta http-equiv="X-UA-Compatible" content="IE=edge">
@@ -8,33 +8,39 @@
     <meta name="description" content="">
     <meta name="author" content="">
 
-    <title>Listen 1</title>
+    <title ng-bind="page_title">Listen 1</title>
 
     <link href="css/bootstrap.min.css" rel="stylesheet">
     <link href="css/angular-ui-notification.css" rel="stylesheet">
 
     <link href="css/cover.css" rel="stylesheet">
     <link href="css/player.css" rel="stylesheet">
+    <link href="css/hotkeys.css" rel="stylesheet">
 
     <script type="text/javascript" src="js/vendor/jquery-1.12.2.js"></script>
     <script type="text/javascript" src="js/vendor/angular.min.js"></script>
     <script type="text/javascript" src="js/vendor/angular-soundmanager2.js"></script>
     <script type="text/javascript" src="js/vendor/angular-ui-notification.js"></script>
-    <!-- require jQuery as dependency -->
+    <script type="text/javascript" src="js/vendor/hotkeys.js"></script>
+    <script type="text/javascript" src="js/vendor/md5.js"></script>
+    <script type="text/javascript" src="js/vendor/aes.js"></script>
+    <script type="text/javascript" src="js/vendor/bigint.js"></script>
+    <script type="text/javascript" src="js/vendor/timer.js"></script>
+
+    <script type="text/javascript" src="js/lastfm.js"></script>
 
-    <script type="text/javascript" src="js/aes.js"></script>
-    <script type="text/javascript" src="js/bigint.js"></script>
     <script type="text/javascript" src="js/lowebutil.js"></script>
-    <script type="text/javascript" src="js/xiami.js"></script>
-    <script type="text/javascript" src="js/qq.js"></script>
-    <script type="text/javascript" src="js/netease.js"></script>
+    <script type="text/javascript" src="js/provider/xiami.js"></script>
+    <script type="text/javascript" src="js/provider/qq.js"></script>
+    <script type="text/javascript" src="js/provider/netease.js"></script>
+
     <script type="text/javascript" src="js/myplaylist.js"></script>
     <script type="text/javascript" src="js/loweb.js"></script>
     <script type="text/javascript" src="js/app.js"></script>
 
   </head>
 
-  <body ng-app="listenone"  ng-controller="NavigationController">
+  <body ng-controller="NavigationController">
 
     <!-- dialog -->
     <div class="shadow" ng-hide="is_dialog_hidden==1"></div>
@@ -91,12 +97,21 @@
           </div>
           
           <button class="btn btn-primary confirm-button" ng-click="editMyPlaylist(list_id)">修改歌单</button>
-          <button class="btn btn-default" ng-click="cancelNewDialog()">取消</button>
+          <button class="btn btn-default" ng-click="closeDialog()">取消</button>
           <div class='dialog-footer'> 
             <button class="btn btn-danger remove-button" ng-click="removeMyPlaylist(list_id)">删除歌单</button>
           </div>
         </div>
 
+        <div ng-show="dialog_type==4" class="dialog-connect-lastfm">
+          <p>正在打开Last.fm页面...</p>
+          <p>请在打开的页面点击"Yes, all access", 允许Listen 1访问你的账户。</p>
+          <div class="buttons">
+            <button class="btn btn-primary confirm-button" ng-click="lastfm.updateStatus();closeDialog();">已经完成授权</button>
+            <button class="btn btn-warning warning-button" ng-click="lastfm.getAuth();">遇到问题,再次打开授权页</button>
+          </div>
+        </div>
+
       </div>
     </div>
 
@@ -186,7 +201,7 @@
           <!-- Initialize a new AngularJS app and associate it with a module named "instantSearch"-->
           <div class="searchbox" ng-controller="InstantSearchController" >
             <!-- Create a binding between the searchString model and the text field -->
-            <input type="text" class="form-control" ng-model="keywords" placeholder="输入歌曲名,歌手或专辑"  ng-model-options="{debounce: 500}" />
+            <input type="text" id="search-input" class="form-control" ng-model="keywords" placeholder="输入歌曲名,歌手或专辑"  ng-model-options="{debounce: 500}" />
 
 
             <ul class="nav nav-tabs"> 
@@ -219,7 +234,7 @@
 
 
     <!-- content page: 设置 -->
-    <div class="site-wrapper" ng-show="current_tag==4">
+    <div class="site-wrapper" ng-show="current_tag==4" ng-init="lastfm.updateStatus()">
       <div class="site-wrapper-innerd" resize>
         <div class="cover-container">
          <!--  <div class="settings-title"><span>第三方登录<span></div>
@@ -241,6 +256,21 @@
               <input id="my-file-selector" type="file" style="display:none;" ng-model="myuploadfiles"  custom-on-change="importMySettings">上传备份文件
             </label>
           </div>
+          <div class="settings-title"><span>快捷键<span></div>
+          <div class="settings-content">
+            <div>
+              <button class="btn btn-primary confirm-button" ng-click="showShortcuts()">查看快捷键列表</button>
+            </div>
+          </div>
+          <div class="settings-title"><span>连接到 Last.fm<span></div>
+          <div class="settings-content">
+            <div>
+              <p> 状态:{{ lastfm.getStatusText() }} </p>
+              <button class="btn btn-primary confirm-button" ng-show="!lastfm.isAuthRequested()" ng-click="lastfm.getAuth(); showDialog(4);">连接到 Last.fm</button>
+              <button class="btn btn-warning confirm-button" ng-show="lastfm.isAuthRequested() && !lastfm.isAuthorized()" ng-click="lastfm.getAuth(); showDialog(4);">重新连接</button>
+              <button class="btn btn-primary confirm-button" ng-show="lastfm.isAuthRequested()" ng-click="lastfm.cancelAuth();">取消连接</button>
+            </div>
+          </div>
           <div class="settings-title"><span>关于<span></div>
           <div class="settings-content">
             <p> Listen 1 主页: <a href="http://listen1.github.io/listen1/" target="_blank"> http://listen1.github.io/listen1/ </a> </p>
@@ -302,9 +332,9 @@
     <div class="mastfoot" ng-controller="PlayController as playCtrl" ng-init="loadLocalSettings()">
       <div class="m-playbar" >
         <div class="btns">
-          <a class="previous" title="上一首" prev-track >上一首</a>
-          <a class="play" ng-class="{pas: isPlaying}" title="播放/暂停" play-pause-toggle>播放/暂停</a>
-          <a class="next" title="下一首" next-track>下一首</a>
+          <a class="previous" title="上一首([)" prev-track >上一首([)</a>
+          <a class="play" ng-class="{pas: isPlaying}" title="播放/暂停(p)" play-pause-toggle>播放/暂停(p)</a>
+          <a class="next" title="下一首(])" next-track>下一首(])</a>
         </div>
 
         <div class="head">
@@ -341,12 +371,12 @@
 
         <div class="ctrl">
           <a class="icn icn-add" ng-click="showDialog(0, currentPlaying)" title="添加到歌单">添加到歌单</a>
-          <a class="icn" ng-class="{ 'icn-shuffle': settings.playmode == 1, 'icn-loop': settings.playmode == 0 }" title="{{ settings.playmode | playmode_title }}" ng-click="changePlaymode()"></a>
-          <a class="icn icn-list" title="列表" ng-click="togglePlaylist()"></a>
+          <a class="icn" ng-class="{ 'icn-shuffle': settings.playmode == 1, 'icn-loop': settings.playmode == 0 }" title="{{ settings.playmode | playmode_title }}(s)" ng-click="changePlaymode()"></a>
+          <a class="icn icn-list" title="列表(l)" ng-click="togglePlaylist()"></a>
         </div>
 
         <div class="volume-ctrl">
-          <a class="icn" ng-class="{ 'icn-vol-mute': mute, 'icn-vol': mute == false }"  title="音量" ng-click="toggleMuteStatus()"></a>
+          <a class="icn" ng-class="{ 'icn-vol-mute': mute, 'icn-vol': mute == false }"  title="静音(m) 增大(u) 减少(d)" ng-click="toggleMuteStatus()"></a>
           <div class="m-pbar volume" >
             <div class="barbg" id="volumebar" mode="volume" draggable>
               <div class="cur" ng-style="{width : volume + '%' }">