cuzi / player

// ==UserScript==
// @name        player
// @namespace   cuzi
// @oujs:author cuzi
// @description Play bandcamp music.
// @homepageURL https://openuserjs.org/scripts/cuzi/player
// @icon        data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADAAAAAwAQMAAABtzGvEAAAABlBMVEUAAAAclZU8CPpPAAAAAXRSTlMAQObYZgAAAFZJREFUeF6N0DEKAzEMBMBAinxbT/NT9gkuVRg7kCFwqS7bTCVW0uOPPOvDK2hsnELQ2DiFoLFxCkFj4xSC+UMwYGBhYkDRwsRAXfdsBHW9r5HvJ27yBmrWa3qFBFkKAAAAAElFTkSuQmCC
// @version     4
// @license     GPL-3.0
// @include     *
// @require     http://ajax.googleapis.com/ajax/libs/jquery/2.1.1/jquery.min.js
// @require     https://raw.githubusercontent.com/cvzi/bcplayer/master/BCPlayerLib.js
// @grant       GM_setValue
// @grant       GM_getValue
// @grant       GM_registerMenuCommand
// @grant       GM_addStyle
// @grant       unsafeWindow
// ==/UserScript==
"use strict";


/*

This program can save a playlist of bandcamp songs from different albums and play them on any bandcamp.com page.
This program is under development, and at the moment has a very simple interface and only a limited functionality.

There are two necessary steps to add a song to the playlist:
 1st: Visit the album page and click on the [+] next to the play button of the song (this will add the song to your "library").
 2nd: Open the player by clicking the symbol in the top right corner.
      In the lower view (which is your "library") click on the [+].
      Now the song is enqueued and should appear in the upper view (which is your "playlist").

Start playback by pressing the play button next to a song in the upper view.

For a fullscreen player open https://bandcamp.com/player

*/

var config = {
  "refreshInterval" : 30, // seconds
  "titlePrefix" : String.fromCharCode(9835),
  "fullScreenURL" : /^https?:\/\/bandcamp\.com\/player\/?$/,
  "ButtonOpenPlayer" : "⧉"
};


/*
how is stuff saved:

bands
identified by id (band_id)
A band at the moment is very simple: { id:123, name:"the band name", albums:[id0,id1]}

tracks
identified by id

albums
identified by id (tralbum_id)

Constraints:
If a track is saved, its album and its band are saved aswell.
If an album has no tracks saved, it must be deleted.
If a band has no albums saved, it must be deleted.

*/

GM_registerMenuCommand("player - Reset everything", function() {
  if(!confirm("Really reset everything? You'll lose all your songs in the library.")) return;

  // JSON:
  GM_setValue("tracks","{}");
  GM_setValue("albums","{}");
  GM_setValue("bands","{}");
  GM_setValue("playlist","[]");
  GM_setValue("playlist_index","-1");

  // No JSON
  GM_setValue("libversion",Number.MIN_SAFE_INTEGER);
  GM_setValue("playeropen",false);
  GM_setValue("requestClose",false);
  GM_setValue("lastPosition",false);

  alert("Reset.");
  document.location.reload();
});
GM_registerMenuCommand("player - Repair", function() {
  if(!confirm("Attempt to repair the player?\n\nThis will empty your current playlist but NOT your library.\n\nPlease close all other bandcamp.com tabs and windows before you run this command.")) return;
  GM_setValue("playeropen","false");
  GM_setValue("playlist_index","-1");
  GM_setValue("libversion",Number.MIN_SAFE_INTEGER);

  GM_setValue("requestClose",false);
  GM_setValue("lastPosition",false);
  alert("Repair finished.");
  document.location.reload();
});

var to_resizeTimeout = true;
var iv_refreshInterval = false;

function fullscreenPlayer(ev) {
  if(!document.getElementById("playerX")) {
    return;
  }
  var mw = $("#playerX");

  // Stop animation of open button (it will be invisible anyway)
  $(".buttonopenplayer").removeClass("playingspinner");


  var w = $(window).width()-10;
  var h = $(window).height();
  var lh = h*0.7;
  var ph = h - lh - mw.find(".status").height();

  mw.css({
    'position' : 'fixed',
    'top' : '0px',
    'left' : '0px',
    'width' : w,
    'height' : h
  });
  mw.find(".library").css('height',lh);
  mw.find(".playlist").css('height',ph);
  mw.css("visibility","");

  if(to_resizeTimeout === true) {
    to_resizeTimeout = false;
    $(window).resize(function(ev) {
      if(to_resizeTimeout === false) {
        to_resizeTimeout = window.setTimeout(function() {
          to_resizeTimeout = false;
          fullscreenPlayer();
        },1000);
      }
    });
  }


}

function maximizePlayer(ev,Lib,pos) {
  // This function makes sure that only one player is open a the same time.
  // It requests any other player to close (by calling hidePlayer() in the other window) and waits (5 seconds) for confirmation before opening the player in this window.


  // Start animations here (because the "Open player" blutton is animated)
  $(".buttonopenplayer").addClass("playingspinner");

  if(ev && ev.data) {
    Lib = ev.data;
  }

  if(document.getElementById("playerX")) {
    // Toggle visibility
    if("hidden" == document.getElementById("playerX").style.visibility) {
      document.getElementById("playerX").style.visibility = "";
    }else {
      document.getElementById("playerX").style.visibility = "hidden";
    }
    return;
  }
  if(GM_getValue("playeropen",false)) {
    // Player is open in another window
    GM_setValue("requestClose",true);
    var checkCounter = 0;
    var iv_waitForOtherPlayer = window.setInterval(function() {
      if(GM_getValue("playeropen",false)) {
        checkCounter++;
        if(checkCounter > 16) { // after 5 seconds
          console.log("5 seconds rule");
          // Waited long enough
          window.clearInterval(iv_waitForOtherPlayer);
          GM_setValue("playeropen",false)
          GM_setValue("requestClose",false);
          maximizePlayer(ev,Lib,pos)
        }
        return;
      }
      window.clearInterval(iv_waitForOtherPlayer);
      maximizePlayer(null,Lib,parseFloat(GM_getValue("lastPosition",false)));
    },300);
  } else {
    // open Player
    var pl;
    GM_setValue("playeropen",true);
    GM_setValue("requestClose",false);
    GM_setValue("lastPosition",false);
    $(window).on('unload', function(){
       if(document.getElementById("playerX")) {
         GM_setValue("playeropen",false);
       }
    });
    var iv_waitForRequestClose = window.setInterval(function() {
      if(GM_getValue("requestClose",false)) {
        window.clearInterval(iv_waitForRequestClose);
        var position = hidePlayer(pl);
        GM_setValue("lastPosition",""+position);
        GM_setValue("playeropen",false);
      }
    },1000);
    pl = showPlayer(Lib,pos);
    document.title = config.titlePrefix+ " - " + document.title;
  }
}

function hidePlayer(pl) {
  // Close the player and remove it from DOM
  // If the player was playing the return value is the last position in seconds
  var pos = false;
  if(pl) {
    pl.pause();
    pos = pl.getTime();
    pl.getMainWindow().remove();
    pl = null;
  }
  document.title = document.title.substr(4);

  // Stop any animations
  $(".buttonopenplayer").removeClass("playingspinner");

  if(iv_refreshInterval !== false) {
    window.clearInterval(iv_refreshInterval);
    iv_refreshInterval = false;
  }

  return pos;
}

function showMenu(options,entries) {
  var $div = $("<div></div>").addClass("dropdownmenu").css({
    "position" : "absolute",
    "top" : options.position.top,
    "left" : options.position.left }).appendTo("#playerX");
  var $ul = $("<ul></ul>").appendTo($div);
  for(var i = 0; i < entries.length; i++) {
    $("<li></li>").appendTo($ul).html(entries[i].title).click((function(i) { return function(ev) {
      if(options.beforeClick)
        options.beforeClick.call(this,ev);
     if(entries[i].click)
       entries[i].click.call(this,ev);
     if(options.afterClick)
       options.afterClick.call(this,ev);
     };
     })(i));
  }
  return $div;
}

function showPlayer(Lib,pos) {
  // Initialize, customize and then open the player
  var libraryTrackMenu = function(ev) {
    var $this = $(this);
    var pl = ev.data; // Player object
    var $tr = $this.parent();
    var tid = parseInt($tr.find("td.ctrl").data("tid"),10);
    var pos = $this.position();
    pos.top += $this.height();

    $("#playerX .library .dropdownmenu").remove();
    //$("#playerX .library").css("overflow","hidden");

    var $menu = showMenu({
      "position" : pos,
      "beforeClick" : function(ev) {

      },
      "afterClick" : function(ev) {
        //$("#playerX .library").css("overflow","auto");
        $menu.remove();
      }
    },
    [
      {
        "title" : "Play",
        "click" : function(ev) {
           pl.addToPlaylist(tid,0);
           pl.play(0);
        }
      },
      {
        "title" : "Add to playlist (at the top)",
        "click" : function(ev) {
          pl.addToPlaylist(tid,0);
        }
      },
      {
        "title" : "Add to playlist (at the end)",
        "click" : function(ev) {
          pl.addToPlaylist(tid,-1);
        }
      },
      {
        "title" : "Show Album",
        "click" : function(ev) {
          var track = pl.getLibrary().getTrackById(tid);
          pl.getMainWindow().find(".searchfield").val("album:"+track.album.title).trigger("keyup");
        }
      },
      {
        "title" : "Show Artist",
        "click" : function(ev) {
          var track = pl.getLibrary().getTrackById(tid);
          pl.getMainWindow().find(".searchfield").val("artist:"+track.band.title).trigger("keyup");
        }
      },
      {
        "title" : "Delete from library",
        "click" : function(ev) {
          pl.getLibrary().removeTrack(tid,function(ok) {
            if(ok) {
              pl.refresh(1);
            } else {
              alert("Could not delete song from library.");
            }
          });
        }
      },
      {
        "title" : "Edit",
        "click" : function(ev) {
          alert("Not here yet");
        }
      }
    ]);


  };


  var pl = new BCPlayer(Lib,"playerX");
  GM_addStyle("\
  .playingspinner {color:#cb1; }\
  /*.playingspinner {width: 20px; display:inline-block; text-align:center; align-content:center;\
      -webkit-animation: rotation 2s infinite linear;\
     animation: rotation 10s infinite linear }\
  @-webkit-keyframes rotation {\
      from {-webkit-transform: rotate(0deg) }\
      to   {-webkit-transform: rotate(359deg) }\
  }\
  @keyframes rotation {\
      from {transform: rotate(0deg) }\
      to   {transform: rotate(359deg) }\
  }\
  */\
  #playerX {z-index: 15; position:absolute; left: 10px; top: 50px; width:600px; height: auto;\
    border:5px solid #333; background: linear-gradient(to right, white, silver); font-size:smaller;}\
  #playerX .library { height:300px; overflow:auto; }\
  #playerX .playlist { height:268px; overflow:auto; }\
  #playerX .status { height:28px; background: #333; color: white; }\
  #playerX .enqueue {\
    display:inline-block; width : 15px; height : 16px; text-align : center; \
    color : black; background:#fff; border: 1px solid #d9d9d9; \
    font-weight : 800; font-size : 14px;  cursor : pointer;\
  }\
  #playerX .seperator h1 { display:inline-block; color:#777; }\
  #playerX .playlist .ctrl { display:inline-block; width:40px; }\
  #playerX .searchfield {margin-left:15px; color:#777; width:240px; background:none; border:none;}\
  #playerX .searchfield .default {color:#999;}\
  #playerX audio {vertical-align:middle;}\
  #playerX .playlist .box {   display:inline-block; width : 15px; height : 16px; text-align : center; \
    color : black; background:#fff; border: 1px solid #d9d9d9; \
    font-weight : 800; font-size : 14px;  cursor : pointer; }\
  #playerX img.thumb {max-height:24px}\
  #playerX li {margin:2px 0px}\
  #playerX .dropdownmenu {background:#bbb;  }\
  #playerX .dropdownmenu ul {list-style:none; padding:0px;  }\
  #playerX #tableheadcopy {width:100%; background: linear-gradient(to right, white, silver); }\
  ");
  var $mw = pl.getMainWindow().appendTo(document.body);

  $mw.find(".seperator_status_playlist").append("<h1>playlist</h1>");
  $mw.find(".seperator_playlist_library").append("<h1>library</h1> "+pl.getLibrary().totalTracks()+" songs ");
  $mw.find(".searchfield").val("Search...").data("default","Search...").appendTo( $mw.find(".seperator_playlist_library")).focus(function() {
    var $this = $(this);
    if($this.val() == $this.data("default")) {
      $this.removeClass("default");
      $this.val("");
    }
  }).blur(function() {
    var $this = $(this);
    if($this.val() === "") {
      $this.addClass("default");
      $this.val($this.data("default"));
    }
  });



  var pad2 = function(i) { if(i < 10 && i > -10) { return "0"+parseInt(i,10) } return ""+parseInt(i,10); };
  var currentSorting = false;
  var currentDir = 1;
  var sortTwoDir = function(sid,str,desc) {
    return function(ev) {
      var $this = $(this);
      var pl = ev.data; // Player object
      if(currentSorting == sid && currentDir == 1) {
        pl.setSorting("desc:"+str);
        currentDir = -1;
      } else {
        pl.setSorting("asc:"+str);
        currentDir = 1;
      }
      currentSorting = sid;
      pl.refresh(1);
    }
  };
  var sortFourDir = function(sid,str1,desc1,str2,desc2) {
    return function(ev) {
      var $this = $(this);
      var pl = ev.data; // Player object
      if(currentSorting == sid && currentDir == 0) {
        pl.setSorting("asc:"+str1);
        currentDir = 1;
      } else if(currentSorting == sid && currentDir == 1) {
        pl.setSorting("desc:"+str1);
        currentDir = 2;
      } else if(currentSorting == sid && currentDir == 2) {
        pl.setSorting("asc:"+str2);
        currentDir = 3;
      } else if(currentSorting == sid && currentDir == 3) {
        pl.setSorting("desc:"+str2);
        currentDir = 0;
      } else {
        pl.setSorting("asc:"+str1);
        currentDir = 1;
      }
      currentSorting = sid;
      pl.refresh(1);
    }
  };

  pl.setColumns([
    {
      title: "&#10063;",
      get: function(track) {
        if(track.album.thumb) {
          return '<img onclick="PopupImage.show_inner(this.dataset.cover,800,800,this)" src="'+track.album.thumb+'" class="thumb" alt="cover" title="Open cover" data-cover="'+track.album.cover+'">';
        } else {
          var symbols = ["127912","127917","9825","127881","128145","128049","127932","128588","10048","128018","128051"];
          var i = 0;
          track.album.title.split("").map(function(v) {i += v.charCodeAt(0)});
          i = (parseInt(i*0.1,10) % (symbols.length+1))-1;
          return '<span style="font-size:20px;">&#'+symbols[i]+';</span>';
        }
      }
    },
    {
      title: "Name",
      key: "title",
      fieldclick : libraryTrackMenu,
      headerclick: sortTwoDir(1,"title","Sort by song") // TODO use string for sorting description
    },
    {
      title: "#",
      get: function(track) { return track.track_num+"/"+track.album.totaltracks; },
      headerclick: sortTwoDir(2,"track_num","Sort by track") // TODO use string for sorting description
    },
    {
      title: "Album",
      get: function(track) {
        return '<a target="_blank" href="'+track.album.url+'">'+track.album.title+'</a>';
      },
      headerclick: sortFourDir(3,"album.title","Sort by album","band.title,album.title,track_num","Sort by artist and album")
    },
    {
      title: "Artist",
      key: "band.title",
      headerclick: sortTwoDir(4,"band.title","Sort by artist")
    },
    {
      title: "Duration",
      get: function(track) {
        return pad2(Math.floor(track.duration / 60))+":"+pad2(Math.floor(track.duration % 60));
      },
      headerclick: sortTwoDir(5,"duration","Sort by length")
    }

  ]);

  pl.refresh();

  // Start playing because we just closed a playing player in another window
  if(pos && "number" == typeof pos) {
    pl.playAt(pos);
  }

  // Refresh every 30 seconds
  if(iv_refreshInterval === false) {
    iv_refreshInterval = window.setInterval(function() {
      pl.refresh(0,true);
    },1000*config.refreshInterval);
  }



  return pl;
}


function invertRGB(s) {
  var c = s.match(/(\d+), (\d+), (\d+)/);
  c.shift();
  c = c.map(function(v) {return  255-parseInt(v,10);});
  return "rgb("+c[0]+","+c[1]+","+c[2]+")";
}

function showPlayerButton(Lib) {
  // Show a small icon in the right top corner or on the left hand side of the bandcamp menu
  var b;
  if(document.getElementById("user-nav")) {
    b = $('<li class="menubar-item"><a><span class="buttonopenplayer">'+config.ButtonOpenPlayer+'</span></a></li>').attr("title","Open player").css({
      "cursor":"pointer"
    }).click(Lib,maximizePlayer).dblclick(fullscreenPlayer).appendTo($("#user-nav"));
    window.setTimeout(function() { b.appendTo($("#user-nav")); },1000);
  } else {
    b = $('<div class="buttonopenplayer">'+config.ButtonOpenPlayer+'</div>').attr("title","Open player").css({
      "position":"absolute",
      "top":"0px",
      "right":"5px",
      "cursor":"pointer",
      "z-index":10
    }).click(Lib,maximizePlayer).dblclick(fullscreenPlayer).appendTo(document.body);
    try { // On bandcamp.example.com pages this (sometimes) throws a security exception probably because of something cross domain
      b.css("color",invertRGB(window.getComputedStyle(document.body).backgroundColor));
    } catch(e) {
      b.css("color","black");
    }
  }
}


(function() {
  if(!unsafeWindow.TralbumData && !document.location.href.match(config.fullScreenURL)) return;
  var result = initBCLibrary();

  if(result.buttons) {
    showPlayerButton(result.library);
  } else if(document.location.href.match(config.fullScreenURL)) {
    showPlayerButton(result.library);
    maximizePlayer(null,result.library);
    fullscreenPlayer();
    window.setTimeout(fullscreenPlayer,1000); // This might happen if the player has to wait for another window
    window.setTimeout(fullscreenPlayer,3000);
  }



})();