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 = ''; + + /** + * 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 -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<>>(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)<>>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 - + @@ -8,33 +8,39 @@ - Listen 1 + Listen 1 + - + + + + + + + - - - - - + + + + - +
@@ -91,12 +97,21 @@ - + +
+

正在打开Last.fm页面...

+

请在打开的页面点击"Yes, all access", 允许Listen 1访问你的账户。

+
+ + +
+
+ @@ -186,7 +201,7 @@