From 90c7a594567d4b33bd0923f461af0f623fafe4db Mon Sep 17 00:00:00 2001
From: ale <ale@incal.net>
Date: Wed, 3 Aug 2011 08:36:50 +0100
Subject: [PATCH] move Javascript code to its own directory; add a Makefile to
 invoke the Closure Javascript Compiler to merge all our code into a single
 minified JS file

---
 server/djrandom/frontend/static/js/djr.min.js |  17 ++
 .../djrandom/frontend/static/js/djr/Makefile  |  16 ++
 .../frontend/static/js/djr/backend.js         | 101 ++++++-
 .../djrandom/frontend/static/js/djr/player.js | 254 +++---------------
 .../frontend/static/js/djr/playlist.js        | 236 ++++++++++++++++
 server/djrandom/frontend/templates/_base.html |   2 +-
 6 files changed, 396 insertions(+), 230 deletions(-)
 create mode 100644 server/djrandom/frontend/static/js/djr.min.js
 create mode 100644 server/djrandom/frontend/static/js/djr/Makefile
 create mode 100644 server/djrandom/frontend/static/js/djr/playlist.js

diff --git a/server/djrandom/frontend/static/js/djr.min.js b/server/djrandom/frontend/static/js/djr.min.js
new file mode 100644
index 0000000..a4a8370
--- /dev/null
+++ b/server/djrandom/frontend/static/js/djr.min.js
@@ -0,0 +1,17 @@
+djr={};var CHARS="0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz".split("");djr.generateRandomId=function(){var a=[],b=CHARS,c,d=b.length;for(c=0;c<40;c++)a[c]=b[0|Math.random()*d];return a.join("")};djr.Backend=function(){};djr.Backend.prototype.search=function(a,b,c){$.ajax({url:"/json/search",data:{q:a},dataType:"json",type:"GET",context:c||this,success:function(a){b(a.results)}})};djr.Backend.prototype.moreLikeThese=function(a,b,c){$.ajax({url:"/json/morelikethese",data:{h:a.join(",")},dataType:"json",type:"POST",context:c||this,success:function(a){b(a.results)}})};
+djr.Backend.prototype.getPlaylist=function(a,b,c){$.ajax({url:"/json/playlist/get/"+a,dataType:"json",type:"GET",context:c||this,success:function(c){b(new djr.PlaylistChunk(c.songs,a))}})};djr.Backend.prototype.savePlaylist=function(a,b){$.ajax({url:"/json/playlist/save",data:{uuid:a,h:b.join(",")},type:"POST"})};djr.Backend.prototype.streamPlaylist=function(a,b,c,d){$.ajax({url:"/json/playlist/stream",data:{uuid:a,stream:b?"y":"n"},dataType:"json",type:"POST",context:d||this,success:function(a){c(a)}})};
+djr.Backend.prototype.getHtmlForSongs=function(a,b,c){$.ajax({url:"/fragment/songs",data:{h:a.join(",")},dataType:"html",type:"POST",context:c||this,success:b})};djr.Backend.prototype.nowPlaying=function(a,b){$.ajax({url:"/json/playing",data:{cur:a,prev:b.join(",")},type:"POST"})};djr.PlaylistChunk=function(a,b){this.songs=a||[];this.title=b};djr.PlaylistChunk.prototype.hasSong=function(a){return this.songs.indexOf(a)>=0};djr.PlaylistChunk.prototype.removeSong=function(a){this.songs=$.grep(this.songs,function(b){return b!=a})};djr.controlButtons=function(a){return'<div class="ctlbox" style="display:none"><a id="'+a+'_remove" class="ctl_btn ctl_remove">&nbsp;</a></div>'};
+djr.PlaylistChunk.prototype.wrapHtml=function(a,b){return'<div id="chunk_'+a+'" class="chunk"><div class="chunk_ctl_wrap">'+djr.controlButtons("chunk_ctl_"+a)+'<a class="chunk_title">'+this.title+'</a></div><div class="chunk_inner">'+(b||"")+"</div></div>"};djr.Playlist=function(a){this.uuid=a||djr.generateRandomId();this.chunks=[];this.song_map={};this.chunk_map={};this.next_chunk_id=0};
+djr.Playlist.prototype.allSongs=function(){var a=[],b,c;for(b=0;b<this.chunks.length;b++)for(c=0;c<this.chunk_map[this.chunks[b]].songs.length;c++)a.push(this.chunk_map[this.chunks[b]].songs[c]);return a};djr.Playlist.prototype.createUniqueChunk=function(a,b){var c=[],d;for(d=0;d<a.length;d++)this.song_map[a[d]]==null&&c.push(a[d]);return c.length>0?new djr.PlaylistChunk(c,b):null};
+djr.Playlist.prototype.addChunk=function(a){djr.debug("adding chunk to playlist "+this.uuid);var b,c=this.next_chunk_id++;for(b=0;b<a.songs.length;b++)this.song_map[a.songs[b]]=c;this.chunk_map[c]=a;this.chunks.push(c);return c};djr.Playlist.prototype.getChunkSongs=function(a){return this.chunk_map[a].songs};
+djr.Playlist.prototype.removeChunk=function(a){djr.debug("removing chunk "+a);var b=this.chunk_map[a].songs,c;for(c=0;c<b.length;c++)delete this.song_map[b[c]];delete this.chunk_map[a];this.chunks=$.grep(this.chunks,function(b){return b!=a})};djr.Playlist.prototype.removeSong=function(a){djr.debug("removing song "+a);var b=this.song_map[a];this.chunk_map[b].removeSong(a);delete this.song_map[a];return this.chunk_map[b].songs.length==0?(this.removeChunk(b),b):-1};
+djr.Playlist.prototype.merge=function(){var a=[],b;for(b=0;b<this.chunks.length;b++)a.push(this.chunk_map[this.chunks[b]].title);a=a.join(" + ");b=new djr.Playlist;b.uuid=this.uuid;b.addChunk(new djr.PlaylistChunk(this.allSongs(),a));return b};djr.Playlist.prototype.getNextSong=function(a){var b=this.song_map[a],c=this.chunk_map[b].songs,b=this.chunks.indexOf(b),a=c.indexOf(a)+1;a>=c.length&&(a=0,b++,b>=this.chunks.length&&(b=0));return this.chunk_map[this.chunks[b]].songs[a]};djr.Player=function(a,b){this.backend=a;this.player=$(b);this.playlist=new djr.Playlist;this.old_songs=[];this.cur_song=null;this.player.jPlayer({swfPath:"/static/js",ready:function(){djr.debug("player ready")}});this.player.bind($.jPlayer.event.ended+".djr",function(){djr.state.player.nextSong()});this.player.bind($.jPlayer.event.error+".djr",function(){djr.state.player.reportError()})};djr.Player.prototype.hideAllChunks=function(){$(".chunk .chunk_inner").hide()};
+djr.Player.prototype.removeChunk=function(a){this.playlist.removeChunk(a);this.savePlaylist();$("#chunk_"+a).remove()};djr.Player.prototype.removeSong=function(a){$("#song_"+a).remove();a=this.playlist.removeSong(a);this.savePlaylist();a>0&&$("#chunk_"+a).remove()};djr.Player.prototype.savePlaylist=function(){this.backend.savePlaylist(this.playlist.uuid,this.playlist.allSongs())};djr.Player.prototype.clearPlaylist=function(){this.playlist=new djr.Playlist;$("#playlistDiv").empty()};
+djr.Player.prototype.mergePlaylistChunks=function(){this.playlist=this.playlist.merge();var a=[];$(".chunk .chunk_inner").each(function(){a.push($(this).html())});$("#playlistDiv").empty();var b=this.playlist.chunks[0];this.setChunkHtml(this.playlist.chunk_map[b],b,a.join(""))};djr.Player.prototype.search=function(a){var b=this;this.backend.search(a,function(c){var d=[];$.each(c,function(a,b){d.push(b.sha1)});d.length==0?djr.debug("No results found."):b.createChunk(d,a)})};
+djr.Player.prototype.extendCurrentPlaylist=function(){var a=this;this.backend.moreLikeThese(this.playlist.allSongs(),function(b){a.createChunk(b,"suggestions")})};djr.Player.prototype.createChunk=function(a,b){var c=this.playlist.createUniqueChunk(a,b);if(c){this.playlist.chunks.length>1&&this.mergePlaylistChunks();var d=this.playlist.addChunk(c);this.savePlaylist();this.backend.getHtmlForSongs(a,function(a){this.hideAllChunks();this.setChunkHtml(c,d,a)},this)}else djr.debug("All the results are already in the playlist")};
+djr.Player.prototype.setChunkHtml=function(a,b,c){a=a.wrapHtml(b,c);$("#playlistDiv").append(a);var d=this,e=$("#chunk_"+b);e.find(".song_a").click(function(){d.play($(this).attr("id").substr(5))});e.find(".album_a").click(function(){d.search('(album:"'+$(this).text()+'")')});e.find(".chunk_title").click(function(){e.find(".chunk_inner").toggle()});e.hover(function(){$(this).find(".chunk_ctl_wrap .ctlbox").show()},function(){$(this).find(".chunk_ctl_wrap .ctlbox").hide()});e.find(".chunk_ctl_wrap .ctlbox .ctl_remove").click(function(){djr.debug("removing chunk "+
+b);d.removeChunk(b)});e.find(".chunk_inner .song").hover(function(){$(this).find(".ctlbox").show()},function(){$(this).find(".ctlbox").hide()});e.find(".chunk_inner .ctlbox .ctl_remove").click(function(){var a=$(this).parent().parent().attr("id").substr(5);d.removeSong(a)})};
+djr.Player.prototype.play=function(a){djr.debug("play "+a);this.cur_song&&(this.old_songs.push(this.cur_song),this.old_songs.length>5&&this.old_songs.shift());this.cur_song=a;$(".song").removeClass("playing");$("#song_"+a).addClass("playing");this.player.jPlayer("setMedia",{mp3:"/dl/"+a}).jPlayer("play");var b=$("#song_"+a+" .artist").text(),c=$("#song_"+a+" .album").text();$("#jp_playlist_1").html($("#song_"+a+" .title").text()+"<br>"+b+"<br><small>"+c+"</small>");b="/album_image/"+escape(b)+"/"+
+escape(c);$("#albumart_fs").attr("src",b);$("#albumart_fs").fullBg();$("#albumart_fs").show();this.backend.nowPlaying(a,this.old_songs)};djr.Player.prototype.nextSong=function(){this.play(this.playlist.getNextSong(this.cur_song))};djr.Player.prototype.streamCurrentPlaylist=function(){};djr.state={backend:null,player:null};
+djr.init=function(){djr.state.backend=new djr.Backend;djr.state.player=new djr.Player(djr.state.backend,"#djr_player");$("#playlistClearBtn").click(function(){djr.state.player.clearPlaylist()});$("#playlistStreamBtn").click(function(){djr.state.player.streamCurrentPlaylist()});$("#playlistExtendBtn").click(function(){djr.state.player.extendCurrentPlaylist()});$("#albumart_fs").load(function(){$(this).fullBg();$(this).show()})};djr.player=function(){return djr.state.player};
+djr.debug=function(a){$("#debug").append(a+"<br>")};
diff --git a/server/djrandom/frontend/static/js/djr/Makefile b/server/djrandom/frontend/static/js/djr/Makefile
new file mode 100644
index 0000000..fc30223
--- /dev/null
+++ b/server/djrandom/frontend/static/js/djr/Makefile
@@ -0,0 +1,16 @@
+
+JSCOMPILER = java -jar ~/src/jscompiler/compiler.jar
+
+SOURCES = \
+	djr.js \
+	backend.js \
+	playlist.js \
+	player.js
+
+TARGET = ../djr.min.js
+
+all: $(TARGET)
+
+../djr.min.js: $(SOURCES)
+	$(JSCOMPILER) $(SOURCES:%=--js=%) --js_output_file=$@
+
diff --git a/server/djrandom/frontend/static/js/djr/backend.js b/server/djrandom/frontend/static/js/djr/backend.js
index 3586149..5c0f45c 100644
--- a/server/djrandom/frontend/static/js/djr/backend.js
+++ b/server/djrandom/frontend/static/js/djr/backend.js
@@ -1,13 +1,34 @@
 // backend.js
 
 
-// Backend API stub.
-
+/**
+ * Backend API.
+ *
+ * This object provides simple wrappers for the most common AJAX calls
+ * to the backend.
+ *
+ * Note: methods that take a 'callback' parameter also accept a
+ * context parameter 'ctx', in case your callbacks need to use 'this'
+ * (also, I suck at Javascript OOP, so it's entirely possible there's
+ * an easier way to do it)...
+ *
+ * @constructor
+ * @this {Backend}
+ */
 djr.Backend = function() {
-  // Nothing yet.
+  // No state yet.
 };
 
-// Search.
+/**
+ * Search.
+ *
+ * It will call 'callback(results)' where results is an array of song
+ * objects that will have the 'sha1' and 'score' attributes.
+ *
+ * @param {string} query The search query.
+ * @param {function} callback Callback function.
+ * @param {Object} ctx Callback context.
+ */
 djr.Backend.prototype.search = function(query, callback, ctx) {
   $.ajax({url: '/json/search',
           data: {'q': query},
@@ -20,7 +41,13 @@ djr.Backend.prototype.search = function(query, callback, ctx) {
          });
 };
 
-// More like these.
+/**
+ * Return results similar to the provided songs.
+ *
+ * @param {Array[string]} hashes SHA1 hashes of the input songs.
+ * @param {function} callback Callback function.
+ * @param {Object} ctx Callback context.
+ */
 djr.Backend.prototype.moreLikeThese = function(uuids, callback, ctx) {
   $.ajax({url: '/json/morelikethese',
           data: {'h': uuids.join(',')},
@@ -33,9 +60,16 @@ djr.Backend.prototype.moreLikeThese = function(uuids, callback, ctx) {
          });
 };
 
-// Get information about a specific song.
-
-// Read a playlist, calls callback(Playlist).
+/**
+ * Get the songs in a playlist.
+ *
+ * It will invoke callback(songs) where 'songs' is an array of SHA1
+ * song hashes.
+ *
+ * @param {string} uuid Playlist identifier.
+ * @param {function} callback Callback function.
+ * @param {Object} ctx Callback context.
+ */
 djr.Backend.prototype.getPlaylist = function(uuid, callback, ctx) {
   $.ajax({url: '/json/playlist/get/' + uuid,
           dataType: 'json',
@@ -47,7 +81,15 @@ djr.Backend.prototype.getPlaylist = function(uuid, callback, ctx) {
          });
 };
 
-// Save a playlist (song array).
+/**
+ * Update contents of a playlist.
+ *
+ * Will create the playlist if necessary. You're supposed to generate
+ * the unique playlist identifier on the caller side.
+ *
+ * @param {string} uuid Playlist identifier.
+ * @param {Array[string]} songs SHA1 hashes of the songs.
+ */
 djr.Backend.prototype.savePlaylist = function(uuid, songs) {
   $.ajax({url: '/json/playlist/save',
           data: {uuid: uuid,
@@ -56,7 +98,19 @@ djr.Backend.prototype.savePlaylist = function(uuid, songs) {
          });
 };
 
-// Stream a playlist.
+/**
+ * Enable/disable streaming for a playlist.
+ *
+ * Invokes callback() with a single parameter: an object that has a
+ * boolean 'stream' attribute, and an optional 'url' attribute
+ * containing the actual stream url (only present if streaming is
+ * enabled).
+ *
+ * @param {string} uuid Playlist identifier.
+ * @param {bool} streaming Enable streaming for this playlist.
+ * @param {function} callback Callback function.
+ * @param {Object} ctx Callback context.
+ */
 djr.Backend.prototype.streamPlaylist = function(uuid, streaming, callback, ctx) {
   $.ajax({url: '/json/playlist/stream',
           data: {uuid: uuid,
@@ -70,23 +124,42 @@ djr.Backend.prototype.streamPlaylist = function(uuid, streaming, callback, ctx)
          });
 };
 
-// Return the HTML fragment for an array of songs.
+/**
+ * Return the HTML fragment to render a list of songs.
+ *
+ * The callback function will be called with the resulting HTML string
+ * as its first argument.
+ *
+ * @param {Array[string]} songs SHA1 hashes of the songs.
+ * @param {function} callback Callback function.
+ * @param {Object} ctx Callback context.
+ */
 djr.Backend.prototype.getHtmlForSongs = function(songs, callback, ctx) {
   $.ajax({url: '/fragment/songs',
           data: {'h': songs.join(',')},
           dataType: 'html',
           type: 'POST',
           context: ctx || this,
-          success: callback,
+          success: callback
          });
 };
 
-// Ping a song that is currently playing.
+/**
+ * Report that a new song is playing.
+ *
+ * Together with the hash of the current song, you can also send a
+ * short list of the most recent songs that have been played.
+ *
+ * @param {string} song SHA1 hash of the song that is about to start
+ *    playing.
+ * @param {Array[string]} old_songs SHA1 hashes of the previously 
+ *    played songs, in order from the oldest to the newest.
+ */
 djr.Backend.prototype.nowPlaying = function(song, old_songs) {
   $.ajax({url: '/json/playing',
           data: {'cur': song,
                  'prev': old_songs.join(',')},
-          type: 'POST',
+          type: 'POST'
           });
 };
 
diff --git a/server/djrandom/frontend/static/js/djr/player.js b/server/djrandom/frontend/static/js/djr/player.js
index 91286e6..2bdfa05 100644
--- a/server/djrandom/frontend/static/js/djr/player.js
+++ b/server/djrandom/frontend/static/js/djr/player.js
@@ -1,159 +1,6 @@
 // player.js
 
 
-// A Playlist chunk (basically, an array of songs).
-
-djr.PlaylistChunk = function(songs, title) {
-  this.songs = songs || [];
-  this.title = title;
-};
-
-djr.PlaylistChunk.prototype.hasSong = function(sha1) {
-  return (this.songs.indexOf(sha1) >= 0);
-};
-
-djr.PlaylistChunk.prototype.removeSong = function(sha1) {
-  this.songs = $.grep(this.songs, function(a) { return a != sha1; });
-};
-
-// HTML template for control buttons, generated from JS.
-djr.controlButtons = function(id_base) {
-  return ('<div class=\"ctlbox\" style=\"display:none\">' 
-          + '<a id=\"' + id_base + '_remove\" class="ctl_btn ctl_remove">&nbsp;</a>'
-          + '</div>');
-};
-
-djr.PlaylistChunk.prototype.wrapHtml = function(chunk_id, songs_html) {
-  return ('<div id=\"chunk_' + chunk_id + '\" class=\"chunk\">'
-          + '<div class=\"chunk_ctl_wrap\">'
-          + djr.controlButtons('chunk_ctl_' + chunk_id)
-          + '<a class=\"chunk_title\">' + this.title + '</a>'
-          + '</div>'
-          + '<div class=\"chunk_inner\">'
-          + (songs_html || '') + '</div></div>');
-};
-
-
-// Playlist.
-
-djr.Playlist = function(uuid) {
-  this.uuid = uuid || djr.generateRandomId();
-  this.chunks = [];
-  this.song_map = {};
-  this.chunk_map = {};
-  this.next_chunk_id = 0;
-};
-
-// Return an array with all songs, in order.
-djr.Playlist.prototype.allSongs = function() {
-  var songs = [], i, j;
-  for (i = 0; i < this.chunks.length; i++) {
-    for (j = 0; j < this.chunk_map[this.chunks[i]].songs.length; j++) {
-      songs.push(this.chunk_map[this.chunks[i]].songs[j]);
-    }
-  }
-  return songs;
-};
-
-// Creates a new chunk with only unique songs.
-djr.Playlist.prototype.createUniqueChunk = function(songs, title) {
-  var unique = [], i;
-  for (i = 0; i < songs.length; i++) {
-    if (this.song_map[songs[i]] != null) {
-      continue;
-    }
-    unique.push(songs[i]);
-  }
-  if (unique.length > 0) {
-    return new djr.PlaylistChunk(unique, title);
-  } else {
-    return null;
-  }
-};
-
-// Add a new chunk (only adds unique songs).
-// Returns the chunk ID, or -1 if no songs were added.
-djr.Playlist.prototype.addChunk = function(playlist_chunk) {
-  djr.debug('adding chunk to playlist ' + this.uuid);
-  var i, chunk_id = this.next_chunk_id++;
-  for (i = 0; i < playlist_chunk.songs.length; i++) {
-    var song = playlist_chunk.songs[i];
-    this.song_map[song] = chunk_id;
-  }
-  this.chunk_map[chunk_id] = playlist_chunk;
-  this.chunks.push(chunk_id);
-  return chunk_id;
-};
-
-// Return the songs for a specific chunk.
-djr.Playlist.prototype.getChunkSongs = function(chunk_id) {
-  return this.chunk_map[chunk_id].songs;
-};
-
-// Remove a chunk (by ID).
-djr.Playlist.prototype.removeChunk = function(chunk_id) {
-  djr.debug('removing chunk ' + chunk_id);
-  var songs = this.chunk_map[chunk_id].songs, i;
-  for (i = 0; i < songs.length; i++) {
-    delete this.song_map[songs[i]];
-  }
-  delete this.chunk_map[chunk_id];
-  this.chunks = $.grep(this.chunks, function(cid) { return cid != chunk_id; });
-};
-
-// Remove a song.
-// Returns the chunk ID if after removing the song, the chunk was left
-// completely empty, or -1 otherwise.
-djr.Playlist.prototype.removeSong = function(song) {
-  djr.debug('removing song ' + song);
-  var chunk_id = this.song_map[song];
-  this.chunk_map[chunk_id].removeSong(song);
-  delete this.song_map[song];
-  if (this.chunk_map[chunk_id].songs.length == 0) {
-    this.removeChunk(chunk_id);
-    return chunk_id;
-  } else {
-    return -1;
-  }
-};
-
-// Return a new Playlist with all the current chunks merged.
-djr.Playlist.prototype.merge = function() {
-  var new_title_parts = [], i;
-  for (i = 0; i < this.chunks.length; i++) {
-    new_title_parts.push(this.chunk_map[this.chunks[i]].title);
-  }
-  var new_title = new_title_parts.join(' + ');
-  var new_playlist = new djr.Playlist();
-  new_playlist.uuid = this.uuid;
-  new_playlist.addChunk(
-    new djr.PlaylistChunk(this.allSongs(), new_title));
-  return new_playlist;
-};
-
-// Find the next song.
-djr.Playlist.prototype.getNextSong = function(song) {
-  var cur_chunk = this.song_map[song];
-  var chunk_songs = this.chunk_map[cur_chunk].songs;
-
-  var chunk_idx = this.chunks.indexOf(cur_chunk);
-  var idx = chunk_songs.indexOf(song) + 1;
-
-  if (idx >= chunk_songs.length) {
-    idx = 0;
-    chunk_idx++;
-    if (chunk_idx >= this.chunks.length) {
-      chunk_idx = 0;
-    }
-  }
-
-  return this.chunk_map[this.chunks[chunk_idx]].songs[idx];
-};
-
-
-
-
-
 // Player
 
 djr.Player = function(backend, selector) {
@@ -168,7 +15,7 @@ djr.Player = function(backend, selector) {
     swfPath: '/static/js',
     ready: function() {
       djr.debug('player ready');
-    },
+    }
   });
   this.player.bind($.jPlayer.event.ended + '.djr', 
                    function () {djr.state.player.nextSong(); });
@@ -247,31 +94,47 @@ djr.Player.prototype.search = function(query) {
     }
 
     // Create a chunk of unique new songs.
-    var new_chunk = player.playlist.createUniqueChunk(songs, query);
-    if (!new_chunk) {
-      djr.debug('All the results are already in the playlist.');
-      return;
-    }
-    songs = new_chunk.songs;
-
-    // Ok, now, if we have more than 1 chunk in the playlist, we
-    // merge them.
-    if (player.playlist.chunks.length > 1) {
-      player.mergePlaylistChunks();
-    }
+    player.createChunk(songs, query);
+  });
+};
 
-    // Now add the new chunks with the search results.
-    var chunk_id = player.playlist.addChunk(new_chunk);
-    djr.debug('search: new chunk ' + chunk_id + ', ' + songs.length + ' songs');
-    player.savePlaylist();
+// Extend the current playlist with suggestions.
+djr.Player.prototype.extendCurrentPlaylist = function() {
+  var player = this;
+  var cur_songs = this.playlist.allSongs();
 
-    player.backend.getHtmlForSongs(songs, function(songs_html) {
-      var chunk_div = 'chunk_' + chunk_id;
-      player.hideAllChunks();
-      player.setChunkHtml(new_chunk, chunk_id, songs_html);
-    });
+  this.backend.moreLikeThese(cur_songs, function(results) {
+    player.createChunk(results, 'suggestions');
   });
+};
+
+djr.Player.prototype.createChunk = function(songs, chunk_title) {
+  // Create the new chunk, with unique songs.
+  var chunk = this.playlist.createUniqueChunk(songs, chunk_title);
+  if (!chunk) {
+    djr.debug('All the results are already in the playlist');
+    return;
+  }
+
+  // If there's more than one chunk in the playlist, merge (that is,
+  // replace the current playlist with a new, merged one).
+  if (this.playlist.chunks.length > 1) {
+    this.mergePlaylistChunks();
+  }
+
+  // Add the new chunk.
+  var chunk_id = this.playlist.addChunk(chunk);
 
+  // Save the current playlist.
+  this.savePlaylist();
+
+  // Load the HTML fragment for the chunk and display it.  Also,
+  // minimize all the other chunks.
+  this.backend.getHtmlForSongs(songs, function(songs_html) {
+    var chunk_div = 'chunk_' + chunk_id;
+    this.hideAllChunks();
+    this.setChunkHtml(chunk, chunk_id, songs_html);
+  }, this);
 };
 
 // Set the HTML contents of a chunk object, and the related event hooks.
@@ -366,41 +229,10 @@ djr.Player.prototype.streamCurrentPlaylist = function(enable) {
   // Nothing for now.
 };
 
-// Extend the current playlist with suggestions.
-djr.Player.prototype.extendCurrentPlaylist = function() {
-  var player = this;
-  var cur_songs = this.playlist.allSongs();
-
-  this.backend.moreLikeThese(cur_songs, function(results) {
-    var new_chunk = player.playlist.createUniqueChunk(results, 'suggestions');
-    if (!new_chunk) {
-      djr.debug('All the results are already in the playlist.');
-      return;
-    }
-
-    // Ok, now, if we have more than 1 chunk in the playlist, we
-    // merge them.
-    if (player.playlist.chunks.length > 1) {
-      player.mergePlaylistChunks();
-    }
-
-    // Add the new chunk with the suggestions.
-    var chunk_id = player.playlist.addChunk(new_chunk);
-    var songs = new_chunk.songs;
-    player.savePlaylist();
-
-    player.backend.getHtmlForSongs(songs, function(songs_html) {
-      var chunk_div = 'chunk_' + chunk_id;
-      player.hideAllChunks();
-      player.setChunkHtml(new_chunk, chunk_id, songs_html);
-    });
-  });
-};
-
 
 djr.state = {
   backend: null,
-  player: null,
+  player: null
 };
 
 djr.init = function () {
@@ -441,14 +273,6 @@ djr.debug = function(msg) {
 
 /**
 
-
-// Empty the current playlist.
-djr.Player.prototype.clearPlaylist = function() {
-  this.curPlaylist = Array();
-  this.curPlaylistId = null;
-  this.curIdx = -1;
-};
-
 // An error has occurred in the player.
 djr.Player.prototype.reportError = function(event) {
   switch(event.jPlayer.error.type) {
diff --git a/server/djrandom/frontend/static/js/djr/playlist.js b/server/djrandom/frontend/static/js/djr/playlist.js
new file mode 100644
index 0000000..cc23bd3
--- /dev/null
+++ b/server/djrandom/frontend/static/js/djr/playlist.js
@@ -0,0 +1,236 @@
+// playlist.js
+
+
+/**
+ * A Playlist chunk (basically, an array of songs).
+ *
+ * A chunk is the lowest level unit of a playlist, it's just an
+ * ordered list of songs, with a title.
+ *
+ * @constructor
+ * @param {Array[string]} songs SHA1 hashes of the songs.
+ * @param {string} title Chunk title (optional).
+ */
+djr.PlaylistChunk = function(songs, title) {
+  this.songs = songs || [];
+  this.title = title;
+};
+
+/**
+ * Check if the playlist contains a specific song.
+ *
+ * @param {string} sha1 Song SHA1 hash.
+ * @return {bool} True if the song is in this chunk.
+ */
+djr.PlaylistChunk.prototype.hasSong = function(sha1) {
+  return (this.songs.indexOf(sha1) >= 0);
+};
+
+/**
+ * Remove a song from the playlist (if present).
+ *
+ * @param {string} sha1 Song SHA1 hash.
+ */
+djr.PlaylistChunk.prototype.removeSong = function(sha1) {
+  this.songs = $.grep(this.songs, function(a) { return a != sha1; });
+};
+
+// HTML template for control buttons, generated from JS.
+djr.controlButtons = function(id_base) {
+  return ('<div class=\"ctlbox\" style=\"display:none\">' 
+          + '<a id=\"' + id_base + '_remove\" class="ctl_btn ctl_remove">&nbsp;</a>'
+          + '</div>');
+};
+
+/**
+ * Generate the HTML chunk for the playlist.
+ *
+ * This method needs a bit of context: when we need to create a new
+ * chunk on-the-fly, the HTML fargment returned by the backend search
+ * must be wrapped by the chunk <div>.  We do it here in this
+ * function, since there's not much to it anyway, and we can save a
+ * round-trip to the server when creating a chunk with existing data
+ * (as it happens when merging after search, for example).
+ *
+ * @param {string} chunk_id The chunk ID.
+ * @param {string} songs_html The HTML fragment returned by the backend.
+ */
+djr.PlaylistChunk.prototype.wrapHtml = function(chunk_id, songs_html) {
+  return ('<div id=\"chunk_' + chunk_id + '\" class=\"chunk\">'
+          + '<div class=\"chunk_ctl_wrap\">'
+          + djr.controlButtons('chunk_ctl_' + chunk_id)
+          + '<a class=\"chunk_title\">' + this.title + '</a>'
+          + '</div>'
+          + '<div class=\"chunk_inner\">'
+          + (songs_html || '') + '</div></div>');
+};
+
+
+
+// Playlist.
+
+djr.Playlist = function(uuid) {
+  this.uuid = uuid || djr.generateRandomId();
+  this.chunks = [];
+  this.song_map = {};
+  this.chunk_map = {};
+  this.next_chunk_id = 0;
+};
+
+/**
+ * Return an array with all songs in the playlist.
+ *
+ * Songs are returned in order.
+ *
+ * @return {Array[string]} SHA1 hashes of all songs.
+ */
+djr.Playlist.prototype.allSongs = function() {
+  var songs = [], i, j;
+  for (i = 0; i < this.chunks.length; i++) {
+    for (j = 0; j < this.chunk_map[this.chunks[i]].songs.length; j++) {
+      songs.push(this.chunk_map[this.chunks[i]].songs[j]);
+    }
+  }
+  return songs;
+};
+
+/**
+ * Creates a new chunk with only unique songs.
+ *
+ * This method creates a new chunk that enforces the constraint that a
+ * playlist should only contain unique songs. The newly created chunk
+ * is just returned, but not added to the playlist.
+ *
+ * @param {Array[string]} songs SHA1 hashes of the songs.
+ * @param {string} title Title of the new chunk.
+ * @return {PlaylistChunk} Newly created chunk with unique songs, or
+ *     null if the chunk would have been empty.
+ */
+djr.Playlist.prototype.createUniqueChunk = function(songs, title) {
+  var unique = [], i;
+  for (i = 0; i < songs.length; i++) {
+    if (this.song_map[songs[i]] != null) {
+      continue;
+    }
+    unique.push(songs[i]);
+  }
+  if (unique.length > 0) {
+    return new djr.PlaylistChunk(unique, title);
+  } else {
+    return null;
+  }
+};
+
+/**
+ * Add a new chunk (only adds unique songs).
+ *
+ * Also assigns a new unique chunk ID (an integer at the moment).
+ *
+ * @param {PlaylistChunk} playlist_chunk Chunk to add.
+ * @return {number} The chunk ID, or -1 if no songs were added.
+ */
+djr.Playlist.prototype.addChunk = function(playlist_chunk) {
+  djr.debug('adding chunk to playlist ' + this.uuid);
+  var i, chunk_id = this.next_chunk_id++;
+  for (i = 0; i < playlist_chunk.songs.length; i++) {
+    var song = playlist_chunk.songs[i];
+    this.song_map[song] = chunk_id;
+  }
+  this.chunk_map[chunk_id] = playlist_chunk;
+  this.chunks.push(chunk_id);
+  return chunk_id;
+};
+
+/**
+ * Return the songs for a specific chunk.
+ *
+ * @param {number} chunk_id ID of the chunk.
+ * @return {Array[string]} SHA1 hashes of the songs.
+ */
+djr.Playlist.prototype.getChunkSongs = function(chunk_id) {
+  return this.chunk_map[chunk_id].songs;
+};
+
+/**
+ * Remove a chunk.
+ *
+ * @param {number} chunk_id ID of the chunk to remove.
+ */
+djr.Playlist.prototype.removeChunk = function(chunk_id) {
+  djr.debug('removing chunk ' + chunk_id);
+  var songs = this.chunk_map[chunk_id].songs, i;
+  for (i = 0; i < songs.length; i++) {
+    delete this.song_map[songs[i]];
+  }
+  delete this.chunk_map[chunk_id];
+  this.chunks = $.grep(this.chunks, function(cid) { return cid != chunk_id; });
+};
+
+/**
+ * Remove a song.
+ *
+ * @param {string} song SHA1 hash of the song.
+ * @return {number} Returns the chunk ID if after removing the song,
+ *     the chunk was left completely empty, or -1 otherwise.
+ */
+djr.Playlist.prototype.removeSong = function(song) {
+  djr.debug('removing song ' + song);
+  var chunk_id = this.song_map[song];
+  this.chunk_map[chunk_id].removeSong(song);
+  delete this.song_map[song];
+  if (this.chunk_map[chunk_id].songs.length == 0) {
+    this.removeChunk(chunk_id);
+    return chunk_id;
+  } else {
+    return -1;
+  }
+};
+
+/**
+ * Merge all chunks into a new playlist.
+ *
+ * The new Playlist will contain a single chunk, with all the
+ * playlists' songs in it, and with a title that created combining the
+ * titles of all the existing chunks.
+ *
+ * The UUID of the new playlist will be the same as the current one.
+ *
+ * @return {Playlist} The new playlist.
+ */
+djr.Playlist.prototype.merge = function() {
+  var new_title_parts = [], i;
+  for (i = 0; i < this.chunks.length; i++) {
+    new_title_parts.push(this.chunk_map[this.chunks[i]].title);
+  }
+  var new_title = new_title_parts.join(' + ');
+  var new_playlist = new djr.Playlist();
+  new_playlist.uuid = this.uuid;
+  new_playlist.addChunk(
+    new djr.PlaylistChunk(this.allSongs(), new_title));
+  return new_playlist;
+};
+
+/**
+ * Find the next song.
+ *
+ * @param {string} song SHA1 hash of a song.
+ * @return {string} SHA1 hash of the song that comes after the
+ *     specified one.
+ */
+djr.Playlist.prototype.getNextSong = function(song) {
+  var cur_chunk = this.song_map[song];
+  var chunk_songs = this.chunk_map[cur_chunk].songs;
+
+  var chunk_idx = this.chunks.indexOf(cur_chunk);
+  var idx = chunk_songs.indexOf(song) + 1;
+
+  if (idx >= chunk_songs.length) {
+    idx = 0;
+    chunk_idx++;
+    if (chunk_idx >= this.chunks.length) {
+      chunk_idx = 0;
+    }
+  }
+
+  return this.chunk_map[this.chunks[chunk_idx]].songs[idx];
+};
diff --git a/server/djrandom/frontend/templates/_base.html b/server/djrandom/frontend/templates/_base.html
index c12b36a..256f6ee 100644
--- a/server/djrandom/frontend/templates/_base.html
+++ b/server/djrandom/frontend/templates/_base.html
@@ -17,7 +17,7 @@
     <script type="text/javascript"
             src="/static/js/jquery.fullbg.min.js"></script>
     <script type="text/javascript"
-            src="/static/player.js?v=5"></script>
+            src="/static/js/djr.min.js"></script>
     {% block head %}{% endblock %}
   </head>
   <body>
-- 
GitLab