Commits

Martin Tournoij committed b1d6f7c

A whole bunch of stuff

  • Participants
  • Parent commits 4bc5242

Comments (0)

Files changed (31)

 - [jQuery](http://jquery.com/)
 - [jQuery.mousewheel](http://brandonaaron.net)
 - [Perfect Scrollbar](http://github.com/noraesae)
+- [Javascript MD5](http://pajhome.org.uk/crypt/md5/md5.html)
-- Improve row selection in playlist
 - Gapless playback
-- ReplayGain
-- last.fm
+- Deal with high-res cover art
 
 
 A TODO list :-)
 
-- Control config.cfg from browser
-- Add various options to config.cfg
 - Select multiple rows in playlist with the mouse
 - Test (& fix) various browsers
 - Actually deal with various errors that may occur
 
 nordavind._root = os.path.dirname(os.path.realpath(sys.argv[0]))
 
-db = nordabind.openDb()
+db = nordavind.openDb()
 c = db.cursor()
 paths = [ r['path'] for r in c.execute('select path from tracks').fetchall() ]
 

nordavind/__init__.py

 # See below for full copyright
 #
 
-import os, re, sys, urllib.parse, sqlite3, shlex, subprocess, base64, glob
+import os, re, sys, urllib.parse, sqlite3, base64, glob
 
-from jinja2 import Environment, FileSystemLoader
 import taglib
 
+import nordavind.audio
+
 
 _root = os.path.dirname(os.path.realpath(sys.argv[0]))
 _wwwroot = ''
-_procs = {}
 config = None
 
 
-def openDb():
+def openDb(create=True):
 	db = sqlite3.connect(config['dbpath'],
 		detect_types=sqlite3.PARSE_DECLTYPES | sqlite3.PARSE_COLNAMES)
 
-	if len(db.cursor().execute('select * from sqlite_master where type="table"').fetchall()) == 0:
+	if create and len(db.cursor().execute('select * from sqlite_master where type="table"').fetchall()) == 0:
 		createDb()
 
 	def dict_factory(cursor, row):
 
 
 def template(f, v):
+	from jinja2 import Environment, FileSystemLoader
+
 	env = Environment(loader=FileSystemLoader('%s/tpl' % _root))
 	env.filters['urlencode'] = lambda s: urllib.parse.quote_plus(s, '')
 
 
 
 def createDb():
-	db = openDb()
+	db = openDb(False)
 	c = db.cursor()
 
 	c.execute('''create table artists (
 		numdiscs int,
 		numtracks int,
 		cover text,
+		rg_gain real,
+		rg_peak real,
 		foreign key(artist) references artists(id) on delete cascade
 	)''')
 
 		discno int,
 		length int,
 		path text not null,
+		rg_gain real,
+		rg_peak real,
 		foreign key(album) references albums(id) on delete cascade
 	)''')
 
 		(path,)).fetchone()
 
 	tags = getTags(path)
+
 	# Add track
 	if track is None:
 		album = c.execute('select * from albums where name = ?',
 				else:
 					cover = None
 
-			c.execute('insert into albums (artist, name, released, cover, numtracks, numdiscs) values(?, ?, ?, ?, ?, ?)',
-				(artist, tags.get('album'), tags.get('date', '').split('-')[0], cover, tags.get('tracktotal'), tags.get('disctotal')))
+			c.execute('''insert into albums (artist, name, released, cover, numtracks, numdiscs, rg_gain, rg_peak)
+				values(?, ?, ?, ?, ?, ?, ?, ?)''',
+				(artist, tags.get('album'), tags.get('date', '').split('-')[0], cover, tags.get('tracktotal'),
+				tags.get('disctotal'), float(tags.get('replaygain_album_gain', '0').replace('dB', '')), float(tags.get('replaygain_album_peak', 0))))
 			album = c.lastrowid
 		else:
 			album = album['id']
 
-		c.execute('insert into tracks (path, name, album, trackno, discno, length) values (?, ?, ?, ?, ?, ?)',
-			(path, tags.get('title'), album, tags.get('tracknumber'), tags.get('discnumber'), tags.get('length')))
+		c.execute('insert into tracks (path, name, album, trackno, discno, length, rg_gain, rg_peak) values (?, ?, ?, ?, ?, ?, ?, ?)',
+			(path, tags.get('title'), album, tags.get('tracknumber'), tags.get('discnumber'), tags.get('length'),
+			float(tags.get('replaygain_track_gain', '0').replace('dB', '')), float(tags.get('replaygain_track_peak', 0))))
 	# Update track
 	else:
 		c.execute('''update tracks set name = ?, trackno = ?, discno = ?, length = ? where id = ?''',
 
 
 def getTags(path):
-	r = {}
-	# Sometimes this prints a (harmless) warning, AFAIK this can't be disabled
-	# :-/
+	# Sometimes this prints a (harmless) warning, AFAIK this can't be disabled :-/
 	f = taglib.File(path)
 
+	r = {}
 	for k, v in f.tags.items():
 		if k in ['DISCNUMBER', 'TRACKNUMBER']:
 			v = [v[0].split('/')[0]]
 	return r
 
 def playTrack(codec, id):
-	global _procs
 	c = openDb().cursor()
 	track = c.execute('select * from tracks where id=?', (id,)).fetchone()
 
 			if not buf: break
 			yield buf
 	else:
-		path = track['path']
-		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 %s' % shlex.quote(path)
-				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 %s' % shlex.quote(path)
-				cache = None
-
-		_procs[id] = subprocess.Popen(cmd, shell=True, stdout=subprocess.PIPE)
-
 		if cache is not None:
 			cachefp = open(cache + '_temp', 'wb')
-
-		while True:
-			buf = _procs[id].stdout.read(1024)
-			if not buf: break
+		
+		for buf in nordavind.audio.convert(track['path'], codec):
 			if cache is not None: cachefp.write(buf)
 			yield buf
 
-		del _procs[id]
 		if cache is not None:
 			cachefp.close()
 			os.rename(cache + '_temp', cache)
 
 
-def playTrack_clean(id):
-	global _procs
-
-	if _procs.get(id):
-		cleanCache(id)
-		_procs.get(id).kill()
-		del _procs[id]
-
-
 def getAlbum(id):
 	c = openDb().cursor()
 

nordavind/audio.py

+""" Deal with audio data
+
+This mostly uses commandline tools, not because it's the fastest or even te
+`best' way, but because it's easy, requires little programming, and very few
+dependencies.
+Libraries such as gstreamer are great, but it pulls in half of Gnome as a
+dependency, which is okay for a desktop, but not always okay for a server.
+"""
+
+
+import subprocess, sys
+
+
+__all__ = ['convert']
+
+
+def convert(path, codec):
+	decode = getattr(sys.modules[__name__], 'decode_' + path.split('.').pop())
+	encode = getattr(sys.modules[__name__], 'encode_' + codec)
+
+	if encode == decode:
+		fp = open(path, 'r')
+	else:
+		src = open(path, 'r')
+		wav = decode(src)
+		fp = encode(wav)
+
+	while True:
+		buf = fp.read(1024)
+		if not buf: break
+		yield buf
+
+
+'''
+def playTrack_clean(id):
+	global _procs
+
+	if _procs.get(id):
+		cleanCache(id)
+		_procs.get(id).kill()
+		del _procs[id]
+'''
+
+
+
+'''
+		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 %s' % shlex.quote(path)
+				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 %s' % shlex.quote(path)
+				cache = None
+
+		_procs[id] = subprocess.Popen(cmd, shell=True, stdout=subprocess.PIPE)
+
+		if cache is not None:
+			cachefp = open(cache + '_temp', 'wb')
+
+		while True:
+			buf = _procs[id].stdout.read(1024)
+			if not buf: break
+			if cache is not None: cachefp.write(buf)
+			yield buf
+
+		del _procs[id]
+		if cache is not None:
+			cachefp.close()
+			os.rename(cache + '_temp', cache)
+'''
+
+
+def _exec(fp, cmd):
+	proc = subprocess.Popen(cmd, stdin=fp, stdout=subprocess.PIPE)
+
+	# TODO: Better error checking
+
+	return proc.stdout
+
+
+def decode_flac(fp): return _exec(fp, ['flac', '-s', '-d', '-o-', '-'])
+def decode_ogg(fp): return _exec(fp, ['oggdec', '-Q', '-o-'])
+def decode_mp3(fp): return _exec(fp, ['mpg123', '-q', '-w-', '-'])
+def encode_ogg(fp): return _exec(fp, ['oggenc',  '-', '-q8', '-Q', '-o-'])
+def encode_mp3(fp): return _exec(fp, ['lame', '--quiet', '-V2', '-', '-'])
 
 
 	@cherrypy.expose
+	def get_settings():
+		return nordavind.template('settings.html', {
+		})
+
+
+	@cherrypy.expose
+	def lastfm_callback(token=None):
+		return '''
+			<html><head></head></html><body>
+			<script>localStorage.setItem('token', '%s'); window.close()</script>
+			You can close this window
+			</body></html>
+		''' % (token,)
+
+
+	@cherrypy.expose
 	def get_album(albumid):
 		dbcache()
 		return json.dumps(nordavind.getAlbum(albumid),
 	@cherrypy.expose
 	def play_track(codec, trackid):
 		cherrypy.response.headers['Content-Type'] = 'audio/%s' % codec
-		cherrypy.request.hooks.attach('on_end_request',
-			lambda: nordavind.playTrack_clean(trackid), True)
+		#cherrypy.request.hooks.attach('on_end_request',
+		#	lambda: nordavind.playTrack_clean(trackid), True)
 		return nordavind.playTrack(codec, trackid)
 	play_track._cp_config = {'response.stream': True}
 

tpl/bootstrap/bootstrap.css

 .table-bordered colgroup + tbody tr:first-child td:last-child {
   border-top-right-radius: 2px;
 }
-.table-striped tbody > tr:nth-child(odd) > td,
-.table-striped tbody > tr:nth-child(odd) > th {
+.table-striped tbody > tr:nth-child(even) > td,
+.table-striped tbody > tr:nth-child(even) > th {
   background-color: #f9f9f9;
 }
 .table-hover tbody tr:hover > td,

tpl/bootstrap/tables.less

 // Default zebra-stripe styles (alternating gray and transparent backgrounds)
 .table-striped {
   tbody {
-    > tr:nth-child(odd) > td,
-    > tr:nth-child(odd) > th {
+    > tr:nth-child(even) > td,
+    > tr:nth-child(even) > th {
       background-color: @tableBackgroundAccent;
     }
   }
 				<img src='#{img.attr 'src'}' alt=''>
 			</div>"""
 
+			$('#large-cover')
+				.css
+					width: "#{img.width()}px"
+					height: "#{img.height()}px"
+				.animate {
+					width: '100%'
+					height: '100%'
+				}, {
+					duration: 500
+				}
+
 			$('#large-cover').one 'click', (e) ->
-				$(this).remove()
+				$(this).animate {
+					width: "#{img.width()}px"
+					height: "#{img.height()}px"
+				}, {
+					complete: => $(this).remove()
+					duration: 500
+				}
+
+
+	###
+	###
+	getInfo: (trackId) ->
+		track = _cache.tracks[trackId]
+
+		return [null, null, null] unless track?
+
+		album = _cache.albums[track.album]
+		artist = _cache.artists[album.artist]
+
+		return [track, album, artist]
 
 	###
 	###
           return;
         }
         $('body').append("<div id='large-cover'>\n	<img src='" + (img.attr('src')) + "' alt=''>\n</div>");
+        $('#large-cover').css({
+          width: "" + (img.width()) + "px",
+          height: "" + (img.height()) + "px"
+        }).animate({
+          width: '100%',
+          height: '100%'
+        }, {
+          duration: 500
+        });
         return $('#large-cover').one('click', function(e) {
-          return $(this).remove();
+          var _this = this;
+
+          return $(this).animate({
+            width: "" + (img.width()) + "px",
+            height: "" + (img.height()) + "px"
+          }, {
+            complete: function() {
+              return $(_this).remove();
+            },
+            duration: 500
+          });
         });
       });
     }
     */
 
 
+    Info.prototype.getInfo = function(trackId) {
+      var album, artist, track;
+
+      track = _cache.tracks[trackId];
+      if (track == null) {
+        return [null, null, null];
+      }
+      album = _cache.albums[track.album];
+      artist = _cache.artists[album.artist];
+      return [track, album, artist];
+    };
+
+    /*
+    */
+
+
     Info.prototype.clear = function() {
       $('#info img').attr('src', '');
       return $('#info tbody').html('');
 # Convenient shortcuts
+window.log = -> console.log.apply console, arguments if console?.log?
+window.$$ = (s) -> $(s).toArray()
+String.prototype.toNum = -> parseInt this, 10
+Number.prototype.toNum = -> parseInt this, 10
+jQuery.fn.replaceHTML = (s, r) -> this.html(this.html().replace s, r)
+
+
 Function.prototype.timeout = (time, args) ->
-	setTimeout =>
+	this.timeoutId = setTimeout =>
 		this.apply this, args
 	, time
+Function.prototype.clearTimeout = -> clearTimeout this.timeoutId
+
 
 Function.prototype.interval = (time, args) ->
-	setInterval =>
+	this.intervalId = setInterval =>
 		this.apply this, args
 	, time
+Function.prototype.clearInterval = -> clearInterval this.intervalId
 
-String.prototype.toNum = -> parseInt this, 10
 
 # Escape HTML entities
 String.prototype.quote = ->
 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)
+	del: (k) -> localStorage.removeItem k
 	init: (k, v) -> localStorage.setItem k, JSON.stringify(v) unless localStorage.getItem k
 
 
 		setSize()
 		move?.apply this, [e]
 
+	origzindex = $(handle).css 'z-index'
 	mousedown = (e) ->
 		handle.addClass 'dragging'
 		dragging = true
 	mouseup = (e) ->
 		handle.removeClass 'dragging'
 		dragging = false
+		$(handle).css 'z-index', origzindex
 		end?.apply this, [e]
 
 	handle.on 'mousedown', mousedown
 (function() {
   var Slider;
 
+  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();
+  };
+
+  String.prototype.toNum = function() {
+    return parseInt(this, 10);
+  };
+
+  Number.prototype.toNum = function() {
+    return parseInt(this, 10);
+  };
+
+  jQuery.fn.replaceHTML = function(s, r) {
+    return this.html(this.html().replace(s, r));
+  };
+
   Function.prototype.timeout = function(time, args) {
     var _this = this;
 
-    return setTimeout(function() {
+    return this.timeoutId = setTimeout(function() {
       return _this.apply(_this, args);
     }, time);
   };
 
+  Function.prototype.clearTimeout = function() {
+    return clearTimeout(this.timeoutId);
+  };
+
   Function.prototype.interval = function(time, args) {
     var _this = this;
 
-    return setInterval(function() {
+    return this.intervalId = setInterval(function() {
       return _this.apply(_this, args);
     }, time);
   };
 
-  String.prototype.toNum = function() {
-    return parseInt(this, 10);
+  Function.prototype.clearInterval = function() {
+    return clearInterval(this.intervalId);
   };
 
   String.prototype.quote = function() {
     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));
     },
+    del: function(k) {
+      return localStorage.removeItem(k);
+    },
     init: function(k, v) {
       if (!localStorage.getItem(k)) {
         return localStorage.setItem(k, JSON.stringify(v));
   };
 
   window.babyUrADrag = function(handle, start, move, end) {
-    var dragging, mousedown, mousemove, mouseup;
+    var dragging, mousedown, mousemove, mouseup, origzindex;
 
     dragging = false;
     mousemove = function(e) {
       setSize();
       return move != null ? move.apply(this, [e]) : void 0;
     };
+    origzindex = $(handle).css('z-index');
     mousedown = function(e) {
       handle.addClass('dragging');
       dragging = true;
     mouseup = function(e) {
       handle.removeClass('dragging');
       dragging = false;
+      $(handle).css('z-index', origzindex);
       return end != null ? end.apply(this, [e]) : void 0;
     };
     handle.on('mousedown', mousedown);

tpl/library.coffee

 					pl.append row
 					save.push row
 				$('#playlist-wrapper').scrollbar 'update'
+				window.playlist.headSize()
 				store.set 'playlist', store.get('playlist').concat save
 
 
+<ol>
+	{% for artist in library %}
+		<li class="artist" data-id="{{ artist.id }}">
+			<i class="icon-expand-alt"></i>
+			<span>{{ artist.name|e }}</span>
+		</li>
+
+		{% for album in artist.albums %}
+			<li class="album" data-id={{ album.id }}>
+				<span>{{ album.released }} {{ album.name|e }}</span>
+			</li>
+		{% endfor %}
+	{% endfor %}
+</ol>
             save.push(row);
           }
           $('#playlist-wrapper').scrollbar('update');
+          window.playlist.headSize();
           return store.set('playlist', store.get('playlist').concat(save));
         }
       });
 	# We need a fixed height for the scrollbar to work
 	$('#library ol').css 'height', "#{$(window).height() - $('#library ol').offset().top}px"
 
-	$('.seekbar').css 'width', "#{$('#player').width() - $('.volume').outerWidth() - $('.volume').position().left - 30}px"
+	$('.seekbar').css 'width', (
+		$('#player').width() -
+		$('.volume').outerWidth() -
+		$('.volume').position().left -
+		$('.buttons-right').outerWidth() -
+		30
+	) + 'px'
 	$('#playlist-wrapper').css 'bottom', "#{$('#info').height() + $('#status').height() + 3}px"
 	$('#info .table-wrapper').width $('#info').width() - $('#info img').width() - 20
 
+	$('#playlist-thead').css 'left', "#{$('#playlist').offset().left}px"
+	window.playlist.headSize() if window.playlist?.headSize?
+
 
 initGlobalKeys = ->
 	cycle = ['#library', '#playlist-wrapper', '#search input', '#player .play',
 	$('input').val ''
 
 	store.init 'playlist', []
+	store.init 'replaygain', 'album'
 
 	setSize()
 
 
 	setSize()
 	$(window).on 'resize', setSize
+	window.playlist.headSize()
 
 	window.playlist.playRow $("#playlist tr[data-id=#{store.get('lasttrack')}]") if store.get('lasttrack')?
 
   background-image: -o-linear-gradient(top, #eaeaea, #f7f7f7);
   background-image: linear-gradient(to bottom, #eaeaea, #f7f7f7);
   background-repeat: repeat-x;
-  border-bottom: 1px solib #b7b7b7;
-  box-shadow: 4px 0px 4px rgba(0, 0, 0, 0.4);
+  border-bottom: 1px solid #b7b7b7;
+  box-shadow: 2px 0px 2px rgba(0, 0, 0, 0.4);
 }
 /*** Defaults ***/
 html,
   height: 100%;
   overflow: hidden;
 }
+fieldset {
+  border: 1px solid #aaa;
+  border-radius: 2px;
+}
 /*** Layout & positions ***/
 #library {
   position: absolute;
   background-image: -o-linear-gradient(top, #eaeaea, #f7f7f7);
   background-image: linear-gradient(to bottom, #eaeaea, #f7f7f7);
   background-repeat: repeat-x;
-  border-bottom: 1px solib #b7b7b7;
-  box-shadow: 4px 0px 4px rgba(0, 0, 0, 0.4);
+  border-bottom: 1px solid #b7b7b7;
+  box-shadow: 2px 0px 2px rgba(0, 0, 0, 0.4);
 }
 #library #search input {
   border: 1px solid #b7b7b7;
   background-image: -o-linear-gradient(top, #eaeaea, #f7f7f7);
   background-image: linear-gradient(to bottom, #eaeaea, #f7f7f7);
   background-repeat: repeat-x;
-  border-bottom: 1px solib #b7b7b7;
-  box-shadow: 4px 0px 4px rgba(0, 0, 0, 0.4);
+  border-bottom: 1px solid #b7b7b7;
+  box-shadow: 2px 0px 2px rgba(0, 0, 0, 0.4);
 }
 #player .btn {
   margin-right: 5px;
 #player.stopped .pause {
   display: none;
 }
+#player .buttons-right {
+  margin-right: 4px;
+}
 /*** Info ***/
 #info .table-wrapper {
   position: relative;
   right: 2px;
 }
 #playlist thead {
-  background-color: #eee;
-  cursor: default;
+  visibility: hidden;
 }
 #playlist tbody tr {
   cursor: default;
 }
-#playlist th {
-  padding: 1px 0;
-  border: 1px solid #bbb;
-  padding-left: 4px;
-}
-#playlist th span {
-  margin-right: 8px;
-}
-#playlist th i {
-  font-size: 14px;
-  line-height: 14px;
-  margin-left: 8px;
-  color: #666;
-  position: relative;
-  top: 1px;
-}
 #playlist td {
   padding: 0;
   padding-left: 5px;
 }
 #large-cover {
   position: absolute;
-  left: 0px;
   right: 0px;
-  top: 0px;
   bottom: 20px;
+  height: 0px;
+  width: 0px;
   z-index: 100;
 }
 #large-cover img {
   box-shadow: -2px -2px 2px rgba(0, 0, 0, 0.05);
   border-top-left-radius: 1px;
 }
+#dialog {
+  position: fixed;
+  top: -150px;
+  left: 50%;
+  z-index: 100;
+  opacity: 0;
+  background-color: #fff;
+  border: 1px solid #e5e5e5;
+  border-radius: 3px;
+  box-shadow: 2px 2px 2px rgba(0, 0, 0, 0.05);
+  min-height: 200px;
+  width: 600px;
+  margin-left: -300px;
+}
+#dialog .content {
+  padding: 10px;
+}
+#dialog .buttons {
+  background-color: #e5e5e5;
+  border-top: 1px solid #cdcdcd;
+  position: absolute;
+  bottom: 0;
+  left: -1px;
+  right: -1px;
+  text-align: right;
+  padding: 5px;
+}
+#dialog .lastfm .btn {
+  display: none;
+}
+#dialog .lastfm-enabled .disable-lastfm {
+  display: block;
+}
+#dialog .lastfm-disabled .enable-lastfm {
+  display: block;
+}
+#dialog .lastfm-loading .loading-lastfm {
+  display: block;
+}
+#backdrop {
+  position: fixed;
+  top: 0;
+  bottom: 0;
+  left: 0;
+  right: 0;
+  background-color: #000;
+  opacity: 0;
+  z-index: 90;
+}
+#playlist-thead {
+  cursor: default;
+  position: fixed;
+  left: 322px;
+  right: 20px;
+  border: 1px solid #a1a1a1;
+  border-bottom: 1px solid #cdcdcd;
+  background-color: #e6e6e6;
+  background-image: linear-gradient(to bottom, #ffffff, #e6e6e6);
+}
+#playlist-thead .cell {
+  display: inline-block;
+  padding-left: 5px;
+  float: left;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+  border-top: 1px solid #fff;
+  border-left: 1px solid #fff;
+  border-right: 1px solid #808080;
+  border-bottom: 1px solid #808080;
+}
+#playlist-thead .cell span {
+  margin-right: 8px;
+}
+#playlist-thead .cell i {
+  font-size: 14px;
+  line-height: 14px;
+  margin-left: 8px;
+  color: #666;
+  position: relative;
+  top: 1px;
+}
 					<input placeholder="Filter (artist/album)" />
 				</div>
 			</div>
-			<ol>
-				{% for artist in library %}
-					<li class="artist" data-id="{{ artist.id }}">
-						<i class="icon-expand-alt"></i>
-						<span>{{ artist.name|e }}</span>
-					</li>
-					{% for album in artist.albums %}
-						<li class="album" data-id={{ album.id }}>
-							<span>{{ album.released }} {{ album.name|e }}</span>
-						</li>
-					{% endfor %}
-				{% endfor %}
-			</ol>
+			{% include "library.html" %}
 			<div class="resize-handle resize-horizontal"></div>
 		</div>
 
 
 			<span class="volume"></span>
 			<span class="seekbar"><span class="buffer"></span></span>
+
+			<span class="buttons-right">
+				<button class="btn btn-small settings"><i class="icon-wrench"></i></button>
+			</span>
 		</div>
 
 		<div id="playlist-wrapper" class="right-of-library pane">
+			{# Making the <thead> behave in the way I wanted it proved to be impossible, so we fake it #}
+			<div id="playlist-thead">
+				<span class="cell play-icon" title="Playing">
+					<span>Playing</span>
+					<i></i>
+				</span>
+				<span class="cell trackno" title="Disc number.Track number" data-sort="numeric">
+					<span>Disc number.Track number</span>
+					<i></i>
+				</span>
+				<span class="cell artist-album" title="Artist / Album">
+					<span>Artist / Album</span>
+					<i></i>
+				</span>
+				<span class="cell title" title="Title">
+					<span>Title</span>
+					<i></i>
+				</span>
+				<span class="cell length" title="Length" data-sort="numeric">
+					<span>Length</span>
+					<i></i>
+				</span>
+			</div>
+
 			<table id="playlist" class="table table-condensed table-striped">
 				<thead>
 					<tr>
-						<th class="play-icon" title="Playing"><span></span><i></i></th>
-						<th class="trackno" title="Disc number.Track number" data-sort="numeric"><span></span><i></i></th>
-						<th class="artist-album" title="Artist / Album"><span>Artist / Album</span><i></i></th>
-						<th class="title" title="Title"><span>Title</span><i></i></th>
-						<th class="length" title="Length" data-sort="numeric"><span></span><i></i></th>
+						<th class="play-icon">a</th>
+						<th class="trackno">a</th>
+						<th class="artist-album">a</th>
+						<th class="title">a</th>
+						<th class="length">a</th>
 					</tr>
 				</thead>
 
 			<span>Stopped</span>
 			<span></span>
 			<span></span>
-			<a href="http://code.arp242.net/nordavind">Nordavind {{ version }}</a>
+			<a href="http://code.arp242.net/nordavind"><i class="icon-bitbucket"></i> Nordavind {{ version }}</a>
 		</div>
 
 		<script src="/tpl/jquery.js?v={{ version }}"></script>
 		<script src="/tpl/jquery.mousewheel.js?v={{ version }}"></script>
 		<script src="/tpl/scrollbar.js?v={{ version }}"></script>
+		<script src="/tpl/md5.js?v={{ version }}"></script>
 
 		<script src="/tpl/lib.js?v={{ version }}"></script>
+		<script src="/tpl/settings.js?v={{ version }}"></script>
 		<script src="/tpl/library.js?v={{ version }}"></script>
 		<script src="/tpl/playlist.js?v={{ version }}"></script>
 		<script src="/tpl/player.js?v={{ version }}"></script>
 		<script src="/tpl/info.js?v={{ version }}"></script>
+		<script src="/tpl/scrobble.js?v={{ version }}"></script>
 
 		<script src="/tpl/main.js?v={{ version }}"></script>
 	</body>
   };
 
   window.setSize = function() {
+    var _ref;
+
     $('#library ol').css('height', "" + ($(window).height() - $('#library ol').offset().top) + "px");
-    $('.seekbar').css('width', "" + ($('#player').width() - $('.volume').outerWidth() - $('.volume').position().left - 30) + "px");
+    $('.seekbar').css('width', ($('#player').width() - $('.volume').outerWidth() - $('.volume').position().left - $('.buttons-right').outerWidth() - 30) + 'px');
     $('#playlist-wrapper').css('bottom', "" + ($('#info').height() + $('#status').height() + 3) + "px");
-    return $('#info .table-wrapper').width($('#info').width() - $('#info img').width() - 20);
+    $('#info .table-wrapper').width($('#info').width() - $('#info img').width() - 20);
+    $('#playlist-thead').css('left', "" + ($('#playlist').offset().left) + "px");
+    if (((_ref = window.playlist) != null ? _ref.headSize : void 0) != null) {
+      return window.playlist.headSize();
+    }
   };
 
   initGlobalKeys = function() {
   $(document).ready(function() {
     $('input').val('');
     store.init('playlist', []);
+    store.init('replaygain', 'album');
     setSize();
     window.library = new Library();
     window.playlist = new Playlist();
     initGlobalKeys();
     setSize();
     $(window).on('resize', setSize);
+    window.playlist.headSize();
     if (store.get('lasttrack') != null) {
       return window.playlist.playRow($("#playlist tr[data-id=" + (store.get('lasttrack')) + "]"));
     }
 
 .player {
 	#gradient > .vertical(#eaeaea, #f7f7f7);
-	border-bottom: 1px solib #b7b7b7;
-	box-shadow: 4px 0px 4px rgba(0, 0, 0, 0.400);
+	border-bottom: 1px solid #b7b7b7;
+	box-shadow: 2px 0px 2px rgba(0, 0, 0, .4);
 }
 
 /*** Defaults ***/
 	height: 100%;
 	overflow: hidden;
 }
+fieldset {
+	border: 1px solid #aaa;
+	border-radius: 2px;
+}
 
 /*** Layout & positions ***/
 #library {
 	&.paused, &.stopped {
 		.pause { display: none; }
 	}
+
+	.buttons-right {
+		margin-right: 4px;
+	}
 }
 
 
 }
 
 #playlist {
-
 	thead {
-		background-color: #eee;
-		cursor: default;
+		visibility: hidden;
 	}
 
 	tbody {
 		}
 	}
 
-	th {
-		padding: 1px 0;
-		border: 1px solid #bbb;
-		padding-left: 4px;
-
-		span {
-			margin-right: 8px;
-		}
-		i {
-			font-size: 14px;
-			line-height: 14px;
-			margin-left: 8px;
-			color: #666;
-			position: relative;
-			top: 1px;
-		}
-	}
-
 	td {
 		padding: 0;
 		padding-left: 5px;
 
 #large-cover {
 	position: absolute;
-	left: 0px;
 	right: 0px;
-	top: 0px;
 	bottom: @statusHeight;
+	height: 0px;
+	width: 0px;
+	//left: 0px;
+	//top: 0px;
 	z-index: 100;
 
 	img {
 		border-top-left-radius: 1px;
 	}
 }
+
+#dialog {
+	position: fixed;
+	top: -150px;
+	left: 50%;
+	z-index: 100;
+	opacity: 0;
+	background-color: #fff;
+	border: 1px solid #e5e5e5;
+	border-radius: 3px;
+	box-shadow: 2px 2px 2px rgba(0, 0, 0, .05);
+	min-height: 200px;
+	width: 600px;
+	margin-left: -300px;
+
+	.content {
+		padding: 10px;
+	}
+
+	.buttons {
+		background-color: #e5e5e5;
+		border-top: 1px solid #cdcdcd;
+		position: absolute;
+		bottom: 0;
+		left: -1px;
+		right: -1px;
+		text-align: right;
+		padding: 5px;
+	}
+
+	.lastfm .btn {
+		display: none;
+	}
+
+	.lastfm-enabled .disable-lastfm {
+		display: block;
+	}
+	.lastfm-disabled .enable-lastfm {
+		display: block;
+	}
+	.lastfm-loading .loading-lastfm {
+		display: block;
+	}
+}
+
+#backdrop {
+	position: fixed;
+	top: 0;
+	bottom: 0;
+	left: 0;
+	right: 0;
+	background-color: #000;
+	opacity: 0;
+	z-index: 90;
+}
+
+#playlist-thead {
+	cursor: default;
+	position: fixed;
+	left: 322px;
+	right: 20px;
+
+	border: 1px solid #a1a1a1;
+	border-bottom: 1px solid #cdcdcd;
+
+	background-color: #e6e6e6;
+	background-image: linear-gradient(to bottom, #fff, #e6e6e6);
+
+	.cell {
+		display: inline-block;
+		padding-left: 5px;
+		float: left;
+
+		overflow: hidden;
+		text-overflow: ellipsis;
+		white-space: nowrap;
+
+		border-top: 1px solid #fff;
+		border-left: 1px solid #fff;
+		border-right: 1px solid #808080;
+		border-bottom: 1px solid #808080;
+
+		span {
+			margin-right: 8px;
+		}
+		i {
+			font-size: 14px;
+			line-height: 14px;
+			margin-left: 8px;
+			color: #666;
+			position: relative;
+			top: 1px;
+		}
+	}
+}
+/*
+ * A JavaScript implementation of the RSA Data Security, Inc. MD5 Message
+ * Digest Algorithm, as defined in RFC 1321.
+ * Version 2.2 Copyright (C) Paul Johnston 1999 - 2009
+ * Other contributors: Greg Holt, Andrew Kepert, Ydnar, Lostinet
+ * Distributed under the BSD License
+ * See http://pajhome.org.uk/crypt/md5 for more info.
+ */
+
+function md5(s)
+{
+	return rstr2hex(rstr_md5(str2rstr_utf8(s)));
+}
+
+/*
+ * Calculate the MD5 of a raw string
+ */
+function rstr_md5(s)
+{
+  return binl2rstr(binl_md5(rstr2binl(s), s.length * 8));
+}
+
+/*
+ * Convert a raw string to a hex string
+ */
+function rstr2hex(input)
+{
+  var hex_tab = "0123456789abcdef";
+  var output = "";
+  var x;
+  for(var i = 0; i < input.length; i++)
+  {
+    x = input.charCodeAt(i);
+    output += hex_tab.charAt((x >>> 4) & 0x0F)
+           +  hex_tab.charAt( x        & 0x0F);
+  }
+  return output;
+}
+
+/*
+ * Encode a string as utf-8.
+ * For efficiency, this assumes the input is valid utf-16.
+ */
+function str2rstr_utf8(input)
+{
+  var output = "";
+  var i = -1;
+  var x, y;
+
+  while(++i < input.length)
+  {
+    /* Decode utf-16 surrogate pairs */
+    x = input.charCodeAt(i);
+    y = i + 1 < input.length ? input.charCodeAt(i + 1) : 0;
+    if(0xD800 <= x && x <= 0xDBFF && 0xDC00 <= y && y <= 0xDFFF)
+    {
+      x = 0x10000 + ((x & 0x03FF) << 10) + (y & 0x03FF);
+      i++;
+    }
+
+    /* Encode output as utf-8 */
+    if(x <= 0x7F)
+      output += String.fromCharCode(x);
+    else if(x <= 0x7FF)
+      output += String.fromCharCode(0xC0 | ((x >>> 6 ) & 0x1F),
+                                    0x80 | ( x         & 0x3F));
+    else if(x <= 0xFFFF)
+      output += String.fromCharCode(0xE0 | ((x >>> 12) & 0x0F),
+                                    0x80 | ((x >>> 6 ) & 0x3F),
+                                    0x80 | ( x         & 0x3F));
+    else if(x <= 0x1FFFFF)
+      output += String.fromCharCode(0xF0 | ((x >>> 18) & 0x07),
+                                    0x80 | ((x >>> 12) & 0x3F),
+                                    0x80 | ((x >>> 6 ) & 0x3F),
+                                    0x80 | ( x         & 0x3F));
+  }
+  return output;
+}
+
+/*
+ * Convert a raw string to an array of little-endian words
+ * Characters >255 have their high-byte silently ignored.
+ */
+function rstr2binl(input)
+{
+  var output = Array(input.length >> 2);
+  for(var i = 0; i < output.length; i++)
+    output[i] = 0;
+  for(var i = 0; i < input.length * 8; i += 8)
+    output[i>>5] |= (input.charCodeAt(i / 8) & 0xFF) << (i%32);
+  return output;
+}
+
+/*
+ * Convert an array of little-endian words to a string
+ */
+function binl2rstr(input)
+{
+  var output = "";
+  for(var i = 0; i < input.length * 32; i += 8)
+    output += String.fromCharCode((input[i>>5] >>> (i % 32)) & 0xFF);
+  return output;
+}
+
+/*
+ * Calculate the MD5 of an array of little-endian words, and a bit length.
+ */
+function binl_md5(x, len)
+{
+  /* append padding */
+  x[len >> 5] |= 0x80 << ((len) % 32);
+  x[(((len + 64) >>> 9) << 4) + 14] = len;
+
+  var a =  1732584193;
+  var b = -271733879;
+  var c = -1732584194;
+  var d =  271733878;
+
+  for(var i = 0; i < x.length; i += 16)
+  {
+    var olda = a;
+    var oldb = b;
+    var oldc = c;
+    var oldd = d;
+
+    a = md5_ff(a, b, c, d, x[i+ 0], 7 , -680876936);
+    d = md5_ff(d, a, b, c, x[i+ 1], 12, -389564586);
+    c = md5_ff(c, d, a, b, x[i+ 2], 17,  606105819);
+    b = md5_ff(b, c, d, a, x[i+ 3], 22, -1044525330);
+    a = md5_ff(a, b, c, d, x[i+ 4], 7 , -176418897);
+    d = md5_ff(d, a, b, c, x[i+ 5], 12,  1200080426);
+    c = md5_ff(c, d, a, b, x[i+ 6], 17, -1473231341);
+    b = md5_ff(b, c, d, a, x[i+ 7], 22, -45705983);
+    a = md5_ff(a, b, c, d, x[i+ 8], 7 ,  1770035416);
+    d = md5_ff(d, a, b, c, x[i+ 9], 12, -1958414417);
+    c = md5_ff(c, d, a, b, x[i+10], 17, -42063);
+    b = md5_ff(b, c, d, a, x[i+11], 22, -1990404162);
+    a = md5_ff(a, b, c, d, x[i+12], 7 ,  1804603682);
+    d = md5_ff(d, a, b, c, x[i+13], 12, -40341101);
+    c = md5_ff(c, d, a, b, x[i+14], 17, -1502002290);
+    b = md5_ff(b, c, d, a, x[i+15], 22,  1236535329);
+
+    a = md5_gg(a, b, c, d, x[i+ 1], 5 , -165796510);
+    d = md5_gg(d, a, b, c, x[i+ 6], 9 , -1069501632);
+    c = md5_gg(c, d, a, b, x[i+11], 14,  643717713);
+    b = md5_gg(b, c, d, a, x[i+ 0], 20, -373897302);
+    a = md5_gg(a, b, c, d, x[i+ 5], 5 , -701558691);
+    d = md5_gg(d, a, b, c, x[i+10], 9 ,  38016083);
+    c = md5_gg(c, d, a, b, x[i+15], 14, -660478335);
+    b = md5_gg(b, c, d, a, x[i+ 4], 20, -405537848);
+    a = md5_gg(a, b, c, d, x[i+ 9], 5 ,  568446438);
+    d = md5_gg(d, a, b, c, x[i+14], 9 , -1019803690);
+    c = md5_gg(c, d, a, b, x[i+ 3], 14, -187363961);
+    b = md5_gg(b, c, d, a, x[i+ 8], 20,  1163531501);
+    a = md5_gg(a, b, c, d, x[i+13], 5 , -1444681467);
+    d = md5_gg(d, a, b, c, x[i+ 2], 9 , -51403784);
+    c = md5_gg(c, d, a, b, x[i+ 7], 14,  1735328473);
+    b = md5_gg(b, c, d, a, x[i+12], 20, -1926607734);
+
+    a = md5_hh(a, b, c, d, x[i+ 5], 4 , -378558);
+    d = md5_hh(d, a, b, c, x[i+ 8], 11, -2022574463);
+    c = md5_hh(c, d, a, b, x[i+11], 16,  1839030562);
+    b = md5_hh(b, c, d, a, x[i+14], 23, -35309556);
+    a = md5_hh(a, b, c, d, x[i+ 1], 4 , -1530992060);
+    d = md5_hh(d, a, b, c, x[i+ 4], 11,  1272893353);
+    c = md5_hh(c, d, a, b, x[i+ 7], 16, -155497632);
+    b = md5_hh(b, c, d, a, x[i+10], 23, -1094730640);
+    a = md5_hh(a, b, c, d, x[i+13], 4 ,  681279174);
+    d = md5_hh(d, a, b, c, x[i+ 0], 11, -358537222);
+    c = md5_hh(c, d, a, b, x[i+ 3], 16, -722521979);
+    b = md5_hh(b, c, d, a, x[i+ 6], 23,  76029189);
+    a = md5_hh(a, b, c, d, x[i+ 9], 4 , -640364487);
+    d = md5_hh(d, a, b, c, x[i+12], 11, -421815835);
+    c = md5_hh(c, d, a, b, x[i+15], 16,  530742520);
+    b = md5_hh(b, c, d, a, x[i+ 2], 23, -995338651);
+
+    a = md5_ii(a, b, c, d, x[i+ 0], 6 , -198630844);
+    d = md5_ii(d, a, b, c, x[i+ 7], 10,  1126891415);
+    c = md5_ii(c, d, a, b, x[i+14], 15, -1416354905);
+    b = md5_ii(b, c, d, a, x[i+ 5], 21, -57434055);
+    a = md5_ii(a, b, c, d, x[i+12], 6 ,  1700485571);
+    d = md5_ii(d, a, b, c, x[i+ 3], 10, -1894986606);
+    c = md5_ii(c, d, a, b, x[i+10], 15, -1051523);
+    b = md5_ii(b, c, d, a, x[i+ 1], 21, -2054922799);
+    a = md5_ii(a, b, c, d, x[i+ 8], 6 ,  1873313359);
+    d = md5_ii(d, a, b, c, x[i+15], 10, -30611744);
+    c = md5_ii(c, d, a, b, x[i+ 6], 15, -1560198380);
+    b = md5_ii(b, c, d, a, x[i+13], 21,  1309151649);
+    a = md5_ii(a, b, c, d, x[i+ 4], 6 , -145523070);
+    d = md5_ii(d, a, b, c, x[i+11], 10, -1120210379);
+    c = md5_ii(c, d, a, b, x[i+ 2], 15,  718787259);
+    b = md5_ii(b, c, d, a, x[i+ 9], 21, -343485551);
+
+    a = safe_add(a, olda);
+    b = safe_add(b, oldb);
+    c = safe_add(c, oldc);
+    d = safe_add(d, oldd);
+  }
+  return Array(a, b, c, d);
+}
+
+/*
+ * These functions implement the four basic operations the algorithm uses.
+ */
+function md5_cmn(q, a, b, x, s, t)
+{
+  return safe_add(bit_rol(safe_add(safe_add(a, q), safe_add(x, t)), s),b);
+}
+function md5_ff(a, b, c, d, x, s, t)
+{
+  return md5_cmn((b & c) | ((~b) & d), a, b, x, s, t);
+}
+function md5_gg(a, b, c, d, x, s, t)
+{
+  return md5_cmn((b & d) | (c & (~d)), a, b, x, s, t);
+}
+function md5_hh(a, b, c, d, x, s, t)
+{
+  return md5_cmn(b ^ c ^ d, a, b, x, s, t);
+}
+function md5_ii(a, b, c, d, x, s, t)
+{
+  return md5_cmn(c ^ (b | (~d)), a, b, x, s, t);
+}
+
+/*
+ * Add integers, wrapping at 2^32. This uses 16-bit operations internally
+ * to work around bugs in some JS interpreters.
+ */
+function safe_add(x, y)
+{
+  var lsw = (x & 0xFFFF) + (y & 0xFFFF);
+  var msw = (x >> 16) + (y >> 16) + (lsw >> 16);
+  return (msw << 16) | (lsw & 0xFFFF);
+}
+
+/*
+ * Bitwise rotate a 32-bit number to the left.
+ */
+function bit_rol(num, cnt)
+{
+  return (num << cnt) | (num >>> (32 - cnt));
+}

tpl/player.coffee

 	_curplaying:
 		trackId: null
 		length: 1
+		start: 0
 
 	_bufstart: null
 	_draggingseekbar: false
 	initMouse: ->
 		my = this
 
+		$('#player').on 'click', '.settings', window.showSettings
+
 		$('#player').on 'click', '.play', (e) ->
 			if isNaN(my.audio.duration)
 				active = $('#playlist .active')
 		window.vol = new Slider
 			target: $('#player .volume')
 			move: (pos) ->
-				v = Math.min 1, pos * 2 / 100
-				my.audio.volume = v
-				store.set 'volume', v
+				#v = Math.min 100, pos * 2
+				my.setVol pos
 				return Math.round pos
 
 		if store.get('volume') isnt null
-			my.audio.volume = store.get 'volume'
-			vol.setpos my.audio.volume * 100
+			my.setVol store.get('volume')
 		else
-			vol.setpos 50
-			my.audio.volume = 0.5
+			my.setVol 50
 
 
 	###
 		$(@audio).bind 'ended', ->
 			$('.seekbar .buffer').css 'width', '0px'
 			my._bufstart = null
+
+			if my._curplaying.length > 30
+				[track, album, artist] = window.info.getInfo my._curplaying.trackId
+				if track
+					window.scrobble.scrobble
+						#mbid: track.mbid
+						timestamp: my._curplaying.start
+						artist: artist.name
+						album: album.name
+						track: track.name
+						trackNumber: track.trackno
+						duration: track.length
+
 			unless my.playNext()
 				$('#player').attr 'class', 'right-of-library stopped'
 				store.set 'lasttrack', null
 				$('#status span:eq(0)').html 'Stopped'
 
 		$(@audio).bind 'timeupdate', (e) ->
-			#log my.audio.currentTime
 			return if my._draggingseekbar
 			v = my.audio.currentTime / my._curplaying.length * 100
 			my.seekbar.setpos v
 		@audio.pause()
 		@audio.src = ''
 		@audio.src = "#{_root}/play-track/#{@codec}/#{trackId}"
-		@audio.play()
 
 		@_curplaying =
 			trackId: trackId
 			length: length
+			start: (new Date().getTime() / 1000).toNum() + new Date().getTimezoneOffset()
+		@setVol()
+		@audio.play()
 
 		row = $("#playlist tr[data-id=#{trackId}]")
 		$('#playlist tr').removeClass 'playing'
 		row.addClass('playing').find('td:eq(0)').html '<i class="icon-play"></i>'
 		store.set 'lasttrack', trackId
 
+		[track, album, artist] = window.info.getInfo trackId
+		if track
+			window.scrobble.nowPlaying
+				artist: artist.name
+				album: album.name
+				track: track.name
+				trackNumber: track.trackno
+				duration: track.length
+
 
 	###
 	Try and play the next track
 		else
 			return false
 
+
 	###
 	Try and play the previous track
 	###
 	playPrev: -> @playNext true
+
+
+	###
+	Set volume in percentage (0-100) & adjust for replaygain
+	If the volume is null, we'll set it to the current volume, but re-apply
+	replaygain (do this when switching tracks)
+	###
+	setVol: (v=null) ->
+		v = store.get('volume') if v is null
+		store.set 'volume', v
+
+		scale = 1
+		if @_curplaying.trackId
+			rg = false
+			apply = store.get 'replaygain'
+			if apply is 'album'
+				rg = window._cache.albums[window._cache.tracks[@_curplaying.trackId].album].rg_gain
+			else if apply is 'track'
+				rg = window._cache.tracks[@_curplaying.trackId].rg_gain
+			scale = Math.pow(10, rg / 20) if rg
+
+		@audio.volume = v * scale / 100
+		window.vol.setpos store.get('volume')
 
     Player.prototype._curplaying = {
       trackId: null,
-      length: 1
+      length: 1,
+      start: 0
     };
 
     Player.prototype._bufstart = null;
       var my;
 
       my = this;
+      $('#player').on('click', '.settings', window.showSettings);
       $('#player').on('click', '.play', function(e) {
         var active;
 
       window.vol = new Slider({
         target: $('#player .volume'),
         move: function(pos) {
-          var v;
-
-          v = Math.min(1, pos * 2 / 100);
-          my.audio.volume = v;
-          store.set('volume', v);
+          my.setVol(pos);
           return Math.round(pos);
         }
       });
       if (store.get('volume') !== null) {
-        my.audio.volume = store.get('volume');
-        return vol.setpos(my.audio.volume * 100);
+        return my.setVol(store.get('volume'));
       } else {
-        vol.setpos(50);
-        return my.audio.volume = 0.5;
+        return my.setVol(50);
       }
     };
 
         return $('#status span:eq(0)').html('Paused');
       });
       $(this.audio).bind('ended', function() {
+        var album, artist, track, _ref;
+
         $('.seekbar .buffer').css('width', '0px');
         my._bufstart = null;
+        if (my._curplaying.length > 30) {
+          _ref = window.info.getInfo(my._curplaying.trackId), track = _ref[0], album = _ref[1], artist = _ref[2];
+          if (track) {
+            window.scrobble.scrobble({
+              timestamp: my._curplaying.start,
+              artist: artist.name,
+              album: album.name,
+              track: track.name,
+              trackNumber: track.trackno,
+              duration: track.length
+            });
+          }
+        }
         if (!my.playNext()) {
           $('#player').attr('class', 'right-of-library stopped');
           store.set('lasttrack', null);
       $(this.audio).bind('timeupdate', function(e) {
         var t, v;
 
-        log(my.audio.currentTime);
         if (my._draggingseekbar) {
           return;
         }
 
 
     Player.prototype.play = function(trackId, length) {
-      var row;
+      var album, artist, row, track, _ref;
 
       if (this.codec === null) {
         return alert("Your browser doesn't seem to support either Ogg/Vorbis or MP3 playback");
       this.audio.pause();
       this.audio.src = '';
       this.audio.src = "" + _root + "/play-track/" + this.codec + "/" + trackId;
-      this.audio.play();
       this._curplaying = {
         trackId: trackId,
-        length: length
+        length: length,
+        start: (new Date().getTime() / 1000).toNum() + new Date().getTimezoneOffset()
       };
+      this.setVol();
+      this.audio.play();
       row = $("#playlist tr[data-id=" + trackId + "]");
       $('#playlist tr').removeClass('playing');
       row.addClass('playing').find('td:eq(0)').html('<i class="icon-play"></i>');
-      return store.set('lasttrack', trackId);
+      store.set('lasttrack', trackId);
+      _ref = window.info.getInfo(trackId), track = _ref[0], album = _ref[1], artist = _ref[2];
+      if (track) {
+        return window.scrobble.nowPlaying({
+          artist: artist.name,
+          album: album.name,
+          track: track.name,
+          trackNumber: track.trackno,
+          duration: track.length
+        });
+      }
     };
 
     /*
       return this.playNext(true);
     };
 
+    /*
+    	Set volume in percentage (0-100) & adjust for replaygain
+    	If the volume is null, we'll set it to the current volume, but re-apply
+    	replaygain (do this when switching tracks)
+    */
+
+
+    Player.prototype.setVol = function(v) {
+      var apply, rg, scale;
+
+      if (v == null) {
+        v = null;
+      }
+      if (v === null) {
+        v = store.get('volume');
+      }
+      store.set('volume', v);
+      scale = 1;
+      if (this._curplaying.trackId) {
+        rg = false;
+        apply = store.get('replaygain');
+        if (apply === 'album') {
+          rg = window._cache.albums[window._cache.tracks[this._curplaying.trackId].album].rg_gain;
+        } else if (apply === 'track') {
+          rg = window._cache.tracks[this._curplaying.trackId].rg_gain;
+        }
+        if (rg) {
+          scale = Math.pow(10, rg / 20);
+        }
+      }
+      this.audio.volume = v * scale / 100;
+      return window.vol.setpos(store.get('volume'));
+    };
+
     return Player;
 
   })();

tpl/playlist.coffee

 	###
 	Clear all selection
 	###
-	clearSelection: ->
-		$('#playlist tr').removeClass('selected').removeClass 'active'
+	clearSelection: (active=false) ->
+		$('#playlist tr').removeClass 'selected'
+		$('#playlist .active').removeClass 'active' if active
 
 
 	###
 	###
 	playRow: (r) ->
 		window.player.play $(r).attr('data-id'), $(r).attr('data-length')
-		@clearSelection()
+		@clearSelection true
 		@selectRow r
 
 
 				window.info.clear()
 				my.savePlaylist()
 				my.cleanCache()
+
+				# Hack to prevent a seemingly empty playlist, this should be fixed better
+				# (this is a problem with perfect scrollbar, the update function should
+				# do this
+				if $('#playlist tr:last').position().top < 15
+					$('#playlist-wrapper')[0].scrollTop = 0
+					$('#playlist-wrapper').scrollbar 'update'
 			# Up arrow
 			else if e.keyCode is 38
 				e.preventDefault()
 		my = this
 
 		sort = null
-		$('#playlist thead').on 'click', 'th', (e) ->
+		$('#playlist-thead').on 'click', '.cell', (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()
+			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]
 
 	###
 	savePlaylist: ->
 		store.set 'playlist', $$('#playlist tbody tr').map (r) -> r.outerHTML
+		$('#playlist-wrapper').scrollbar 'update'
+		@headSize()
 
 
 	###
 				type: 'post'
 				data:
 					tracks: deleted.join ','
+
+
+	###
+	###
+	headSize: ->
+		$('#playlist thead th').each (i, cell) ->
+			$("#playlist-thead .cell:eq(#{i})").css 'width', "#{$(cell).width() + 2}px"
+		#w = $('#playlist').width() - $('#playlist-thead').width()
+		w = $('#playlist-thead').width() - ($('#playlist-thead .cell:last').position().left + $('#playlist-thead .cell:last').outerWidth())
+		$('#playlist-thead > .cell:last').css 'width', "+=#{w}px"
     */
 
 
-    Playlist.prototype.clearSelection = function() {
-      return $('#playlist tr').removeClass('selected').removeClass('active');
+    Playlist.prototype.clearSelection = function(active) {
+      if (active == null) {
+        active = false;
+      }
+      $('#playlist tr').removeClass('selected');
+      if (active) {
+        return $('#playlist .active').removeClass('active');
+      }
     };
 
     /*
 
     Playlist.prototype.playRow = function(r) {
       window.player.play($(r).attr('data-id'), $(r).attr('data-length'));
-      this.clearSelection();
+      this.clearSelection(true);
       return this.selectRow(r);
     };
 
           $('#playlist .selected').remove();
           window.info.clear();
           my.savePlaylist();
-          return my.cleanCache();
+          my.cleanCache();
+          if ($('#playlist tr:last').position().top < 15) {
+            $('#playlist-wrapper')[0].scrollTop = 0;
+            return $('#playlist-wrapper').scrollbar('update');
+          }
         } else if (e.keyCode === 38) {
           e.preventDefault();
           r = $('#playlist .active');
 
       my = this;
       sort = null;
-      return $('#playlist thead').on('click', 'th', function(e) {
+      return $('#playlist-thead').on('click', '.cell', function(e) {
         var body, dir, h, int, n, pn, psort, rows, sortFun;
 
         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();
+        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();
         }
         if (psort && h[0] === psort[0]) {
           psort = null;
 
 
     Playlist.prototype.savePlaylist = function() {
-      return store.set('playlist', $$('#playlist tbody tr').map(function(r) {
+      store.set('playlist', $$('#playlist tbody tr').map(function(r) {
         return r.outerHTML;
       }));
+      $('#playlist-wrapper').scrollbar('update');
+      return this.headSize();
     };
 
     /*
       }
     };
 
+    /*
+    */
+
+
+    Playlist.prototype.headSize = function() {
+      var w;
+
+      $('#playlist thead th').each(function(i, cell) {
+        return $("#playlist-thead .cell:eq(" + i + ")").css('width', "" + ($(cell).width() + 2) + "px");
+      });
+      w = $('#playlist-thead').width() - ($('#playlist-thead .cell:last').position().left + $('#playlist-thead .cell:last').outerWidth());
+      return $('#playlist-thead > .cell:last').css('width', "+=" + w + "px");
+    };
+
     return Playlist;
 
   })();

tpl/scrobble.coffee

+class Scrobble
+	###
+	The secret key is supposed to be, well, secret ... Which is somewhat
+	impossible in an open-source application.
+
+	Please don't misuse this key :-)
+
+	Also, if you make modifications to Nordavind, then please register your own
+	key. Many thanks :-)
+	###
+	secret: '0ce163b13c9d0ae05fd152a9a5b92a45'
+	key: '2741bbf6e0178180846e814f042cfbcd'
+	root: 'http://ws.audioscrobbler.com/2.0'
+
+	enabled: false
+
+	constructor: ->
+		@enabled = true if window.localStorage.getItem 'lastfm'
+
+
+	###
+	###
+	startSession: ->
+		my = this
+
+		window.open "http://www.last.fm/api/auth/?api_key=#{@key}" +
+			"&cb=#{window.location.href.replace(/\/$/, '')}/lastfm-callback"
+
+		(->
+			token = localStorage.getItem 'token'
+			return if token is null
+			localStorage.removeItem 'token'
+			this.clearInterval()
+
+			my._req
+				method: 'auth.getSession'
+				token: token
+			, (data) ->
+				window.store.set 'lastfm', data.session
+				my.enabled = true
+		).interval 500
+
+
+	###
+	###
+	nowPlaying: (info) ->
+		return unless @enabled
+
+		info['method'] = 'track.updateNowPlaying'
+		@_req info, null, 'post'
+
+
+	###
+	A track should only be scrobbled when the following conditions have been
+	met:
+	- The track must be longer than 30 seconds.
+	- And the track has been played for at least half its duration, or for 4
+	  minutes (whichever occurs earlier.)
+	###
+	scrobble: (info) ->
+		return unless @enabled
+
+		info['method'] = 'track.scrobble'
+		@_req info, null, 'post'
+
+
+	###
+	Make a request to the API
+	###
+	_req: (paramsobj, cb=null, type='get') ->
+		paramsobj['api_key'] = @key
+		paramsobj['format'] = 'json'
+
+		session = window.store.get 'lastfm'
+		paramsobj['sk'] = session.key if session?
+
+		params = ([k, v] for own k, v of paramsobj)
+		params.sort (a, b) -> a[0].localeCompare b[0]
+		sig = md5 ("#{k}#{v}" for [k, v] in params when k not in ['format', 'callback']).join('') + @secret
+		urlparams = ("#{encodeURIComponent k}=#{encodeURIComponent v}" for [k, v] in params).join '&'
+
+		#session = window.store.get 'lastfm'
+		#urlparams += "&sk=#{session.key}" if session?
+
+		jQuery.ajax
+			url: "#{@root}?#{urlparams}&api_sig=#{sig}"
+			type: 'post'
+			dataType: 'json'
+			success: (data) ->
+				if data.error
+					alert "LastFM error #{data.error}: #{data.message}"
+					return
+				cb.call null, data if cb
+
+
+window.scrobble = new Scrobble
+// Generated by CoffeeScript 1.6.2
+(function() {
+  var Scrobble,
+    __hasProp = {}.hasOwnProperty;
+
+  Scrobble = (function() {
+    /*
+    	The secret key is supposed to be, well, secret ... Which is somewhat
+    	impossible in an open-source application.
+    
+    	Please don't misuse this key :-)
+    
+    	Also, if you make modifications to Nordavind, then please register your own
+    	key. Many thanks :-)
+    */
+    Scrobble.prototype.secret = '0ce163b13c9d0ae05fd152a9a5b92a45';
+
+    Scrobble.prototype.key = '2741bbf6e0178180846e814f042cfbcd';
+
+    Scrobble.prototype.root = 'http://ws.audioscrobbler.com/2.0';
+
+    Scrobble.prototype.enabled = false;
+
+    function Scrobble() {
+      if (window.localStorage.getItem('lastfm')) {
+        this.enabled = true;
+      }
+    }
+
+    /*
+    */
+
+
+    Scrobble.prototype.startSession = function() {
+      var my;
+
+      my = this;
+      window.open(("http://www.last.fm/api/auth/?api_key=" + this.key) + ("&cb=" + (window.location.href.replace(/\/$/, '')) + "/lastfm-callback"));
+      return (function() {
+        var token;
+
+        token = localStorage.getItem('token');
+        if (token === null) {
+          return;
+        }
+        localStorage.removeItem('token');
+        this.clearInterval();
+        return my._req({
+          method: 'auth.getSession',
+          token: token
+        }, function(data) {
+          window.store.set('lastfm', data.session);
+          return my.enabled = true;
+        });
+      }).interval(500);
+    };
+
+    /*
+    */
+
+
+    Scrobble.prototype.nowPlaying = function(info) {
+      if (!this.enabled) {
+        return;
+      }
+      info['method'] = 'track.updateNowPlaying';
+      return this._req(info, null, 'post');
+    };
+
+    /*
+    	A track should only be scrobbled when the following conditions have been
+    	met:
+    	- The track must be longer than 30 seconds.
+    	- And the track has been played for at least half its duration, or for 4
+    	  minutes (whichever occurs earlier.)
+    */
+
+
+    Scrobble.prototype.scrobble = function(info) {
+      if (!this.enabled) {
+        return;
+      }
+      info['method'] = 'track.scrobble';
+      return this._req(info, null, 'post');
+    };
+
+    /*
+    	Make a request to the API
+    */
+
+
+    Scrobble.prototype._req = function(paramsobj, cb, type) {
+      var k, params, session, sig, urlparams, v;
+
+      if (cb == null) {
+        cb = null;
+      }
+      if (type == null) {
+        type = 'get';
+      }
+      paramsobj['api_key'] = this.key;
+      paramsobj['format'] = 'json';
+      session = window.store.get('lastfm');
+      if (session != null) {
+        paramsobj['sk'] = session.key;
+      }
+      params = (function() {
+        var _results;
+
+        _results = [];
+        for (k in paramsobj) {
+          if (!__hasProp.call(paramsobj, k)) continue;
+          v = paramsobj[k];
+          _results.push([k, v]);
+        }
+        return _results;
+      })();
+      params.sort(function(a, b) {
+        return a[0].localeCompare(b[0]);
+      });
+      sig = md5(((function() {
+        var _i, _len, _ref, _results;
+
+        _results = [];
+        for (_i = 0, _len = params.length; _i < _len; _i++) {
+          _ref = params[_i], k = _ref[0], v = _ref[1];
+          if (k !== 'format' && k !== 'callback') {
+            _results.push("" + k + v);
+          }
+        }
+        return _results;
+      })()).join('') + this.secret);
+      urlparams = ((function() {
+        var _i, _len, _ref, _results;