Commits

Martin Tournoij committed f912dfa

Update

Comments (0)

Files changed (22)

 Nordavind is a web based audio player. The idea is that you can play your music
 collection at home anywhere.
 
-The UI is inspired by foobar2000, or rather, my particular foobar2000 setup,
-since Nordavind doesn’t offer the extreme flexibility that foobar2000 has.
+The UI is inspired by foobar2000, or rather, my particular foobar2000 setup (
+Nordavind doesn’t offer the *extreme* flexibility that foobar2000 has).
 
 
 Browser support
 In particular, I’ve tested Opera 12 and Firefox 23 on Windows & FreeBSD.
 I did some basic testing with Chrome 29 and IE10, which seem to work okay.
 
-Note that (most) smartphones & tablets won’t work very well at the moment,
-problems include reliance on double/middleclicking, awkward scrolling, and
-window size. Some works needs to be done here.
+Note that (most) smartphones & tablets won’t work very well at the moment due to
+a number of problems. Some works needs to be done here.
 
 
 Audio codecs
 - Internet Explorer and Safari will play MP3 files
 - Chrome will play both
 
-Nordavind will transparently convert this for you, but you should be aware that
+Nordavind will transparently convert files for you, but you should be aware that
 converting from MP3 to Ogg Vorbis (or vice versa) *will* reduce audio quality
-even at high bitrates because you’re converting from one lossless format to
-another. So you may want to choose your browser depending on the format of you
-music collection.
+even at fairly high bitrates because you’re converting from one lossless format
+to another. So you may want to choose your browser depending on the format of
+you music collection.
 
 Note that converting FLAC to either format is fine.
 
 - Caching on the server
 - Cache in browser (cache playlist, remove cache when deleting)
 - config.ini
+- Add shading to selected rows
+- Don't show tray | in status
+- Pretty-print remaining in status
+- Select playlist row on playback
 
 
 For future versions:
 
 - Add various options
 - Test various browsers
-- Actually deal with various error
-- More flexible library (grouping by arbitrary tags)
+- Actually deal with various errors
+- More flexible library view (ie. grouping by arbitrary tags)
 - Specify columns in playlist
 - Resize columns in playlist
 - Support for reading more codecs (ape, mpc, aac, etc.)
 		try:
 			if f.split('.').pop().lower() in ['mp3', 'flac']:
 				if '%s/%s' % (root, f) in paths: continue
-				nordavind.add_or_update_track(('%s/%s' % (root, f)))
+				nordavind.addOrUpdateTrack(('%s/%s' % (root, f)))
 		except:
 			print('Error with %s/%s' % (root, f))
 			raise

db/.keep

Empty file added.

nordavind/__init__.py

 # See below for full copyright
 #
 
-import cgi, datetime, os, re, sys, traceback, urllib.parse, sqlite3
-import _thread, time, shlex, subprocess, base64
+import os, re, sys, urllib.parse, sqlite3, shlex, subprocess, base64
 
 from jinja2 import Environment, FileSystemLoader
 import taglib
 config = None
 
 
-def OpenDb():
+def openDb():
 	global _db
 	_db = sqlite3.connect(config['dbpath'],
 		detect_types=sqlite3.PARSE_DECLTYPES | sqlite3.PARSE_COLNAMES)
 	_db.row_factory = dict_factory
 
 
-def Template(f, v):
+def template(f, v):
 	env = Environment(loader=FileSystemLoader('%s/tpl' % _root))
 	env.filters['urlencode'] = lambda s: urllib.parse.quote_plus(s, '')
 
 	return re.sub(r'>\s*<', '><', env.get_template(f).render(**v))
 
 
-def CreateDb():
+def createDb():
 	c = _db.cursor()
 
 	c.execute('''create table artists (
 	_db.commit()
 
 
-def add_or_update_track(path):
+def addOrUpdateTrack(path):
 	c = _db.cursor()
 	track = c.execute('select * from tracks where path = ?',
 		(path,)).fetchone()
 
-	tags = gettags(path)
+	tags = getTags(path)
 	# Add track
-	if track == None:
+	if track is None:
 		album = c.execute('select * from albums where name = ?',
 			(tags.get('album'),)
 		).fetchone()
 
 		# Add album
-		if album == None:
+		if album is None:
 			a = tags.get('albumartist')
 			if not a: a = tags.get('artist')
 
 				(a,)).fetchone()
 
 			# Add artist
-			if artist == None:
+			if artist is None:
 				c.execute('insert into artists (name) values (?)',
 					(a,))
 				artist = c.lastrowid
 	_db.commit()
 
 
-def gettags(path):
+def getTags(path):
 	r = {}
 	# Sometimes this prints a (harmless) warning, AFAIK this can't be disabled
 	# :-/
 	f = taglib.File(path)
+
 	for k, v in f.tags.items():
 		if k in ['DISCNUMBER', 'TRACKNUMBER']:
 			v = [v[0].split('/')[0]]
 	return r
 
 
-def getlibrary():
+def getLibrary():
 	c = _db.cursor()
 
 	r = []
-	artists = c.execute('select * from artists order by name').fetchall()
-	for a in artists:
+	for a in c.execute('select * from artists order by name').fetchall():
 		r.append({
 			'id': a['id'],
 			'name': a['name'],
 	return r
 
 
-def getalbum(id):
-	c = _db.cursor()
-
-	album = c.execute('''select albums.*, artists.name as artistname from albums
-		inner join artists on artists.id = albums.artist
-		where albums.id=?''', (id,)).fetchone()
-	album['tracks'] = c.execute('select * from tracks where album=? order by discno, trackno',
-		(id,)).fetchall()
-
-	return album
-
-
-def playtrack(codec, id):
+def playTrack(codec, id):
 	c = _db.cursor()
 	track = c.execute('select * from tracks where id=?', (id,)).fetchone()
 
-	cache = '%s/%s.%s' % (config['cachepath'], re.sub(r'[^\w]', '', track['path']), codec)
+	cache = None
+	if config['cachepath'] not in [None, False, '']:
+		cache = '%s/%s.%s' % (config['cachepath'], re.sub(r'[^\w]', '', track['path']), codec)
 
-	#if not os.path.exists(cache) or os.stat(cache).st_size == 0:
-	src = '%s/stream-audio/%s/%s' % (
-		_wwwroot,
-		urllib.parse.quote_plus(track['path'].replace('/', '||')),
-		urllib.parse.quote_plus(cache.replace('/', '||')))
-	#else:
-	#	src = cache.replace(_root, '')
+	if cache and os.path.exists(cache):
+		fp = open(cache, 'rb')
+		while True:
+			buf = fp.read(8192)
+			if not buf: break
+			yield buf
+	else:
+		path = track['path']
+		t = path.split('.').pop()
 
-	return {
-		'src': src,
-	}
+		if codec == 'ogg':
+			if t == 'flac':
+				cmd = 'flac -sd %s -o -| oggenc - -q8 -Qo -' % shlex.quote(path)
+			elif t == 'mp3':
+				cmd = 'mpg123 -qw- %s| oggenc - -q8 -Qo -' % shlex.quote(path)
+			elif t == 'ogg':
+				cmd = 'cat'
+				cache = None
+		elif codec == 'mp3':
+			if t == 'flac':
+				cmd = 'flac -sd %s -o -| lame --quiet -V2 - -' % shlex.quote(path)
+			elif t == 'ogg':
+				cmd = 'oggdec -Qo- %s | lame --quiet -V2 - -' % shlex.quote(path)
+			elif t == 'mp3':
+				cmd = 'cat'
+				cache = None
 
+		p = subprocess.Popen(cmd, shell=True, stdout=subprocess.PIPE)
 
-def gettrack(id):
+		if cache is not None:
+			cachefp = open(cache + '_temp', 'wb')
+
+		while True:
+			buf = p.stdout.read(1024)
+			if not buf: break
+			if cache is not None: cachefp.write(buf)
+			yield buf
+
+		cachefp.close()
+		os.rename(cache + '_temp', cache)
+
+
+def playTrack_clean():
+	pass
+
+
+def getAlbum(id):
 	c = _db.cursor()
-	track = c.execute('select * from tracks where id=?', (id,)).fetchone()
-	album = c.execute('select * from albums where id=?', (track['album'],)).fetchone()
+
+	album = c.execute('select * from albums where id=?', (id,)).fetchone()
 	artist = c.execute('select * from artists where id=?', (album['artist'],)).fetchone()
+	tracks = c.execute('select * from tracks where album=? order by discno, trackno', (album['id'],)).fetchall()
 
 	if album['cover']:
 		t = album['cover'].split('.').pop()
 		album['coverdata'] = ''
 
 	return {
-		'track': track,
+		'artist': artist,
 		'album': album,
-		'artist': artist,
+		'tracks': tracks,
 	}
 
 
-def streamaudio(path, cache=None):
-	path = urllib.parse.unquote_plus(path.replace('||', '/'))
-	codec = cache.split('.').pop()
-
-	cache = None # TODO
-
-	if cache != None:
-		cache = urllib.parse.unquote_plus(cache.replace('||', '/'))
-
-	#if cache != None and os.path.exists(cache):
-	#	return open(cache, 'rb').read()
-
-	t = path.split('.').pop()
-
-	if codec == 'ogg':
-		if t == 'flac':
-			cmd = 'flac -sd %s -o -| oggenc - -q8 -Qo -' % shlex.quote(path)
-		elif t == 'mp3':
-			cmd = 'mpg123 -qw- %s| oggenc - -q8 -Qo -' % shlex.quote(path)
-		elif t == 'ogg':
-			cmd = 'cat'
-			cache = None
-	elif codec == 'mp3':
-		if t == 'flac':
-			cmd = 'flac -sd %s -o -| lame --quiet -V2 - -' % shlex.quote(path)
-		elif t == 'ogg':
-			cmd = 'oggdec -Qo- %s | lame --quiet -V2 - -' % shlex.quote(path)
-		elif t == 'mp3':
-			cmd = 'cat'
-			cache = None
-
-	p = subprocess.Popen(cmd, shell=True, stdout=subprocess.PIPE)
-
-	if cache != None:
-		cachefp = open(cache, 'wb')
-
-	while True:
-		buf = p.stdout.read(1024)
-		if not buf: break
-		if cache != None: cachefp.write(buf)
-		yield buf
+def getAlbumByTrack(id):
+	c = _db.cursor()
+	return getAlbum(c.execute('select * from tracks where id=?',
+		(id,)).fetchone()['album'])
 
 
 def start():
-	OpenDb()
+	openDb()
 	if len(_db.cursor().execute('select * from sqlite_master where type="table"').fetchall()) == 0:
-		CreateDb()
+		createDb()
+
+	if not os.path.exists(config['cachepath']):
+		os.makedirs(config['cachepath'])
 
 
 # TODO: Get from file
 # See below for full copyright
 #
 
-import sys, json
+import sys, json, os
 
 import cherrypy
 
 	@cherrypy.expose
 	def index():
 		nordavind.start()
-		return nordavind.Template('main.html', {
-			'library': nordavind.getlibrary(),
+		return nordavind.template('main.html', {
+			'library': nordavind.getLibrary(),
 		})
 
 
 	@cherrypy.expose
 	def get_album(albumid):
 		nordavind.start()
-		return json.dumps(
-			nordavind.getalbum(albumid)
-		, default=JSONDefault)
+		return json.dumps(nordavind.getAlbum(albumid),
+			default=JSONDefault)
 
 
 	@cherrypy.expose
-	def get_track(trackid):
+	def get_album_by_track(trackid):
 		nordavind.start()
-		return json.dumps(
-			nordavind.gettrack(trackid)
-		, default=JSONDefault)
+		return json.dumps(nordavind.getAlbumByTrack(trackid),
+			default=JSONDefault)
 
 
 	@cherrypy.expose
 	def play_track(codec, trackid):
 		nordavind.start()
-		return json.dumps(
-			nordavind.playtrack(codec, trackid)
-		, default=JSONDefault)
 
-
-	@cherrypy.expose
-	def stream_audio(path, cache):
-		cherrypy.response.headers['Content-Type'] = 'audio/%s' % cache.split('.').pop()
-		nordavind.start()
-		return nordavind.streamaudio(path, cache)
-	stream_audio._cp_config = {'response.stream': True}
+		cherrypy.response.headers['Content-Type'] = 'audio/%s' % codec
+		return nordavind.playTrack(codec, trackid)
+	play_track._cp_config = {'response.stream': True}
 
 
 server = '0.0.0.0'
 	if len(listen) > 1:
 		port = listen[1]
 
+cherrypy.tools.playTrack_clean = cherrypy.Tool('on_end_request', nordavind.playTrack_clean)
 cherrypy.config.update({
 	'server.socket_host': server,
 	'server.socket_port': port,
 })
 cherrypy.quickstart(AgentCooper, config={
 	'/': {
-		'tools.staticdir.root': '/data/code/music/',
+		'tools.staticdir.root': os.path.dirname(os.path.realpath(sys.argv[0])),
 	},
 	'/tpl': {
 		'tools.staticdir.on': 'True',
 		'tools.staticdir.dir': 'tpl',
 	},
+	'/play-track': {
+		'tools.playTrack_clean.on': True,
+	}
 })
 
 
-
 # The MIT License (MIT)
 #
 # Copyright © 2013 Martin Tournoij
+# Init. info pane
+window.Info = class Info
+	# jQuery request to fetch info
+	_req: null
+
+
+	###
+	###
+	constructor: ->
+		$('#info .table-wrapper').scrollbar
+			wheelSpeed: 150
+
+
+	###
+	Set info to trackId
+	###
+	setTrack: (trackId) ->
+		if window._cache['tracks'][trackId]?
+			@_set window._cache['tracks'][trackId]
+			return
+
+		my = this
+		@_req.abort() if @_req
+		@_req = jQuery.ajax
+			url: "#{_root}/get-album-by-track/#{trackId}"
+			type: 'get'
+			dataType: 'json'
+			#error: (req, st) -> alert req.responseText if st isnt 'abort'
+			success: (data) =>
+				@_req = null
+				window._cache['artists'][data.artist.id] = data.artist
+				window._cache['albums'][data.album.id] = data.album
+
+				for t in data.tracks
+					window._cache['tracks'][t.id] = t
+					my._set t if t.id is trackId.toNum()
+
+
+	_set: (track) ->
+		album = window._cache['albums'][track.album]
+		artist = window._cache['artists'][album.artist]
+
+		$('#info img').one 'load', ->
+			$('#info .table-wrapper').width $('#info').width() - $('#info img').width() - 20
+		$('#info img').attr 'src', album.coverdata
+
+		$('#info tbody').html('').append """
+			<tr>
+				<th>Artist name</th>
+				<td>#{artist.name.quote() or '[Unknown]'}</td>
+			</tr>
+			<tr>
+				<th>Album title</th>
+				<td>#{album.name.quote() or '[Unknown]'}</td>
+			</tr>
+			<tr>
+				<th>Track title</th>
+				<td>#{track.name.quote() or '[Unknown]'}</td>
+			</tr>
+			<tr>
+				<th>Released</th>
+				<td>#{track.released or '[Unknown]'}</td>
+			</tr>
+			<tr>
+				<th>Track number</th>
+				<td>#{track.trackno or '[Unknown]'}</td>
+			</tr>
+			<tr>
+				<th>Total tracks</th>
+				<td>#{album.numtracks or '[Unknown]'}</td>
+			</tr>
+			<tr>
+				<th>Disc number</th>
+				<td>#{track.discno or '[Unknown]'}</td>
+			</tr>
+			<tr>
+				<th>Total discs</th>
+				<td>#{album.numdiscs or '[Unknown]'}</td>
+			</tr>
+			<tr>
+				<th>Length</th>
+				<td>#{displaytime track.length or '[Unknown]'}</td>
+			</tr>
+			<tr>
+				<th>Filename</th>
+				<td>#{track.path.split('/').pop().quote()}</td>
+			</tr>
+			<tr>
+				<th>Directory</th>
+				<td>#{track.path.split('/')[..-2].join('/').quote()}</td>
+			</tr>
+		"""
+// Generated by CoffeeScript 1.6.2
+(function() {
+  var Info;
+
+  window.Info = Info = (function() {
+    Info.prototype._req = null;
+
+    /*
+    */
+
+
+    function Info() {
+      $('#info .table-wrapper').scrollbar({
+        wheelSpeed: 150
+      });
+    }
+
+    /*
+    	Set info to trackId
+    */
+
+
+    Info.prototype.setTrack = function(trackId) {
+      var my,
+        _this = this;
+
+      if (window._cache['tracks'][trackId] != null) {
+        this._set(window._cache['tracks'][trackId]);
+        return;
+      }
+      my = this;
+      if (this._req) {
+        this._req.abort();
+      }
+      return this._req = jQuery.ajax({
+        url: "" + _root + "/get-album-by-track/" + trackId,
+        type: 'get',
+        dataType: 'json',
+        success: function(data) {
+          var t, _i, _len, _ref, _results;
+
+          _this._req = null;
+          window._cache['artists'][data.artist.id] = data.artist;
+          window._cache['albums'][data.album.id] = data.album;
+          _ref = data.tracks;
+          _results = [];
+          for (_i = 0, _len = _ref.length; _i < _len; _i++) {
+            t = _ref[_i];
+            window._cache['tracks'][t.id] = t;
+            if (t.id === trackId.toNum()) {
+              _results.push(my._set(t));
+            } else {
+              _results.push(void 0);
+            }
+          }
+          return _results;
+        }
+      });
+    };
+
+    Info.prototype._set = function(track) {
+      var album, artist;
+
+      album = window._cache['albums'][track.album];
+      artist = window._cache['artists'][album.artist];
+      $('#info img').one('load', function() {
+        return $('#info .table-wrapper').width($('#info').width() - $('#info img').width() - 20);
+      });
+      $('#info img').attr('src', album.coverdata);
+      return $('#info tbody').html('').append("<tr>\n	<th>Artist name</th>\n	<td>" + (artist.name.quote() || '[Unknown]') + "</td>\n</tr>\n<tr>\n	<th>Album title</th>\n	<td>" + (album.name.quote() || '[Unknown]') + "</td>\n</tr>\n<tr>\n	<th>Track title</th>\n	<td>" + (track.name.quote() || '[Unknown]') + "</td>\n</tr>\n<tr>\n	<th>Released</th>\n	<td>" + (track.released || '[Unknown]') + "</td>\n</tr>\n<tr>\n	<th>Track number</th>\n	<td>" + (track.trackno || '[Unknown]') + "</td>\n</tr>\n<tr>\n	<th>Total tracks</th>\n	<td>" + (album.numtracks || '[Unknown]') + "</td>\n</tr>\n<tr>\n	<th>Disc number</th>\n	<td>" + (track.discno || '[Unknown]') + "</td>\n</tr>\n<tr>\n	<th>Total discs</th>\n	<td>" + (album.numdiscs || '[Unknown]') + "</td>\n</tr>\n<tr>\n	<th>Length</th>\n	<td>" + (displaytime(track.length || '[Unknown]')) + "</td>\n</tr>\n<tr>\n	<th>Filename</th>\n	<td>" + (track.path.split('/').pop().quote()) + "</td>\n</tr>\n<tr>\n	<th>Directory</th>\n	<td>" + (track.path.split('/').slice(0, -1).join('/').quote()) + "</td>\n</tr>");
+    };
+
+    return Info;
+
+  })();
+
+}).call(this);
+# Convenient shortcuts
+Function.prototype.timeout = (time, args) ->
+	setTimeout =>
+		this.apply this, args
+	, time
+
+Function.prototype.interval = (time, args) ->
+	setInterval =>
+		this.apply this, args
+	, time
+
+String.prototype.toNum = -> parseInt this, 10
+
+# Escape HTML entities
+String.prototype.quote = ->
+	this
+		.replace(/</g, '&lt')
+		.replace(/>/g, '&gt')
+		.replace(/&/g, '&amp;')
+		.replace(/"/g, '&quot;')
+		.replace(/'/g, '&apos;')
+		.replace(/\//g, '&#x2f;')
+
+
+# Find the first element matching sel
+jQuery.fn.findNext = (sel, num=1, _prev=false) ->
+	ref = if _prev then this.prev() else this.next()
+	while true
+		return false if ref.length is 0
+		arewe = ref.is sel
+		num -= 1 if arewe
+		return ref if arewe and num is 0
+		ref = if _prev then ref.prev() else ref.next()
+jQuery.fn.findPrev = (sel, num=1) -> this.findNext sel, num, true
+
+
+window.log = -> console.log.apply console, arguments if console?.log?
+window.$$ = (s) -> $(s).toArray()
+
+
+# localStorage wrapper
+window.store =
+	get: (k) -> JSON.parse localStorage.getItem(k)
+	set: (k, v) -> localStorage.setItem k, JSON.stringify(v)
+	init: (k, v) -> localStorage.setItem k, JSON.stringify(v) unless localStorage.getItem k
+
+
+# Formats seconds as min:sec
+window.displaytime = (sec) ->
+	m = Math.floor sec / 60
+	s = Math.floor sec % 60
+	return "#{m}:#{if s < 10 then 0 else ''}#{s}"
+
+
+# Drag & drop
+window.babyUrADrag = (handle, start, move, end) ->
+	dragging = false
+
+	mousemove = (e) ->
+		return unless dragging
+		setSize()
+		move?.apply this, [e]
+
+	mousedown = (e) ->
+		handle.addClass 'dragging'
+		dragging = true
+		$(handle).css 'z-index', '99'
+		document.body.focus()
+		start?.apply this, [e]
+
+		return false
+
+	mouseup = (e) ->
+		handle.removeClass 'dragging'
+		dragging = false
+		end?.apply this, [e]
+
+	handle.on 'mousedown', mousedown
+	$('body').on 'mousemove', mousemove
+	$('body').on 'mouseup', mouseup
+
+
+window.Slider = class Slider
+	constructor: (opt) ->
+		@opt = opt
+		my = this
+
+		opt.target = $(opt.target)
+		opt.target.addClass 'slider'
+		opt.target.append '<span class="slider-bar"></span>'
+		opt.target.append '<span class="slider-handle"></span>'
+
+		@bar = opt.target.find '.slider-bar'
+		@handle = opt.target.find '.slider-handle'
+
+		tooltip = null
+		setpos = (e) ->
+			left = e.pageX - my.bar.offset().left - my.handle.width()
+			max = my.bar.width() - my.handle.width()
+			left = 0 if left < 0
+			left = max if left > max
+
+			my.handle.css 'left', "#{left}px"
+			tip = my.opt.move my.getpos() if my.opt.move
+			if tip?
+				if tooltip is null
+					$(my.opt.target).append "<span id='tooltip'></span>"
+					tooltip = $('#tooltip')
+				tooltip.html tip
+				tooltip.css 'left', "#{left - 20}px"
+
+		stop = ->
+			my.opt.stop() if my.opt.stop
+			$('#tooltip').remove()
+			tooltip = null
+
+		start = -> my.opt.start() if my.opt.start
+
+		@bar.bind 'click', setpos
+		babyUrADrag @handle, start, setpos, stop
+
+
+	# Get position as percentage 0-100
+	getpos: ->
+		@handle.css('left').toNum() / ((@bar.width() - @handle.width()) / 100)
+
+
+	# Set position as percentage 0-100
+	setpos: (p) ->
+		@handle.css 'left', "#{(@bar.width() - @handle.width()) / 100 * p}px"
+
+// Generated by CoffeeScript 1.6.2
+(function() {
+  var Slider;
+
+  Function.prototype.timeout = function(time, args) {
+    var _this = this;
+
+    return setTimeout(function() {
+      return _this.apply(_this, args);
+    }, time);
+  };
+
+  Function.prototype.interval = function(time, args) {
+    var _this = this;
+
+    return setInterval(function() {
+      return _this.apply(_this, args);
+    }, time);
+  };
+
+  String.prototype.toNum = function() {
+    return parseInt(this, 10);
+  };
+
+  String.prototype.quote = function() {
+    return this.replace(/</g, '&lt').replace(/>/g, '&gt').replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/'/g, '&apos;').replace(/\//g, '&#x2f;');
+  };
+
+  jQuery.fn.findNext = function(sel, num, _prev) {
+    var arewe, ref;
+
+    if (num == null) {
+      num = 1;
+    }
+    if (_prev == null) {
+      _prev = false;
+    }
+    ref = _prev ? this.prev() : this.next();
+    while (true) {
+      if (ref.length === 0) {
+        return false;
+      }
+      arewe = ref.is(sel);
+      if (arewe) {
+        num -= 1;
+      }
+      if (arewe && num === 0) {
+        return ref;
+      }
+      ref = _prev ? ref.prev() : ref.next();
+    }
+  };
+
+  jQuery.fn.findPrev = function(sel, num) {
+    if (num == null) {
+      num = 1;
+    }
+    return this.findNext(sel, num, true);
+  };
+
+  window.log = function() {
+    if ((typeof console !== "undefined" && console !== null ? console.log : void 0) != null) {
+      return console.log.apply(console, arguments);
+    }
+  };
+
+  window.$$ = function(s) {
+    return $(s).toArray();
+  };
+
+  window.store = {
+    get: function(k) {
+      return JSON.parse(localStorage.getItem(k));
+    },
+    set: function(k, v) {
+      return localStorage.setItem(k, JSON.stringify(v));
+    },
+    init: function(k, v) {
+      if (!localStorage.getItem(k)) {
+        return localStorage.setItem(k, JSON.stringify(v));
+      }
+    }
+  };
+
+  window.displaytime = function(sec) {
+    var m, s;
+
+    m = Math.floor(sec / 60);
+    s = Math.floor(sec % 60);
+    return "" + m + ":" + (s < 10 ? 0 : '') + s;
+  };
+
+  window.babyUrADrag = function(handle, start, move, end) {
+    var dragging, mousedown, mousemove, mouseup;
+
+    dragging = false;
+    mousemove = function(e) {
+      if (!dragging) {
+        return;
+      }
+      setSize();
+      return move != null ? move.apply(this, [e]) : void 0;
+    };
+    mousedown = function(e) {
+      handle.addClass('dragging');
+      dragging = true;
+      $(handle).css('z-index', '99');
+      document.body.focus();
+      if (start != null) {
+        start.apply(this, [e]);
+      }
+      return false;
+    };
+    mouseup = function(e) {
+      handle.removeClass('dragging');
+      dragging = false;
+      return end != null ? end.apply(this, [e]) : void 0;
+    };
+    handle.on('mousedown', mousedown);
+    $('body').on('mousemove', mousemove);
+    return $('body').on('mouseup', mouseup);
+  };
+
+  window.Slider = Slider = (function() {
+    function Slider(opt) {
+      var my, setpos, start, stop, tooltip;
+
+      this.opt = opt;
+      my = this;
+      opt.target = $(opt.target);
+      opt.target.addClass('slider');
+      opt.target.append('<span class="slider-bar"></span>');
+      opt.target.append('<span class="slider-handle"></span>');
+      this.bar = opt.target.find('.slider-bar');
+      this.handle = opt.target.find('.slider-handle');
+      tooltip = null;
+      setpos = function(e) {
+        var left, max, tip;
+
+        left = e.pageX - my.bar.offset().left - my.handle.width();
+        max = my.bar.width() - my.handle.width();
+        if (left < 0) {
+          left = 0;
+        }
+        if (left > max) {
+          left = max;
+        }
+        my.handle.css('left', "" + left + "px");
+        if (my.opt.move) {
+          tip = my.opt.move(my.getpos());
+        }
+        if (tip != null) {
+          if (tooltip === null) {
+            $(my.opt.target).append("<span id='tooltip'></span>");
+            tooltip = $('#tooltip');
+          }
+          tooltip.html(tip);
+          return tooltip.css('left', "" + (left - 20) + "px");
+        }
+      };
+      stop = function() {
+        if (my.opt.stop) {
+          my.opt.stop();
+        }
+        $('#tooltip').remove();
+        return tooltip = null;
+      };
+      start = function() {
+        if (my.opt.start) {
+          return my.opt.start();
+        }
+      };
+      this.bar.bind('click', setpos);
+      babyUrADrag(this.handle, start, setpos, stop);
+    }
+
+    Slider.prototype.getpos = function() {
+      return this.handle.css('left').toNum() / ((this.bar.width() - this.handle.width()) / 100);
+    };
+
+    Slider.prototype.setpos = function(p) {
+      return this.handle.css('left', "" + ((this.bar.width() - this.handle.width()) / 100 * p) + "px");
+    };
+
+    return Slider;
+
+  })();
+
+}).call(this);

tpl/library.coffee

+window.Library = class Library
+	###
+	###
+	constructor: ->
+		$('#library ol').scrollbar
+			wheelSpeed: 150
+
+		@selectRow $('#library li:first')
+		@initFilter()
+		@initMouse()
+		@initKeyboard()
+
+	
+	###
+	###
+	updateScrollbar: -> $('#library ol').scrollbar 'update'
+
+
+	###
+	Toggle artist open/close
+	###
+	toggleArtist: (row) ->
+		row = row.closest 'li'
+		return unless row.is '.artist'
+
+		n = row.next()
+		while true
+			break unless n.hasClass 'album'
+
+			if n.css('display') is 'block'
+				n.css 'display', 'none'
+				row.find('i').attr 'class', 'icon-expand-alt'
+			else
+				n.css 'display', 'block'
+				row.find('i').attr 'class', 'icon-collapse-alt'
+			n = n.next()
+
+		@updateScrollbar
+
+
+	###
+	Add album to playlist
+	###
+	addAlbumToPlaylist: (albumId) ->
+		jQuery.ajax
+			url: "#{_root}/get-album/#{albumId}"
+			type: 'get'
+			dataType: 'json'
+			#error: (req, st) -> alert req.responseText if st isnt 'abort'
+			success: (data) ->
+				pl = $('#playlist tbody')
+				save = []
+
+				window._cache['artists'][data.artist.id] = data.artist
+				window._cache['albums'][data.album.id] = data.album
+				for t in data.tracks
+					window._cache['tracks'][t.id] = t
+					row = """<tr data-id="#{t.id}" data-length="#{t.length}">
+						<td></td>
+						<td>#{t.discno}.#{if t.trackno < 10 then 0 else ''}#{t.trackno}</td>
+						<td>#{data.artist.name.quote()} - #{data.album.name.quote()}</td>
+						<td>#{t.name.quote()}</td>
+						<td>#{displaytime t.length}</td>
+					</tr>"""
+
+					pl.append row
+					save.push row
+				$('#playlist-wrapper').scrollbar 'update'
+				store.set 'playlist', store.get('playlist').concat save
+
+
+	###
+	Select artist/album
+	###
+	selectRow: (row) ->
+		row = row.closest 'li'
+		return false unless row
+
+		$('#library .active').removeClass 'active'
+		row.addClass 'active'
+
+		if row.position().top > $('#library ol').height() or row.position().top < 0
+			$('#library ol')[0].scrollTop += row.closest('li').position().top
+			@updateScrollbar
+
+
+	###
+	Add album to playlist
+	###
+	addAlbum: (row) -> @addAlbumToPlaylist row.closest('li').attr 'data-id'
+
+
+	###
+	Filter
+	TODO: unicode, ie. `a' also matches `ä'
+	###
+	initFilter: ->
+		t = null
+		pterm = null
+
+		dofilter = (target) ->
+			target.removeClass 'invalid'
+			term = target.val().trim()
+			return if term is pterm
+
+			pterm = term
+			if term is ''
+				$('#library .artist').show()
+				$('#library .album').hide()
+				return
+
+			try
+				term = new RegExp term
+			# Not a valid RegExp
+			# TODO: Give a clearer warning
+			catch exc
+				target.addClass 'invalid'
+				return
+
+			# Hiding everything & then showing matches is *much* faster
+			$('#library li').hide()
+			$('#library ol')[0].scrollTop = 0
+			$$('#library li').forEach (row) ->
+				row = $(row)
+				if row.text().toLowerCase().match term
+					if row.is('.artist')
+						row.show()
+					else
+						row.findPrev('.artist').show()
+			@updateScrollbar
+
+		filter = (e) ->
+			clearTimeout t if t
+			t = dofilter.timeout 400, [$(e.target)]
+
+		$('#search input').on 'keydown', filter
+		$('#search input').on 'change', filter
+
+
+	###
+	Bind mouse events
+	###
+	initMouse: ->
+		my = this
+
+		$('#library ol').on 'click', 'li span', -> my.selectRow $(this)
+		$('#library ol').on 'click', '.artist i', -> my.toggleArtist $(this)
+		$('#library ol').on 'dblclick', '.artist span', (e) ->
+			e.preventDefault()
+			my.selectRow $(this)
+			my.toggleArtist $(this)
+
+		$('#library ol').on 'dblclick', '.album span', (e) ->
+			e.preventDefault()
+			my.selectRow $(this)
+			my.addAlbum $(this)
+
+		# Middle mouse button
+		$('#library ol').on 'mousedown', '.artist span', (e) ->
+			return unless e.button is 1
+
+			e.preventDefault()
+			my.selectRow $(this)
+
+			next = $(this).closest('li').next()
+			while true
+				break unless next.is('.album')
+				my.addAlbum next
+				next = next.next()
+
+		$('#library ol').on 'mousedown', '.album span', (e) ->
+			return unless e.button is 1
+
+			e.preventDefault()
+			my.selectRow $(this)
+			my.addAlbum $(this)
+
+
+	###
+	Keybinds
+	###
+	initKeyboard: ->
+		my = this
+		chain = ''
+		timer = null
+		cleartimer = null
+		$('body').on 'keydown', (e) ->
+			return unless window._activepane?.is '#library'
+			return if document.activeElement?.tagName?.toLowerCase() is 'input'
+			return if e.ctrlKey or e.altKey
+
+			events =
+				27: -> # Esc
+					clearTimeout timer if timer
+					clearTimeout cleartimer if cleartimer
+					chain = ''
+					return
+				38: -> my.selectRow $('#library .active').findPrev 'li:visible' # Up
+				40: -> my.selectRow $('#library .active').findNext 'li:visible' # Down
+				39: -> # Right
+					act = $('#library .active')
+					if act.is '.artist'
+						if act.next().is(':visible')
+							my.selectRow act.next()
+						else
+							my.toggleArtist act
+				37: -> # Left
+					act = $('#library .active')
+					if act.is('.album')
+						my.selectRow act.findPrev('.artist')
+					else if act.is('.artist') and act.next().is(':visible')
+						my.toggleArtist act
+				33: -> # Page up
+					n = Math.floor $('#library ol').height() / $('#library li:first').outerHeight()
+					r = my.selectRow $('#library .active').findPrev('li:visible', n)
+					my.selectRow $('#library li:first') if r is false
+				34: -> # Page down
+					n = Math.floor $('#library ol').height() / $('#library li:first').outerHeight()
+					r = my.selectRow $('#library .active').findNext('li:visible', n)
+					my.selectRow $('#library li:last') if r is false
+				36: -> my.selectRow $('#library li:first') # Home
+				35: -> my.selectRow $('#library li:last').findPrev('.artist') # End
+				13: -> # Enter
+					act = $('#library .active')
+					my.toggleArtist act if act.is('.artist')
+					my.addAlbum act if act.is('.album')
+
+			if events[e.keyCode]?
+				e.preventDefault()
+				events[e.keyCode]()
+			# 0-9a-z & space
+			# TODO: unicode, ie. `a' also matches `ä'
+			else if e.keyCode is 32 or (e.keyCode > 46 and e.keyCode < 91)
+				e.preventDefault()
+				chain += String.fromCharCode(e.keyCode).toLowerCase()
+				clearTimeout timer if timer
+				f = (chain) ->
+					$('#library li').each (i, elem) ->
+						elem = $(elem)
+						if elem.is(':visible') and elem.text().toLowerCase().indexOf(chain) is 0
+							my.selectRow elem
+							return false
+
+				timer = f.timeout 100, [chain]
+
+				clearTimeout cleartimer if cleartimer
+				cleartimer = (-> chain = '').timeout 3000
+// Generated by CoffeeScript 1.6.2
+(function() {
+  var Library;
+
+  window.Library = Library = (function() {
+    /*
+    */
+    function Library() {
+      $('#library ol').scrollbar({
+        wheelSpeed: 150
+      });
+      this.selectRow($('#library li:first'));
+      this.initFilter();
+      this.initMouse();
+      this.initKeyboard();
+    }
+
+    /*
+    */
+
+
+    Library.prototype.updateScrollbar = function() {
+      return $('#library ol').scrollbar('update');
+    };
+
+    /*
+    	Toggle artist open/close
+    */
+
+
+    Library.prototype.toggleArtist = function(row) {
+      var n;
+
+      row = row.closest('li');
+      if (!row.is('.artist')) {
+        return;
+      }
+      n = row.next();
+      while (true) {
+        if (!n.hasClass('album')) {
+          break;
+        }
+        if (n.css('display') === 'block') {
+          n.css('display', 'none');
+          row.find('i').attr('class', 'icon-expand-alt');
+        } else {
+          n.css('display', 'block');
+          row.find('i').attr('class', 'icon-collapse-alt');
+        }
+        n = n.next();
+      }
+      return this.updateScrollbar;
+    };
+
+    /*
+    	Add album to playlist
+    */
+
+
+    Library.prototype.addAlbumToPlaylist = function(albumId) {
+      return jQuery.ajax({
+        url: "" + _root + "/get-album/" + albumId,
+        type: 'get',
+        dataType: 'json',
+        success: function(data) {
+          var pl, row, save, t, _i, _len, _ref;
+
+          pl = $('#playlist tbody');
+          save = [];
+          window._cache['artists'][data.artist.id] = data.artist;
+          window._cache['albums'][data.album.id] = data.album;
+          _ref = data.tracks;
+          for (_i = 0, _len = _ref.length; _i < _len; _i++) {
+            t = _ref[_i];
+            window._cache['tracks'][t.id] = t;
+            row = "<tr data-id=\"" + t.id + "\" data-length=\"" + t.length + "\">\n	<td></td>\n	<td>" + t.discno + "." + (t.trackno < 10 ? 0 : '') + t.trackno + "</td>\n	<td>" + (data.artist.name.quote()) + " - " + (data.album.name.quote()) + "</td>\n	<td>" + (t.name.quote()) + "</td>\n	<td>" + (displaytime(t.length)) + "</td>\n</tr>";
+            pl.append(row);
+            save.push(row);
+          }
+          $('#playlist-wrapper').scrollbar('update');
+          return store.set('playlist', store.get('playlist').concat(save));
+        }
+      });
+    };
+
+    /*
+    	Select artist/album
+    */
+
+
+    Library.prototype.selectRow = function(row) {
+      row = row.closest('li');
+      if (!row) {
+        return false;
+      }
+      $('#library .active').removeClass('active');
+      row.addClass('active');
+      if (row.position().top > $('#library ol').height() || row.position().top < 0) {
+        $('#library ol')[0].scrollTop += row.closest('li').position().top;
+        return this.updateScrollbar;
+      }
+    };
+
+    /*
+    	Add album to playlist
+    */
+
+
+    Library.prototype.addAlbum = function(row) {
+      return this.addAlbumToPlaylist(row.closest('li').attr('data-id'));
+    };
+
+    /*
+    	Filter
+    	TODO: unicode, ie. `a' also matches `ä'
+    */
+
+
+    Library.prototype.initFilter = function() {
+      var dofilter, filter, pterm, t;
+
+      t = null;
+      pterm = null;
+      dofilter = function(target) {
+        var exc, term;
+
+        target.removeClass('invalid');
+        term = target.val().trim();
+        if (term === pterm) {
+          return;
+        }
+        pterm = term;
+        if (term === '') {
+          $('#library .artist').show();
+          $('#library .album').hide();
+          return;
+        }
+        try {
+          term = new RegExp(term);
+        } catch (_error) {
+          exc = _error;
+          target.addClass('invalid');
+          return;
+        }
+        $('#library li').hide();
+        $('#library ol')[0].scrollTop = 0;
+        $$('#library li').forEach(function(row) {
+          row = $(row);
+          if (row.text().toLowerCase().match(term)) {
+            if (row.is('.artist')) {
+              return row.show();
+            } else {
+              return row.findPrev('.artist').show();
+            }
+          }
+        });
+        return this.updateScrollbar;
+      };
+      filter = function(e) {
+        if (t) {
+          clearTimeout(t);
+        }
+        return t = dofilter.timeout(400, [$(e.target)]);
+      };
+      $('#search input').on('keydown', filter);
+      return $('#search input').on('change', filter);
+    };
+
+    /*
+    	Bind mouse events
+    */
+
+
+    Library.prototype.initMouse = function() {
+      var my;
+
+      my = this;
+      $('#library ol').on('click', 'li span', function() {
+        return my.selectRow($(this));
+      });
+      $('#library ol').on('click', '.artist i', function() {
+        return my.toggleArtist($(this));
+      });
+      $('#library ol').on('dblclick', '.artist span', function(e) {
+        e.preventDefault();
+        my.selectRow($(this));
+        return my.toggleArtist($(this));
+      });
+      $('#library ol').on('dblclick', '.album span', function(e) {
+        e.preventDefault();
+        my.selectRow($(this));
+        return my.addAlbum($(this));
+      });
+      $('#library ol').on('mousedown', '.artist span', function(e) {
+        var next, _results;
+
+        if (e.button !== 1) {
+          return;
+        }
+        e.preventDefault();
+        my.selectRow($(this));
+        next = $(this).closest('li').next();
+        _results = [];
+        while (true) {
+          if (!next.is('.album')) {
+            break;
+          }
+          my.addAlbum(next);
+          _results.push(next = next.next());
+        }
+        return _results;
+      });
+      return $('#library ol').on('mousedown', '.album span', function(e) {
+        if (e.button !== 1) {
+          return;
+        }
+        e.preventDefault();
+        my.selectRow($(this));
+        return my.addAlbum($(this));
+      });
+    };
+
+    /*
+    	Keybinds
+    */
+
+
+    Library.prototype.initKeyboard = function() {
+      var chain, cleartimer, my, timer;
+
+      my = this;
+      chain = '';
+      timer = null;
+      cleartimer = null;
+      return $('body').on('keydown', function(e) {
+        var events, f, _ref, _ref1, _ref2;
+
+        if (!((_ref = window._activepane) != null ? _ref.is('#library') : void 0)) {
+          return;
+        }
+        if (((_ref1 = document.activeElement) != null ? (_ref2 = _ref1.tagName) != null ? _ref2.toLowerCase() : void 0 : void 0) === 'input') {
+          return;
+        }
+        if (e.ctrlKey || e.altKey) {
+          return;
+        }
+        events = {
+          27: function() {
+            if (timer) {
+              clearTimeout(timer);
+            }
+            if (cleartimer) {
+              clearTimeout(cleartimer);
+            }
+            chain = '';
+          },
+          38: function() {
+            return my.selectRow($('#library .active').findPrev('li:visible'));
+          },
+          40: function() {
+            return my.selectRow($('#library .active').findNext('li:visible'));
+          },
+          39: function() {
+            var act;
+
+            act = $('#library .active');
+            if (act.is('.artist')) {
+              if (act.next().is(':visible')) {
+                return my.selectRow(act.next());
+              } else {
+                return my.toggleArtist(act);
+              }
+            }
+          },
+          37: function() {
+            var act;
+
+            act = $('#library .active');
+            if (act.is('.album')) {
+              return my.selectRow(act.findPrev('.artist'));
+            } else if (act.is('.artist') && act.next().is(':visible')) {
+              return my.toggleArtist(act);
+            }
+          },
+          33: function() {
+            var n, r;
+
+            n = Math.floor($('#library ol').height() / $('#library li:first').outerHeight());
+            r = my.selectRow($('#library .active').findPrev('li:visible', n));
+            if (r === false) {
+              return my.selectRow($('#library li:first'));
+            }
+          },
+          34: function() {
+            var n, r;
+
+            n = Math.floor($('#library ol').height() / $('#library li:first').outerHeight());
+            r = my.selectRow($('#library .active').findNext('li:visible', n));
+            if (r === false) {
+              return my.selectRow($('#library li:last'));
+            }
+          },
+          36: function() {
+            return my.selectRow($('#library li:first'));
+          },
+          35: function() {
+            return my.selectRow($('#library li:last').findPrev('.artist'));
+          },
+          13: function() {
+            var act;
+
+            act = $('#library .active');
+            if (act.is('.artist')) {
+              my.toggleArtist(act);
+            }
+            if (act.is('.album')) {
+              return my.addAlbum(act);
+            }
+          }
+        };
+        if (events[e.keyCode] != null) {
+          e.preventDefault();
+          return events[e.keyCode]();
+        } else if (e.keyCode === 32 || (e.keyCode > 46 && e.keyCode < 91)) {
+          e.preventDefault();
+          chain += String.fromCharCode(e.keyCode).toLowerCase();
+          if (timer) {
+            clearTimeout(timer);
+          }
+          f = function(chain) {
+            return $('#library li').each(function(i, elem) {
+              elem = $(elem);
+              if (elem.is(':visible') && elem.text().toLowerCase().indexOf(chain) === 0) {
+                my.selectRow(elem);
+                return false;
+              }
+            });
+          };
+          timer = f.timeout(100, [chain]);
+          if (cleartimer) {
+            clearTimeout(cleartimer);
+          }
+          return cleartimer = (function() {
+            return chain = '';
+          }).timeout(3000);
+        }
+      });
+    };
+
+    return Library;
+
+  })();
+
+}).call(this);
 
 
 # Script root
-_root = ''
-
-# Easy reference to our <audio> element
-_audio = $('audio')[0]
-
-# jQuery request to fetch info
-_inforeq = null
+window._root = ''
 
 # Current active pane
-_activepane = null
-
-# Current playing track
-_curplaying =
-	trackId: null
-	length: 1
+window._activepane = null
 
 # Cache info
-_infocache =
+window._cache =
 	tracks: {}
 	albums: {}
 	artists: {}
 
-# Which codec will we be using?
-_codec = null
-
-_draggingseekbar = false
-
-# Convenient shortcuts
-setTimeout = (t, f) -> window.setTimeout f, t
-setInterval = (t, f) -> window.setInterval f, t
-log = -> console.log.apply console, arguments if console?.log?
-$$ = (s) -> $(s).toArray()
-
-
-# localStorage wrapper
-store =
-	get: (k) -> JSON.parse localStorage.getItem(k)
-	set: (k, v) -> localStorage.setItem k, JSON.stringify(v)
-	init: (k, v) -> localStorage.setItem k, JSON.stringify(v) unless localStorage.getItem k
-
-
-# Escape HTML entities
-String.prototype.quote = ->
-	this
-		.replace(/</g, '&lt')
-		.replace(/>/g, '&gt')
-		.replace(/&/g, '&amp;')
-		.replace(/"/g, '&quot;')
-		.replace(/'/g, '&apos;')
-		.replace(/\//g, '&#x2f;')
-
-
-String.prototype.toNum = -> parseInt this, 10
-
-
-# Find the first element matching sel
-jQuery.fn.findNext = (sel, num=1, _prev=false) ->
-	ref = if _prev then $(this).prev() else $(this).next()
-	while true
-		return false if ref.length is 0
-		arewe = ref.is sel
-		num -= 1 if arewe
-		return ref if arewe and num is 0
-		ref = if _prev then ref.prev() else ref.next()
-jQuery.fn.findPrev = (sel, num=1) -> this.findNext sel, num, true
-
-
-# Init library pane
-initLibrary = ->
-	$('#library ol').scrollbar
-		wheelSpeed: 150
-
-	# Toggle artist open/close
-	toggleArtist = (elem) ->
-		elem = elem.closest 'li'
-		return unless elem.is '.artist'
-		n = elem.next()
-		while true
-			break unless n.hasClass 'album'
-			if n.css('display') is 'block'
-				n.css 'display', 'none'
-				elem.find('i').attr 'class', 'icon-expand-alt'
-			else
-				n.css 'display', 'block'
-				elem.find('i').attr 'class', 'icon-collapse-alt'
-			n = n.next()
-
-		$('#library ol').scrollbar 'update'
-
-	# Select artist/album
-	selectLRow = (elem) ->
-		return false unless elem
-		$('#library .active').removeClass 'active'
-		elem.closest('li').addClass 'active'
-
-		if elem.position().top > $('#library ol').height() or elem.position().top < 0
-			$('#library ol')[0].scrollTop += elem.closest('li').position().top
-			$('#library ol').scrollbar 'update'
-
-	# Add album to playlist
-	addAlbum = (elem) ->
-		addAlbumToPlaylist elem.closest('li').attr('data-id')
-
-	# Select the first row on page load
-	# TODO: Perhaps remember last
-	selectLRow $('#library li:first')
-
-	# Various mouse binds
-	$('#library ol').on 'click', 'li span', -> selectLRow $(this)
-	$('#library ol').on 'click', '.artist i', -> toggleArtist $(this)
-	$('#library ol').on 'dblclick', '.artist span', (e) ->
-		e.preventDefault()
-		selectLRow $(this)
-		toggleArtist $(this)
-
-	$('#library ol').on 'mousedown', '.artist span', (e) ->
-		return unless e.button is 1
-		e.preventDefault()
-		selectLRow $(this)
-
-		next = $(this).closest('li').next()
-		while true
-			break unless next.is('.album')
-			addAlbum next
-			next = next.next()
-
-	$('#library ol').on 'mousedown', '.album span', (e) ->
-		return unless e.button is 1
-		e.preventDefault()
-		selectLRow $(this)
-		addAlbum $(this)
-
-	$('#library ol').on 'dblclick', '.album span', (e) ->
-		e.preventDefault()
-		addAlbum $(this)
-
-	# Filter
-	# TODO: unicode, ie. `a' also matches `ä'
-	t = null
-	pterm = null
-	filter = (e) ->
-		clearTimeout t if t
-		t = setTimeout 400, ->
-			$(e.target).removeClass 'invalid'
-			term = $(e.target).val().trim()
-			return if term is pterm
-			pterm = term
-			if term is ''
-				$('#library .artist').show()
-				$('#library .album').hide()
-				return
-
-			try
-				term = new RegExp term
-			catch exc
-				$(e.target).addClass 'invalid'
-				return
-			$('#library li').hide()
-			$('#library ol')[0].scrollTop = 0
-			$$('#library li').forEach (elem) ->
-				elem = $(elem)
-				if elem.text().toLowerCase().match term
-					if elem.is('.artist')
-						elem.show()
-					else
-						elem.findPrev('.artist').show()
-			$('#library ol').scrollbar 'update'
-
-	$('#search input').on 'keydown', filter
-	$('#search input').on 'change', filter
-
-	# Keybinds
-	chain = ''
-	timer = null
-	cleartimer = null
-	$('body').on 'keydown', (e) ->
-		return if document.activeElement?.tagName?.toLowerCase() is 'input'
-		return unless _activepane?.is '#library'
-		return if e.ctrlKey or e.altKey
-
-		events =
-			27: -> # Esc
-				clearTimeout timer if timer
-				clearTimeout cleartimer if cleartimer
-				chain = ''
-				return
-			38: -> selectLRow $('#library .active').findPrev 'li:visible' # Up
-			40: -> selectLRow $('#library .active').findNext 'li:visible' # Down
-			39: -> # Right
-				act = $('#library .active')
-				if act.is '.artist'
-					if act.next().is(':visible')
-						selectLRow act.next()
-					else
-						toggleArtist act
-			37: -> # Left
-				act = $('#library .active')
-				if act.is('.album')
-					selectLRow act.findPrev('.artist')
-				else if act.is('.artist') and act.next().is(':visible')
-					toggleArtist act
-			33: -> # Page up
-				n = Math.floor $('#library ol').height() / $('#library li:first').outerHeight()
-				r = selectLRow $('#library .active').findPrev('li:visible', n)
-				selectLRow $('#library li:first') if r is false
-			34: -> # Page down
-				n = Math.floor $('#library ol').height() / $('#library li:first').outerHeight()
-				r = selectLRow $('#library .active').findNext('li:visible', n)
-				selectLRow $('#library li:last') if r is false
-			36: -> selectLRow $('#library li:first') # Home
-			35: -> selectLRow $('#library li:last') # End
-			13: -> # Enter
-				act = $('#library .active')
-				toggleArtist act if act.is('.artist')
-				addAlbum act if act.is('.album')
-
-		if events[e.keyCode]?
-			e.preventDefault()
-			events[e.keyCode]()
-		# 0-9a-z & space
-		# TODO: unicode, ie. `a' also matches `ä'
-		else if e.keyCode is 32 or (e.keyCode > 46 and e.keyCode < 91)
-			e.preventDefault()
-			chain += String.fromCharCode(e.keyCode).toLowerCase()
-			clearTimeout timer if timer
-			timer = setTimeout 100, ->
-				$('#library li').each (i, elem) ->
-					elem = $(elem)
-					if elem.is(':visible') and elem.text().toLowerCase().indexOf(chain) is 0
-						selectLRow elem
-						return false
-			clearTimeout cleartimer if cleartimer
-			cleartimer = setTimeout 3000, -> chain = ''
-
-
-# Init playlist
-initPlaylist = ->
-	store.get('playlist').forEach (r) ->
-		$('#playlist tbody').append r
-
-	$('#playlist-wrapper').scrollbar
-		wheelSpeed: 150
-
-	# Set a row as active
-	setRowActive = (row) ->
-		row = $(row).closest 'tr'
-
-		$('#playlist tr').removeClass 'active'
-		row.addClass 'active'
-		setInfo row.attr('data-id')
-
-		if row.position().top > $('#playlist-wrapper').height() or row.position().top < 0
-			$('#playlist-wrapper')[0].scrollTop += row.position().top
-			$('#playlist-wrapper').scrollbar 'update'
-
-	# Select a row in the playlist
-	selectRow = (row, active=true) ->
-		return false unless row
-		row = $(row).closest 'tr'
-		row.addClass 'selected'
-		setRowActive row if active
-
-	# Deselect a row ion the playlist
-	deSelectRow = (row, active=true) ->
-		row = $(row).closest 'tr'
-		row.removeClass 'selected'
-		setRowActive row if active
-
-	# Select all rows from .active until `stop'
-	selectRowsUntil = (stop, active=true) ->
-		return false unless stop
-		stop = $(stop).closest 'tr'
-		row = $('#playlist .active')
-
-		if row.length is 0
-			selectRow stop, active
-			return
-
-		dir = if stop.index() > row.index() then 'next' else 'prev'
-		while true
-			selectRow row, false
-			if row.is stop
-				setRowActive row if active
-				break
-			row = if dir is 'next' then row.next() else row.prev()
-
-	# Clear all selection
-	clearSelection = ->
-		$('#playlist tr').removeClass('selected').removeClass 'active'
-
-	# Mouse
-	$('body').on 'click', (e) ->
-		return unless _activepane?.is '#playlist-wrapper'
-		return if $(e.target).closest('tr').length is 1
-		clearSelection()
-
-	$('#playlist tbody').on 'click', 'tr', (e) ->
-		if e.shiftKey
-			selectRowsUntil this
-		else if e.ctrlKey
-			selectRow this
-		else
-			clearSelection()
-			selectRow this
-
-	$('#playlist tbody').on 'dblclick', 'tr', (e) ->
-		clearSelection()
-		selectRow this
-		playRow this
-
-	# Keybinds
-	$('body').bind 'keydown', (e) ->
-		return unless _activepane?.is '#playlist-wrapper'
-
-		# A
-		if e.ctrlKey and e.keyCode is 65
-			e.preventDefault()
-			$('#playlist tbody tr').addClass 'selected'
-		# Del
-		else if e.keyCode is 46
-			e.preventDefault()
-			$('#playlist .selected').remove()
-			savePlaylist()
-		# Up arrow
-		else if e.keyCode is 38
-			e.preventDefault()
-			r = $('#playlist .active')
-			return selectRow $('#playlist tbody tr:last') if r.length is 0
-			return if r.prev().length is 0
-			if e.shiftKey
-				if r.hasClass('selected') and r.prev().hasClass('selected')
-					deSelectRow r
-				selectRow r.prev()
-			else if e.ctrlKey
-				r.removeClass('active').prev().addClass('active')
-			else
-				clearSelection()
-				selectRow r.prev()
-		# Down arrow
-		else if e.keyCode is 40
-			e.preventDefault()
-			r = $('#playlist .active')
-			return selectRow $('#playlist tbody tr:first') if r.length is 0
-			return if r.next().length is 0
-			if e.shiftKey
-				if r.hasClass('selected') and r.next().hasClass('selected')
-					deSelectRow r
-				selectRow r.next()
-			else if e.ctrlKey
-				r.removeClass('active').next().addClass('active')
-			else
-				clearSelection()
-				selectRow r.next()
-		# Page down
-		else if e.keyCode is 34
-			e.preventDefault()
-			n = Math.floor $('#playlist-wrapper').height() / $('#playlist tr:last').outerHeight()
-			if e.shiftKey
-				r = selectRowsUntil $('#playlist .active').findNext('tr', n)
-				selectRowsUntil $('#playlist tbody tr:last') if r is false
-			else
-				clearSelection()
-				r = selectRow $('#playlist .active').findNext('tr', n)
-				selectRow $('#playlist tbody tr:last') if r is false
-		# Page up
-		else if e.keyCode is 33
-			e.preventDefault()
-			n = Math.floor $('#playlist-wrapper').height() / $('#playlist tr:last').outerHeight()
-			if e.shiftKey
-				r = selectRowsUntil $('#playlist .active').findPrev('tr', n)
-				selectRowsUntil $('#playlist tbody tr:first') if r is false
-			else
-				clearSelection()
-				r = selectRow $('#playlist .active').findPrev('tr', n)
-				selectRow $('#playlist tbody tr:first') if r is false
-		# Home
-		else if e.keyCode is 36
-			e.preventDefault()
-			if e.shiftKey
-				selectRowsUntil $('#playlist tbody tr:first')
-			else if e.ctrlKey
-				$('#playlist .active').removeClass 'active'
-				$('#playlist tbody tr:first').addClass 'active'
-			else
-				clearSelection()
-				selectRow $('#playlist tbody tr:first')
-		# End
-		else if e.keyCode is 35
-			e.preventDefault()
-			if e.shiftKey
-				selectRowsUntil $('#playlist tbody tr:last')
-			else if e.ctrlKey
-				$('#playlist .active').removeClass 'active'
-				$('#playlist tbody tr:last').addClass 'active'
-			else
-				clearSelection()
-				selectRow $('#playlist tbody tr:last')
-		# Enter
-		else if e.keyCode is 13
-			e.preventDefault()
-			$('#playlist .active').dblclick()
-
-	# Sorting
-	sort = null
-	$('#playlist thead').on 'click', 'th', (e) ->
-		h = $(this)
-
-		psort = null
-		if $('#playlist thead').find('.icon-sort-up').length > 0
-			psort = $('#playlist thead').find('.icon-sort-up').parent()
-		else if $('#playlist thead').find('.icon-sort-down').length > 0
-			psort = $('#playlist thead').find('.icon-sort-down').parent()
-
-		psort = null if psort and h[0] is psort[0]
-
-		dir = null
-		if h.find('.icon-sort-up').length > 0
-			dir = 'down'
-			h.find('i').attr 'class', 'icon-sort-down'
-		else if h.find('.icon-sort-down').length > 0
-			dir = 'up'
-			h.find('i').attr 'class', 'icon-sort-up'
-		else
-			dir = 'up'
-			h.find('i').attr 'class', 'icon-sort-up'
-
-		psort?.find('i').attr 'class', ''
-
-		body = $('#playlist tbody')
-		rows = body.find('tr').toArray()
-
-		n = h.index()
-		pn = psort?.index()
-		int = (num) -> parseFloat num.replace(':', '.')
-
-		sortFun = (rowa, rowb) ->
-			if rowa.tagName?.toLowerCase() is 'tr'
-				a = $(rowa).find("td:eq(#{n})").text()
-				b = $(rowb).find("td:eq(#{n})").text()
-				inpsort = false
-			else
-				inpsort = true
-				a = $(rowa).text()
-				b = $(rowb).text()
-
-			if dir is 'up' and h.attr('data-sort') is 'numeric'
-				fun = ->
-					return 0 if int(a) is int(b)
-					return if int(a) > int(b) then 1 else -1
-			else if dir is 'down' and h.attr('data-sort') is 'numeric'
-				fun = ->
-					return 0 if int(a) is int(b)
-					return if int(b) > int(a) then 1 else -1
-			else if dir is 'up'
-				fun = -> a.localeCompare b
-			else if dir is 'down'
-				fun = -> b.localeCompare a
-
-			r = fun()
-
-			if r is 0 and not inpsort and psort?
-				r = sortFun $(rowa).find("td:eq(#{pn})"), $(rowb).find("td:eq(#{pn})")
-
-			return r
-
-		rows.sort sortFun
-
-		body.html ''
-		rows.forEach (r) -> body.append r
-		savePlaylist()
-
-
-# Set info to trackId
-setInfo = (trackId) ->
-	set = (track) ->
-		album = _infocache['albums'][track.album]
-		artist = _infocache['artists'][album.artist]
-
-		$('#info img').one 'load', ->
-			$('#info .table-wrapper').width $('#info').width() - $('#info img').width() - 20
-		$('#info img').attr 'src', album.coverdata
-
-		$('#info tbody').html('').append """
-			<tr>
-				<th>Artist name</th>
-				<td>#{artist.name.quote() or '[Unknown]'}</td>
-			</tr>
-			<tr>
-				<th>Album title</th>
-				<td>#{album.name.quote() or '[Unknown]'}</td>
-			</tr>
-			<tr>
-				<th>Track title</th>
-				<td>#{track.name.quote() or '[Unknown]'}</td>
-			</tr>
-			<tr>
-				<th>Released</th>
-				<td>#{track.released or '[Unknown]'}</td>
-			</tr>
-			<tr>
-				<th>Track number</th>
-				<td>#{track.trackno or '[Unknown]'}</td>
-			</tr>
-			<tr>
-				<th>Total tracks</th>
-				<td>#{album.numtracks or '[Unknown]'}</td>
-			</tr>
-			<tr>
-				<th>Disc number</th>
-				<td>#{track.discno or '[Unknown]'}</td>
-			</tr>
-			<tr>
-				<th>Total discs</th>
-				<td>#{album.numdiscs or '[Unknown]'}</td>
-			</tr>
-			<tr>
-				<th>Length</th>
-				<td>#{displaytime track.length or '[Unknown]'}</td>
-			</tr>
-			<tr>
-				<th>Filename</th>
-				<td>#{track.path.split('/').pop().quote()}</td>
-			</tr>
-			<tr>
-				<th>Directory</th>
-				<td>#{track.path.split('/')[..-2].join('/').quote()}</td>
-			</tr>
-		"""
-
-	if _infocache['tracks'][trackId]?
-		set _infocache['tracks'][trackId]
-	else
-		_inforeq.abort() if _inforeq
-		_inforeq = jQuery.ajax
-			url: "#{_root}/get-track/#{trackId}"
-			type: 'get'
-			dataType: 'json'
-			#error: (req, st) -> alert req.responseText if st isnt 'abort'
-			success: (data) ->
-				_inforeq = null
-				# TODO Clear old items from cache at some point
-				_infocache['artists'][data.artist.id] = data.artist
-				_infocache['albums'][data.album.id] = data.album
-				_infocache['tracks'][data.track.id] = data.track
-
-				set data.track
-
-
-# Try and play the next track
-playNext = (prev=false) ->
-	n = if prev then $('#playlist .playing').prev() else $('#playlist .playing').next()
-	$('#playlist .playing').removeClass 'playing'
-	if n.length > 0
-		playRow n
-		return true
-	else
-		return false
-
-
-# Try and play the previous track
-playPrev = -> playNext true
-
-
-# Init. the player
-# We also update the statusbar here for efficiency
-initPlayer = ->
-	bufstart = null
-
-	$('#player').on 'click', '.play', (e) ->
-		if isNaN(_audio.duration)
-			active = $('#playlist .active')
-			return playRow active if active.length > 0
-			return playRow $('#playlist tbody tr:eq(0)')
-		_audio.play()
-
-	$('#player').on 'click', '.pause', (e) -> _audio.pause()
-	$('#player').on 'click', '.forward', (e) -> playNext()
-	$('#player').on 'click', '.backward', (e) -> playPrev()
-
-	$('#player').on 'click', '.stop', (e) ->
-		$('.seekbar .buffer').css 'width', '0px'
-		_audio.pause()
-		$('#playlist tr').removeClass 'playing'
-		_audio.src = ''
-		$('#player').attr 'class', 'right-of-library stopped'
-		store.set 'lasttrack', null
-		bufstart = null
-		$('#status span:eq(0)').html 'Stopped'
-
-	window.vol = new Slider
-		target: $('#player .volume')
-		move: (pos) ->
-			v = Math.min 1, pos * 2 / 100
-			_audio.volume = v
-			store.set 'volume', v
-			return Math.round pos
-
-	if store.get('volume') isnt null
-		_audio.volume = store.get 'volume'
-		vol.setpos _audio.volume * 100
-	else
-		vol.setpos 50
-		_audio.volume = 0.5
-
-	seekbar = new Slider
-		target: $('#player .seekbar')
-		start: -> _draggingseekbar = true
-		move: (pos) ->
-			v = $('audio')[0].seekable.end(0) / 100 * pos
-			$('audio')[0].currentTime = v
-			return displaytime v
-		stop: -> _draggingseekbar = false
-
-	$(_audio).bind 'play', ->
-		$('#player').attr 'class', 'right-of-library playing'
-		$('#playlist .playing .icon-pause').attr 'class', 'icon-play'
-
-	$(_audio).bind 'pause', ->
-		$('#player').attr 'class', 'right-of-library paused'
-		$('#playlist .playing .icon-play').attr 'class', 'icon-pause'
-		$('#status span:eq(0)').html 'Paused'
-
-	$(_audio).bind 'ended', ->
-		$('.seekbar .buffer').css 'width', '0px'
-		bufstart = null
-		unless playNext()
-			$('#player').attr 'class', 'right-of-library stopped'
-			store.set 'lasttrack', null
-			$('#status span:eq(0)').html 'Stopped'
-
-	$(_audio).bind 'timeupdate', (e) ->
-		return if _draggingseekbar
-		v = _audio.currentTime / _curplaying.length * 100
-		seekbar.setpos v
-
-		t = displaytime _audio.currentTime
-		$('#status span:eq(0)').html 'Playing'
-		$('#status span:eq(1)').html "#{t} / #{displaytime _curplaying.length}"
-
-	$(_audio).bind 'progress', (e) ->
-		try
-			c = Math.round(_audio.buffered.end(0) / _curplaying.length * 100)
-		catch exc
-			return
-
-		if c is 100
-			$('#status span:eq(2)').html "Buffer #{c}%"
-		else
-			unless bufstart
-				bufstart = new Date().getTime() / 1000
-				return