Commits

Martin Tournoij committed 45168e2

Update

Comments (0)

Files changed (20)

 
 Browser support
 ===============
-Nordavind should work in all current browser versions. Examples of olders
-browsers that will *not* work are Internet Explorer 8 and Safari 5.
+Nordavind works best in Firefox, other current browsers (Opera, Chrome, IE10)
+also work, but all experience minor issues. At the moment adding features,
+tweaking the interface, and fixing real bugs is a higher priority than dealing
+with various browser quirks (Firefox just happens to be the only browser that
+works without fizzle).
 
-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 due to
-a number of problems. Some works needs to be done here.
+Browsers that will *never-ever* work are Internet Explorer 8 and Safari 5.
 
 
 Audio codecs
 ------------
-Nordavid uses the HTML5 `<audio>` capability, while all current browsers support
+Nordavid uses the HTML5 `<audio>` element, while all current browsers support
 this quite well, there are some difference, notably, in the supported codecs.
 
 - Firefox and Opera will play Ogg Vorbis files
 
 Configuration
 -------------
-TODO
+You almost certainly want to edit `config.cfg` and edit at least the `password`
+and `musicpath` options.
 
 
 Running
 Run `serve.py` to start the server. You can optionally add an `address:port`
 to listen on (defaults to `0.0.0.0:8001`).
 
-
-Security
---------
-TODO
+Note that Nordavind only supports a single user; you can’t use the same
+installation with multiple users.
 
 
 Adding your music collection
+- sqlite errors
+
+
 A TODO list :-)
 
-
-For 1.0:
-- Fix seekbar while buffering
-- 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 errors
+- Gapless playback
+- Control config.cfg from browser
+- Add various options to config.cfg
+- ReplayGain
+- last.fm
+- Select multiple rows in playlist with the mouse
+- Test (& fix) various browsers
+- Actually deal with various errors that may occur
 - More flexible library view (ie. grouping by arbitrary tags)
 - Specify columns in playlist
 - Resize columns in playlist
+# Nordavind configuration
+# 
+# All path can be relative to the Nordavind directory (doesn't start with a /),
+# or may be an absolute path (starts with a /)
+#
+
+# Location of your music files
+musicpath = /data/music
+
+# Location of database to use
+dbpath = db/db.sqlite3
+
+# Location of cache files; if it's an empty string or not given, we won't use a
+# cache
+cachepath = /tmp/nordavind/
+
+# The username & password to access Nordavind
+user = martin
+password = nordavind
+
+# Secret key only known on the server (used in authentication)
+authkey = r0t9hwguivnewefdw

config.ini

-# Nordavind configuration
-# 
-# All path can be relative to the Nordavind directory (doesn't start with a /),
-# or may be an absolute path (starts with a /)
-#
-
-# Location of your music files
-musicpath = /data/music
-
-# Location of database to use
-dbpath = db/db.sqlite3
-
-# Location of cache files; if it's an empty string or not given, we won't use a
-# cache
-cachepath = /tmp/cache/

nordavind/__init__.py

 # See below for full copyright
 #
 
-import os, re, sys, urllib.parse, sqlite3, shlex, subprocess, base64
+import os, re, sys, urllib.parse, sqlite3, shlex, subprocess, base64, glob
 
 from jinja2 import Environment, FileSystemLoader
 import taglib
 _root = os.path.dirname(os.path.realpath(sys.argv[0]))
 _wwwroot = ''
 _db = None
+_procs = {}
 config = None
 
 
 
 	return r
 
-
 def playTrack(codec, id):
+	global _procs
 	c = _db.cursor()
 	track = c.execute('select * from tracks where id=?', (id,)).fetchone()
 
 	cache = None
-	if config['cachepath'] not in [None, False, '']:
-		cache = '%s/%s.%s' % (config['cachepath'], re.sub(r'[^\w]', '', track['path']), codec)
+	if config.get('cachepath') not in [None, False, '']:
+		cache = '%s/%s_%s.%s' % (config['cachepath'], id, re.sub(r'[^\w]', '', track['path']), codec)
 
 	if cache and os.path.exists(cache):
 		fp = open(cache, 'rb')
 			elif t == 'mp3':
 				cmd = 'mpg123 -qw- %s| oggenc - -q8 -Qo -' % shlex.quote(path)
 			elif t == 'ogg':
-				cmd = 'cat'
+				cmd = 'cat %s' % shlex.quote(path)
 				cache = None
 		elif codec == 'mp3':
 			if t == 'flac':
 			elif t == 'ogg':
 				cmd = 'oggdec -Qo- %s | lame --quiet -V2 - -' % shlex.quote(path)
 			elif t == 'mp3':
-				cmd = 'cat'
+				cmd = 'cat %s' % shlex.quote(path)
 				cache = None
 
-		p = subprocess.Popen(cmd, shell=True, stdout=subprocess.PIPE)
+		_procs[id] = subprocess.Popen(cmd, shell=True, stdout=subprocess.PIPE)
 
 		if cache is not None:
 			cachefp = open(cache + '_temp', 'wb')
 
 		while True:
-			buf = p.stdout.read(1024)
+			buf = _procs[id].stdout.read(1024)
 			if not buf: break
 			if cache is not None: cachefp.write(buf)
 			yield buf
 
-		cachefp.close()
-		os.rename(cache + '_temp', cache)
+		del _procs[id]
+		if cache is not None:
+			cachefp.close()
+			os.rename(cache + '_temp', cache)
 
 
-def playTrack_clean():
-	pass
+def playTrack_clean(id):
+	global _procs
+
+	if _procs.get(id):
+		cleanCache(id)
+		_procs.get(id).kill()
+		del _procs[id]
 
 
 def getAlbum(id):
 		(id,)).fetchone()['album'])
 
 
+def cleanCache(trackid):
+	if config.get('cachepath') in [None, False, '']:
+		return
+
+	cache = glob.glob('%s/%s_*' % (config['cachepath'], trackid))
+	for c in cache: os.unlink(c)
+
+
 def start():
 	openDb()
 	if len(_db.cursor().execute('select * from sqlite_master where type="table"').fetchall()) == 0:
 		createDb()
 
-	if not os.path.exists(config['cachepath']):
+	if config.get('cachepath') not in [None, False, ''] and not os.path.exists(config['cachepath']):
 		os.makedirs(config['cachepath'])
 
+config = {}
+for line in open('config.cfg').readlines():
+	line = line.strip()
+	if line == '' or line[0] == '#': continue
 
-# TODO: Get from file
-config = {
-	'musicpath': '/data/music/',
-	'dbpath': '%s/db/db.sqlite3' % _root,
-	'cachepath': '/tmp/nordavind/',
-}
+	k, v = line.split('=')
+	k = k.strip()
+	v = v.strip()
+
+	if v == '': continue
+
+	if k == 'dbpath':
+		v = '%s/%s' % (_root, v)
+
+	config[k] = v
+	
 
 
 
 # See below for full copyright
 #
 
-import sys, json, os
+import sys, json, os, datetime
 
 import cherrypy
 
 		return obj.strftime('%Y-%m-%d %H:%M')
 
 
+def dbcache():
+	mtime = datetime.datetime.fromtimestamp(int(os.stat(nordavind.config['dbpath']).st_mtime))
+	fmt = '%a, %d %b %Y %H:%M:%S GMT'
+	cherrypy.response.headers['Last-Modified'] = mtime.strftime(fmt)
+	cherrypy.response.headers['Cache-Control'] = 'max-age=%s, must-revalidate' % (86400 * 365,)
+	cherrypy.response.headers['Expires'] = (mtime + datetime.timedelta(days=7)).strftime(fmt)
+
+
 class AgentCooper:
 	@cherrypy.expose
 	def index():
 		nordavind.start()
+		dbcache()
+
 		return nordavind.template('main.html', {
+			'version': '1.0',
 			'library': nordavind.getLibrary(),
 		})
 
 	@cherrypy.expose
 	def get_album(albumid):
 		nordavind.start()
+		dbcache()
 		return json.dumps(nordavind.getAlbum(albumid),
 			default=JSONDefault)
 
 	@cherrypy.expose
 	def get_album_by_track(trackid):
 		nordavind.start()
+		dbcache()
 		return json.dumps(nordavind.getAlbumByTrack(trackid),
 			default=JSONDefault)
 
 		nordavind.start()
 
 		cherrypy.response.headers['Content-Type'] = 'audio/%s' % codec
+		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}
 
 
+	@cherrypy.expose
+	def clean_cache(tracks=None):
+		for t in tracks.split(','):
+			nordavind.cleanCache(t)
+
+
+	@cherrypy.expose
+	def tpl(*args, **kwargs):
+		path = '%s/tpl/%s' % (nordavind._root, '/'.join(args))
+
+		if not os.path.exists(path):
+			raise cherrypy.NotFound()
+
+		mtime = datetime.datetime.fromtimestamp(int(os.stat('config.cfg').st_mtime))
+		fmt = '%a, %d %b %Y %H:%M:%S GMT'
+
+		cherrypy.response.headers['Last-Modified'] = mtime.strftime(fmt)
+		cherrypy.response.headers['Cache-Control'] = 'max-age=%s, must-revalidate' % (86400 * 365,)
+		cherrypy.response.headers['Expires'] = (mtime + datetime.timedelta(days=7)).strftime(fmt)
+
+		return cherrypy.lib.static.serve_file(path)
+
+
 server = '0.0.0.0'
 port = 8001
 
 	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': 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,
+		'tools.gzip.on': True,
+		'tools.gzip.mime_types': ['text/css', 'text/html', 'text/plain', 'application/json', 'application/javascript'],
+
+		'tools.auth_digest.on': True,
+		'tools.auth_digest.realm': 'Nordavind',
+		'tools.auth_digest.key': nordavind.config['authkey'],
+		'tools.auth_digest.get_ha1': cherrypy.lib.auth_digest.get_ha1_dict_plain(
+			{ nordavind.config['user']: nordavind.config['password'] }
+		)
 	}
 })
 
 
+
 # The MIT License (MIT)
 #
 # Copyright © 2013 Martin Tournoij
 		$('#info .table-wrapper').scrollbar
 			wheelSpeed: 150
 
+		$('#info').on 'click', 'img', (e) ->
+			img = $(this)
+			return if img.attr('src') is ''
+
+			$('body').append """<div id='large-cover'>
+				<img src='#{img.attr 'src'}' alt=''>
+			</div>"""
+
+			$('#large-cover').one 'click', (e) ->
+				$(this).remove()
+
+	###
+	###
+	clear: ->
+		$('#info img').attr 'src', ''
+		$('#info tbody').html ''
+
 
 	###
 	Set info to trackId
 					my._set t if t.id is trackId.toNum()
 
 
+	###
+	###
 	_set: (track) ->
 		album = window._cache['albums'][track.album]
 		artist = window._cache['artists'][album.artist]
       $('#info .table-wrapper').scrollbar({
         wheelSpeed: 150
       });
+      $('#info').on('click', 'img', function(e) {
+        var img;
+
+        img = $(this);
+        if (img.attr('src') === '') {
+          return;
+        }
+        $('body').append("<div id='large-cover'>\n	<img src='" + (img.attr('src')) + "' alt=''>\n</div>");
+        return $('#large-cover').one('click', function(e) {
+          return $(this).remove();
+        });
+      });
     }
 
     /*
+    */
+
+
+    Info.prototype.clear = function() {
+      $('#info img').attr('src', '');
+      return $('#info tbody').html('');
+    };
+
+    /*
     	Set info to trackId
     */
 
       });
     };
 
+    /*
+    */
+
+
     Info.prototype._set = function(track) {
       var album, artist;
 
 		dragging = true
 		$(handle).css 'z-index', '99'
 		document.body.focus()
+		e.preventDefault()
 		start?.apply this, [e]
 
-		return false
-
 	mouseup = (e) ->
 		handle.removeClass 'dragging'
 		dragging = false
 
 		opt.target = $(opt.target)
 		opt.target.addClass 'slider'
-		opt.target.append '<span class="slider-bar"></span>'
+		h = opt.target.html()
+		opt.target.html ''
+		opt.target.append "<span class='slider-bar'>#{h}</span>"
 		opt.target.append '<span class="slider-handle"></span>'
 
 		@bar = opt.target.find '.slider-bar'
 
 		start = -> my.opt.start() if my.opt.start
 
-		@bar.bind 'click', setpos
+		@bar.bind 'click', (e) ->
+			start()
+			setpos e
+			stop()
 		babyUrADrag @handle, start, setpos, stop
 
 
 	setpos: (p) ->
 		@handle.css 'left', "#{(@bar.width() - @handle.width()) / 100 * p}px"
 
+
+###
+###
+window.selectBox = (target) ->
+	return
+	target = $(target)
+
+	dragging = false
+	box = null
+	startX = 0
+	startY = 0
+	target.on 'mousedown.selectbox', (e) ->
+		dragging = true
+		$('body').append '<div id="selectbox"></div>'
+		box = $('#selectbox')
+
+		startX = e.pageX
+		startY = e.pageY
+
+		box.css
+			left: "#{startX}px"
+			top: "#{startY}px"
+		document.body.focus()
+		e.preventDefault()
+
+	$('body').on 'mouseup.selectbox', (e) ->
+		dragging = false
+		box.remove()
+		box = null
+
+	$('body').on 'mousemove.selectbox', (e) ->
+		return unless dragging
+
+		row = $(e.target).closest 'tr'
+		window.playlist.selectRow row if row.length > 0
+
+		w = $(window).width()
+		h = $(window).height()
+
+		if e.pageX > startX
+			l = startX
+			r = w - e.pageX
+		else
+			l = e.pageX
+			r = w - startX
+
+		if e.pageY > startY
+			t = startY
+			b = h - e.pageY
+		else
+			t = e.pageY
+			b = h - startY
+
+		box.css
+			left: l + 'px'
+			right: r + 'px'
+			top: t + 'px'
+			bottom: b + 'px'
       dragging = true;
       $(handle).css('z-index', '99');
       document.body.focus();
-      if (start != null) {
-        start.apply(this, [e]);
-      }
-      return false;
+      e.preventDefault();
+      return start != null ? start.apply(this, [e]) : void 0;
     };
     mouseup = function(e) {
       handle.removeClass('dragging');
 
   window.Slider = Slider = (function() {
     function Slider(opt) {
-      var my, setpos, start, stop, tooltip;
+      var h, 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>');
+      h = opt.target.html();
+      opt.target.html('');
+      opt.target.append("<span class='slider-bar'>" + h + "</span>");
       opt.target.append('<span class="slider-handle"></span>');
       this.bar = opt.target.find('.slider-bar');
       this.handle = opt.target.find('.slider-handle');
           return my.opt.start();
         }
       };
-      this.bar.bind('click', setpos);
+      this.bar.bind('click', function(e) {
+        start();
+        setpos(e);
+        return stop();
+      });
       babyUrADrag(this.handle, start, setpos, stop);
     }
 
 
   })();
 
+  /*
+  */
+
+
+  window.selectBox = function(target) {
+    var box, dragging, startX, startY;
+
+    return;
+    target = $(target);
+    dragging = false;
+    box = null;
+    startX = 0;
+    startY = 0;
+    target.on('mousedown.selectbox', function(e) {
+      dragging = true;
+      $('body').append('<div id="selectbox"></div>');
+      box = $('#selectbox');
+      startX = e.pageX;
+      startY = e.pageY;
+      box.css({
+        left: "" + startX + "px",
+        top: "" + startY + "px"
+      });
+      document.body.focus();
+      return e.preventDefault();
+    });
+    $('body').on('mouseup.selectbox', function(e) {
+      dragging = false;
+      box.remove();
+      return box = null;
+    });
+    return $('body').on('mousemove.selectbox', function(e) {
+      var b, h, l, r, row, t, w;
+
+      if (!dragging) {
+        return;
+      }
+      row = $(e.target).closest('tr');
+      if (row.length > 0) {
+        window.playlist.selectRow(row);
+      }
+      w = $(window).width();
+      h = $(window).height();
+      if (e.pageX > startX) {
+        l = startX;
+        r = w - e.pageX;
+      } else {
+        l = e.pageX;
+        r = w - startX;
+      }
+      if (e.pageY > startY) {
+        t = startY;
+        b = h - e.pageY;
+      } else {
+        t = e.pageY;
+        b = h - startY;
+      }
+      return box.css({
+        left: l + 'px',
+        right: r + 'px',
+        top: t + 'px',
+        bottom: b + 'px'
+      });
+    });
+  };
+
 }).call(this);

tpl/library.coffee

 		row = row.closest 'li'
 		return unless row.is '.artist'
 
+		if row.find('i').attr('class') is 'icon-expand-alt'
+			row.find('i').attr 'class', 'icon-collapse-alt'
+			hide = false
+		else
+			row.find('i').attr 'class', 'icon-expand-alt'
+			hide = true
+
 		n = row.next()
 		while true
 			break unless n.hasClass 'album'
 
-			if n.css('display') is 'block'
+			if hide
 				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'
+				if $('#search input').val() is '' or n.attr('data-match') is 'true'
+					n.css 'display', 'block'
 			n = n.next()
 
 		@updateScrollbar
 				if row.text().toLowerCase().match term
 					if row.is('.artist')
 						row.show()
+						n = row.next()
+						while true
+							break unless n.hasClass 'album'
+							n.attr 'data-match', 'true'
+							n = n.next()
 					else
+						row.attr 'data-match', 'true'
 						row.findPrev('.artist').show()
 			@updateScrollbar
 
 
 
     Library.prototype.toggleArtist = function(row) {
-      var n;
+      var hide, n;
 
       row = row.closest('li');
       if (!row.is('.artist')) {
         return;
       }
+      if (row.find('i').attr('class') === 'icon-expand-alt') {
+        row.find('i').attr('class', 'icon-collapse-alt');
+        hide = false;
+      } else {
+        row.find('i').attr('class', 'icon-expand-alt');
+        hide = true;
+      }
       n = row.next();
       while (true) {
         if (!n.hasClass('album')) {
           break;
         }
-        if (n.css('display') === 'block') {
+        if (hide) {
           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');
+          if ($('#search input').val() === '' || n.attr('data-match') === 'true') {
+            n.css('display', 'block');
+          }
         }
         n = n.next();
       }
         $('#library li').hide();
         $('#library ol')[0].scrollTop = 0;
         $$('#library li').forEach(function(row) {
+          var n, _results;
+
           row = $(row);
           if (row.text().toLowerCase().match(term)) {
             if (row.is('.artist')) {
-              return row.show();
+              row.show();
+              n = row.next();
+              _results = [];
+              while (true) {
+                if (!n.hasClass('album')) {
+                  break;
+                }
+                n.attr('data-match', 'true');
+                _results.push(n = n.next());
+              }
+              return _results;
             } else {
+              row.attr('data-match', 'true');
               return row.findPrev('.artist').show();
             }
           }
 
 
 $(document).ready ->
-	#detectSupport()
-
 	# Reset inputs on page refresh
 	$('input').val ''
 
 	window.player = new Player()
 	window.info = new Info()
 
+	#detectSupport()
 	initPanes()
 
 	setSize()
 #player .seekbar .buffer {
   position: absolute;
   left: 0;
-  top: 9px;
+  top: 4px;
   height: 2px;
   background-color: #ccc;
   z-index: 1;
   height: 100%;
   float: right;
   margin-top: 4px;
+  z-index: 10;
 }
 #info img[src=""] {
   display: none;
   background-color: #8592b5;
   color: #fff;
 }
+#playlist-wrapper .selected:nth-child(odd) td {
+  background-color: #95a0bf;
+}
 #playlist-wrapper.pane-active .selected td {
   background-color: #0a246a;
 }
+#playlist-wrapper.pane-active .selected:nth-child(odd) td {
+  background-color: #0c2c81;
+}
 tr.active {
   outline: 1px solid #333;
 }
 }
 /*** Status ***/
 #status span:not(:empty) {
-  border-right: 1px solid #333;
+  border-left: 1px solid #333;
   padding: 0 8px;
 }
 #status a {
 #status span:first-of-type {
   margin-left: 0;
   padding-left: 0;
-}
-#status span:last-of-type {
   border: none;
 }
 #tooltip {
   border-radius: 2px;
   background-color: #aaa;
 }
+#selectbox {
+  border: 1px solid #00f;
+  background-color: #44f;
+  border-radius: 2px;
+  opacity: 0.25;
+  position: absolute;
+}
+#large-cover {
+  position: absolute;
+  left: 0px;
+  right: 0px;
+  top: 0px;
+  bottom: 20px;
+  z-index: 100;
+}
+#large-cover img {
+  max-width: 100%;
+  max-height: 100%;
+  position: absolute;
+  right: 0px;
+  bottom: 0px;
+  box-shadow: -2px -2px 2px rgba(0, 0, 0, 0.05);
+  border-top-left-radius: 1px;
+}
 		<link rel="shortcut icon" href="/tpl/favicon.ico">
 		<link rel="apple-touch-icon" href="/tpl/logo.png" />
 
-		<link rel="stylesheet" href="/tpl/bootstrap/bootstrap.css?1">
-		<link rel="stylesheet" href="/tpl/main.css?1">
+		<link rel="stylesheet" href="/tpl/bootstrap/bootstrap.css?v={{ version }}">
+		<link rel="stylesheet" href="/tpl/main.css?v={{ version }}">
 
 		<title>Nordavind</title>
 	</head>
 			<span>Stopped</span>
 			<span></span>
 			<span></span>
-			<a href="http://code.arp242.net/nordavind">Nordavind 1.0</a>
+			<a href="http://code.arp242.net/nordavind">Nordavind {{ version }}</a>
 		</div>
 
-		<script src="/tpl/jquery.js?1"></script>
-		<script src="/tpl/jquery.mousewheel.js?1"></script>
-		<script src="/tpl/scrollbar.js?1"></script>
+		<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/lib.js?1"></script>
-		<script src="/tpl/library.js?1"></script>
-		<script src="/tpl/playlist.js?1"></script>
-		<script src="/tpl/player.js?1"></script>
-		<script src="/tpl/info.js?1"></script>
+		<script src="/tpl/lib.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/main.js?1"></script>
+		<script src="/tpl/main.js?v={{ version }}"></script>
 	</body>
 </html>
 		.buffer {
 			position: absolute;
 			left: 0;
-			top: 9px;
+			top: 4px;
 			height: 2px;
 			background-color: #ccc;
 			z-index: 1;
 		height: 100%;
 		float: right;
 		margin-top: 4px;
+		z-index: 10;
 	}
 
 	img[src=""] {
 	background-color: #8592b5;
 	color: #fff;
 }
-#playlist-wrapper.pane-active {
+#playlist-wrapper .selected:nth-child(odd) td {
+	background-color: lighten(#8592b5, 5%);
+}
 
-	.selected td {
-		background-color: #0a246a;
-	}
+#playlist-wrapper.pane-active .selected td {
+	background-color: #0a246a;
+}
+#playlist-wrapper.pane-active .selected:nth-child(odd) td {
+	background-color: lighten(#0a246a, 5%);
+}
 
-}
 
 tr.active {
 	outline: 1px solid #333;
 .slider {
 	display: inline-block;
 	position: relative;
-	//min-width: 100px;
-
 	margin-right: 4px;
 
 	.slider-bar {
 /*** Status ***/
 #status {
 	span:not(:empty) {
-		border-right: 1px solid #333;
+		//border-right: 1px solid #333;
+		border-left: 1px solid #333;
 		padding: 0 8px;
 	}
 
 	span:first-of-type {
 		margin-left: 0;
 		padding-left: 0;
-	}
-	
-	span:last-of-type {
 		border: none;
 	}
 }
 	.border-radius(2px);
 	background-color: #aaa;
 }
+
+#selectbox {
+	border: 1px solid #00f;
+	background-color: #44f;
+	border-radius: 2px;
+	opacity: 0.25;
+	position: absolute;
+}
+
+#large-cover {
+	position: absolute;
+	left: 0px;
+	right: 0px;
+	top: 0px;
+	bottom: @statusHeight;
+	z-index: 100;
+
+	img {
+		max-width: 100%;
+		max-height: 100%;
+		position: absolute;
+		right: 0px;
+		bottom: 0px;
+		box-shadow: -2px -2px 2px rgba(0, 0, 0, .05);
+		border-top-left-radius: 1px;
+	}
+}

tpl/player.coffee

 			$('#player').attr 'class', 'right-of-library stopped'
 			store.set 'lasttrack', null
 			my._bufstart = null
+			$('#status span').html ''
 			$('#status span:eq(0)').html 'Stopped'
 
 
+
 	###
 	###
 	initVolume: ->
 			target: $('#player .seekbar')
 			start: -> my._draggingseekbar = true
 			move: (pos) ->
-				v = my.audio.seekable.end(0) / 100 * pos
+				v = my._curplaying.length / 100 * pos
 				my.audio.currentTime = v
 				return displaytime v
 			stop: -> my._draggingseekbar = false
 			unless my.playNext()
 				$('#player').attr 'class', 'right-of-library stopped'
 				store.set 'lasttrack', null
+				$('#status span').html ''
 				$('#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
 				dur = (new Date().getTime() / 1000 - my.bufstart)
 				r = (dur / c) * (100 - c)
 
-				$('#status span:eq(2)').html "Buffer #{c}% (~#{Math.round r}s remaining)"
+				$('#status span:eq(2)').html "Buffer #{c}% (~#{displaytime Math.round(r)}s remaining)"
 
 		$(@audio).bind 'progress', (e) ->
 			try
 	Play audio file `trackId` of `length` seconds
 	###
 	play: (trackId, length) ->
-		return if @codec is null
+		if @codec is null
+			return alert "Your browser doesn't seem to support either Ogg/Vorbis or MP3 playback"
 
+		@bufstart = null
 		@audio.pause()
 		@audio.src = ''
 		@audio.src = "#{_root}/play-track/#{@codec}/#{trackId}"
 		@audio.play()
 
+		@_curplaying =
+			trackId: trackId
+			length: length
+
 		row = $("#playlist tr[data-id=#{trackId}]")
 		$('#playlist tr').removeClass 'playing'
 		row.addClass('playing').find('td:eq(0)').html '<i class="icon-play"></i>'
         $('#player').attr('class', 'right-of-library stopped');
         store.set('lasttrack', null);
         my._bufstart = null;
+        $('#status span').html('');
         return $('#status span:eq(0)').html('Stopped');
       });
     };
         move: function(pos) {
           var v;
 
-          v = my.audio.seekable.end(0) / 100 * pos;
+          v = my._curplaying.length / 100 * pos;
           my.audio.currentTime = v;
           return displaytime(v);
         },
         if (!my.playNext()) {
           $('#player').attr('class', 'right-of-library stopped');
           store.set('lasttrack', null);
+          $('#status span').html('');
           return $('#status span:eq(0)').html('Stopped');
         }
       });
       $(this.audio).bind('timeupdate', function(e) {
         var t, v;
 
+        log(my.audio.currentTime);
         if (my._draggingseekbar) {
           return;
         }
           }
           dur = new Date().getTime() / 1000 - my.bufstart;
           r = (dur / c) * (100 - c);
-          return $('#status span:eq(2)').html("Buffer " + c + "% (~" + (Math.round(r)) + "s remaining)");
+          return $('#status span:eq(2)').html("Buffer " + c + "% (~" + (displaytime(Math.round(r))) + "s remaining)");
         }
       });
       return $(this.audio).bind('progress', function(e) {
       var row;
 
       if (this.codec === null) {
-        return;
+        return alert("Your browser doesn't seem to support either Ogg/Vorbis or MP3 playback");
       }
+      this.bufstart = null;
       this.audio.pause();
       this.audio.src = '';
       this.audio.src = "" + _root + "/play-track/" + this.codec + "/" + trackId;
       this.audio.play();
+      this._curplaying = {
+        trackId: trackId,
+        length: length
+      };
       row = $("#playlist tr[data-id=" + trackId + "]");
       $('#playlist tr').removeClass('playing');
       row.addClass('playing').find('td:eq(0)').html('<i class="icon-play"></i>');

tpl/playlist.coffee

 		@initKeyboard()
 		@initSort()
 
+		selectBox $('#playlist-wrapper')
+
+
 	###
 	Set a row as active
 	###
 	###
 	Play this table row
 	###
-	playRow: (r) -> window.player.play $(r).attr('data-id'), $(r).attr('data-length')
+	playRow: (r) ->
+		window.player.play $(r).attr('data-id'), $(r).attr('data-length')
+		@clearSelection()
+		@selectRow r
 
 
 	###
 				e.preventDefault()
 				albums = []
 				$('#playlist .selected').remove()
+				window.info.clear()
 				my.savePlaylist()
 				my.cleanCache()
 			# Up arrow
 	###
 	###
 	cleanCache: ->
-		return
 		tracks = []
 		albums = []
 		artists = []
 			albumid = window._cache.tracks[trackid]?.album
 			artistid = window._cache.albums[albumid]?.artist
 
-			tracks.push trackid
-			albums.push albumid
-			artists.push artistid
+			tracks.push trackid unless trackid in tracks
+			albums.push albumid unless albumid in albums
+			artists.push artistid unless artistid in artists
 
-		# TODO: Finish me
+		deleted = []
+		for k, t of window._cache.tracks
+			unless t.id in tracks
+				deleted.push t.id
+				delete window._cache.tracks[k]
+
+		for k, t of window._cache.albums
+			delete window._cache.albums[k] unless t.id in albums
+
+		for k, t of window._cache.artists
+			delete window._cache.artists[k] unless t.id in artists
+
+		if deleted.length > 0
+			jQuery.ajax
+				url: "#{_root}/clean-cache"
+				type: 'post'
+				data:
+					tracks: deleted.join ','
 // Generated by CoffeeScript 1.6.2
 (function() {
-  var Playlist;
+  var Playlist,
+    __indexOf = [].indexOf || function(item) { for (var i = 0, l = this.length; i < l; i++) { if (i in this && this[i] === item) return i; } return -1; };
 
   window.Playlist = Playlist = (function() {
     /*
       this.initMouse();
       this.initKeyboard();
       this.initSort();
+      selectBox($('#playlist-wrapper'));
     }
 
     /*
 
 
     Playlist.prototype.playRow = function(r) {
-      return window.player.play($(r).attr('data-id'), $(r).attr('data-length'));
+      window.player.play($(r).attr('data-id'), $(r).attr('data-length'));
+      this.clearSelection();
+      return this.selectRow(r);
     };
 
     /*
           e.preventDefault();
           albums = [];
           $('#playlist .selected').remove();
+          window.info.clear();
           my.savePlaylist();
           return my.cleanCache();
         } else if (e.keyCode === 38) {
 
 
     Playlist.prototype.cleanCache = function() {
-      var albums, artists, tracks;
+      var albums, artists, deleted, k, t, tracks, _ref, _ref1, _ref2, _ref3, _ref4, _ref5;
 
-      return;
       tracks = [];
       albums = [];
       artists = [];
-      return $$('#playlist tbody tr').forEach(function(row) {
+      $$('#playlist tbody tr').forEach(function(row) {
         var albumid, artistid, trackid, _ref, _ref1;
 
         trackid = $(row).attr('data-id');
         albumid = (_ref = window._cache.tracks[trackid]) != null ? _ref.album : void 0;
         artistid = (_ref1 = window._cache.albums[albumid]) != null ? _ref1.artist : void 0;
-        tracks.push(trackid);
-        albums.push(albumid);
-        return artists.push(artistid);
+        if (__indexOf.call(tracks, trackid) < 0) {
+          tracks.push(trackid);
+        }
+        if (__indexOf.call(albums, albumid) < 0) {
+          albums.push(albumid);
+        }
+        if (__indexOf.call(artists, artistid) < 0) {
+          return artists.push(artistid);
+        }
       });
+      deleted = [];
+      _ref = window._cache.tracks;
+      for (k in _ref) {
+        t = _ref[k];
+        if (_ref1 = t.id, __indexOf.call(tracks, _ref1) < 0) {
+          deleted.push(t.id);
+          delete window._cache.tracks[k];
+        }
+      }
+      _ref2 = window._cache.albums;
+      for (k in _ref2) {
+        t = _ref2[k];
+        if (_ref3 = t.id, __indexOf.call(albums, _ref3) < 0) {
+          delete window._cache.albums[k];
+        }
+      }
+      _ref4 = window._cache.artists;
+      for (k in _ref4) {
+        t = _ref4[k];
+        if (_ref5 = t.id, __indexOf.call(artists, _ref5) < 0) {
+          delete window._cache.artists[k];
+        }
+      }
+      if (deleted.length > 0) {
+        return jQuery.ajax({
+          url: "" + _root + "/clean-cache",
+          type: 'post',
+          data: {
+            tracks: deleted.join(',')
+          }
+        });
+      }
     };
 
     return Playlist;
Tip: Filter by directory path e.g. /media app.js to search for public/media/app.js.
Tip: Use camelCasing e.g. ProjME to search for ProjectModifiedEvent.java.
Tip: Filter by extension type e.g. /repo .js to search for all .js files in the /repo directory.
Tip: Separate your search with spaces e.g. /ssh pom.xml to search for src/ssh/pom.xml.
Tip: Use ↑ and ↓ arrow keys to navigate and return to view the file.
Tip: You can also navigate files with Ctrl+j (next) and Ctrl+k (previous) and view the file with Ctrl+o.
Tip: You can also navigate files with Alt+j (next) and Alt+k (previous) and view the file with Alt+o.