Skip to content

Instantly share code, notes, and snippets.

@Beanow
Last active September 1, 2017 20:24
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save Beanow/bd9d75e10874e0e7b1a4fc08dfe8b0ac to your computer and use it in GitHub Desktop.
Save Beanow/bd9d75e10874e0e7b1a4fc08dfe8b0ac to your computer and use it in GitHub Desktop.
Earbits Enhancements UserScript
// ==UserScript==
// @name Earbits Enhancements
// @namespace https://github.com/beanow
// @description Implements improvements for Earbits.com's web player.
// @version 0.6
// @icon https://i.imgur.com/YrGdzh6.png
// @include /^https?://www\.earbits\.com.*$/
// @start-at document-idle
// @grant none
// ==/UserScript==
/*
Changelog:
. v0.6
* Fixed many inconsistency issues with playback from
different contexts like favs and artists.
* Fixed artist profile loading issues.
* Use React instead of jQuery to highlight favs.
- v0.5
* Fixed major CPU leak in player progress bar.
Should prevent your browser from slowing down over time.
- v0.4
* Added a favicon, taken from the Android app.
- v0.3
* Replaced broken Set.prototype.difference polyfill.
* Fixed NaN errors on favs duration.
- v0.2
* Cleaned up the remember-me like functionality.
* Fixed a favourites sync bug due to API pagination.
- v0.1
* Added a direct link, similar to Android's share,
for the currently playing song.
Disclaimer:
This software is intended as a fair use modification
of the You42 Radio (a.k.a. "Earbits") copyrighted software
as part of the earbits.com website.
Permission to use, copy, modify, and/or distribute this
software for any purpose with or without fee is hereby
granted to You42 Radio (a.k.a. "Earbits").
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS
ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO
EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER
RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION,
ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE
OF THIS SOFTWARE.
*/
//Add a favicon from the Android app.
$('<link rel="shortcut icon" type="image/png" href="https://i.imgur.com/YrGdzh6.png"/>')
.appendTo("head");
/*
The difference function is not implemented by all browsers.
So Earbits adds a polyfill for it, which is not implemented
correctly. Using for...in instead of for...of.
*/
Set.prototype.difference = function(b){
var d = new Set(this);
for (var e of b) d.delete(e);
return d;
}
/*
Various polyfills.
*/
Number.isInteger = Number.isInteger || function(value) {
return typeof value === 'number' &&
isFinite(value) &&
Math.floor(value) === value;
};
/*
For whatever reason, the Favorite's duration field in local storage
is sometimes a formatted string, sometimes an integer in ms.
It should be the integer one, so here's a function to fix bad data.
*/
Helpers.formatted_time_to_int = function(t){
var p = t.match(/^(\d+):(\d+)$/);
var min = parseInt(p[1], 10);
var sec = parseInt(p[2], 10);
return min*60*1000 + sec*1000;
}
Favorite.scrub_duration_field = function(){
var dirty = false;
var favs = Favorite.fetch_all();
favs.forEach(function(f){
if(typeof f.duration == 'string'){
f.duration = Helpers.formatted_time_to_int(f.duration);
dirty = true;
}
});
if(dirty) localStorage.setItem('favorites', JSON.stringify(favs));
}
/*
Hacky bugfix for the favourites sync.
By default the below GET request is paginated to 25 results.
This will cause every sync call to try and re-add all tracks
beyond the first 25 returned here. Additionally new favs
outside these first 25 will not be added to local storage.
Effectively breaking the fav sync entirely after you reach
26 favourites.
Unfortunately this means I have to copy the whole function implementation.
There's no light shim possible currently.
*/
Favorite.sync_favorites = function () {
$.ajax({
url: window.api_core_url + '/v1/users/' + Session.current_user().id + '/favorites',
type: 'GET',
dataType: 'json',
crossDomain: !0,
data: {
//Adding the user_id to params is redundant, as it's taken from the path.
per_page: 9999 //Hacky, but over 9000!
},
success: function (e) {
var t = Favorite.fetch_all(),
n = new Set(t.map(function (e) {
return e.trackId
}));
console.log('[MOD] syncing favorites'),
console.log(e);
for (var r = 0; r < e.length; r++) {
var o = e[r].track;
n.has(o.id) || Favorite.add({
name: o.name,
albumName: o.album_name,
featureImage: o.feature_image,
coverImage: o.cover_image,
artistName: o.artist_name,
artistId: o.artist_id,
mediaUrl: o.media_file,
duration: o.duration,
trackId: o.id
}, !1)
}
var a = new Set(e.map(function (e) {
return e.track.id
}));
Favorite.persist_tracks_not_on_server(n, a)
}.bind(this),
error: function () {
}.bind(this)
})
}
/*
While local storage still has user information.
Clean up login stuff to avoid confusion.
*/
if(Session.get_user_id() !== null){
Helpers.hide_login_registration();
Favorite.scrub_duration_field();
Favorite.sync_favorites();
//Let's not send these just yet shall we?
//GAWrapper.send_event('registration', 'mod-remember-me', t.email)
}
var targetFns = {};
//Modifications to the Player.render method.
if(Player.prototype.render){
/*
Adds a direct link to the currently playing song.
This way it can be shared on any other media besides Facebook.
Currently the FB button is also broken for me, so I opted to replace it.
*/
var orig_render = Player.prototype.render;
Player.prototype.render = function(){
//Update methods on existing object.
for(var fn in targetFns){
if(targetFns[fn] != this[fn]){
console.log('[MOD] Runtime swap for', fn);
targetFns[fn] = targetFns[fn].bind(this);
this[fn] = targetFns[fn];
}
}
//Call the original render method.
var out = orig_render.call(this);
//Find the share button wrapper.
var vc = out //"player_control_wrapper"
.props.children[0] //"player_top_image"
.props.children[0] //"player_control"
.props.children[1] //"controls"
.props.children[1] //"video-controls"
var btns = vc.props.children[3] //"album-int-btns"
//Replace FB share with direct link.
btns.props.children = React.createElement('a', {href: "/tracks/"+this.state.trackId+"#track/"+this.state.trackId, target:"_blank"}, "Direct link");
//Use the render method to update the progress bar + current time instead of direct DOM writes.
this.video = this.video || document.getElementById("video");
var prog = this.video ? 100 / this.video.duration * this.video.currentTime : 0;
var time = Helpers.track_formatted_time_for_seconds(this.video ? this.video.currentTime : 0);
//Find the progress bar to update width the React way.
var pr = vc.props.children[0] //"myProgress"
pr.props.children = React.createElement("div", {id: "myBar", style: {width: prog+'%'}});
//Find the current time to update seconds the React way.
var tt = vc.props.children[1] //"track_time"
tt.props.children[0] = React.createElement("div", {id: "time_current"}, time);
//Because we're using numbers now for the duration, render with formatter.
tt.props.children[1] = React.createElement('div', {id: 'time_end'}, Helpers.track_formatted_time(this.state.trackDuration));
//One shouldn't be using jQuery to manage classes.
var likeG = vc
.props.children[2] //"control-btns"
.props.children[4] //"like"
.props.children[0] //"album_like_btn"
.props.children //<g>
//This will add the active class when track is favorited.
likeG.props.children = React.cloneElement(likeG.props.children, {
className: ['st0', this.state.trackIsFavorited ? 'active' : ''].join(' ')
});
return out;
}
}
//Modifications to the Player.audio_onTimeUpdate method.
if(Player.prototype.audio_onTimeUpdate){
/*
The existing audio_onTimeUpdate method causes a major CPU leak.
This method is called between 15-250ms while the HTML5 Media element plays.
Earbits would call setInterval(fn, 500) every time this event fires.
These intervals were not cleared, so over time a HUGE amount of intervals
are trying to do direct DOM read and write operations, ruining performance.
The fix I use here is not a React best practice, but should perform well.
And more importantly, does not degrade in performance over time.
Using this.forceUpdate() the component executes the modified render().
Render uses this.video.currentTime to obtain current progress.
I'm using this instead of the recommended prop/state based values
as React may batch updates, causing visual stutter in the progress bar.
*/
Player.prototype.audio_onTimeUpdate = targetFns.onTimeUpdate = function(e){
this.forceUpdate();
}
}
/*
The various different play methods on the Player class are a mess.
Because the DRY principle was not applied, some play methods break while
others work. Simply because of inconsistent implementations.
This section attempts to normalize the implementation differences to fix
the various bugs it causes.
*/
Player.prototype.play_normalized = function(track, context){
var ensureNumberDuration = function(t){
if(typeof t == 'string')
return Helpers.formatted_time_to_int(t);
return t
}
//Ensure we have the state-like format we're expecting.
n = {
context: context,
artistId: track.artist_id || track.artistId,
artistName: track.artist_name || track.artistName,
bio: (track.artist && track.artist.biography) || track.artistBio || track.bio,
albumName: track.album_name || track.albumName,
trackId: track.id || track.track_id || track.trackId,
trackName: track.name || track.trackName,
trackSlug: track.slug || track.trackSlug,
//Duration as a *number* please.
trackDuration: ensureNumberDuration(track.duration) || ensureNumberDuration(track.trackDuration),
coverImage: track.cover_image || track.coverImage,
featureImage: track.feature_image || track.featureImage,
mediaUrl: track.media_file || track.mediaUrl,
//fb_share_button: Adding a React component on the state is weird. Please use render().
}
//Highligh the like without jQuery.
n.trackIsFavorited = Favorite.exists({trackId: n.trackId});
console.log('[MOD] Playing', n.artistName, '-', n.trackName, n.trackId);
//Quirk: directly set media properties to know which src we'll play().
if(this.video = this.video || document.getElementById("video")){
this.video.src = n.mediaUrl;
this.video.play();
}
//Do the analytics stuff as before.
GAWrapper.send_event('track', 'track-started-from-' + n.context, n.trackId + ' - ' + n.trackName),
firehose_fire_event('track_started', {
track_id: n.trackId,
collection_type: n.context,
collection_id: this.state.collectionId || this.props.collectionId
});
//Commit to state.
this.setState(n);
}
if(Player.prototype.fetch_and_play_track){
Player.prototype.fetch_and_play_track = targetFns.fetch_and_play_track = function(e, t){
var c = this.currentContext(e.context);
switch(c) {
case 'favorite':
t ? this.play_normalized(Favorite.get_next_track(this.current_track_id()), c) : this.play_normalized(e, c);
break;
case 'album':
this.play_normalized(e, c);
break;
case 'artist':
case 'recommended-artist':
this.play_artist({
artistId: e.artistId,
artistBio: e.artistBio
}, c);
break;
default:
this.play_collection(e.collectionId);
break;
}
}
}
function retryRequest(url, data, success){
this.serverRequest = $.ajax({
url: url,
data: data,
dataType: 'jsonp',
tryCount: 0,
retryLimit: 3,
success: success.bind(this),
error: function() {
this.tryCount++,
SleepTimer.run(0.5),
$.ajax(this)
}
});
}
if(Player.prototype.play_collection){
Player.prototype.play_collection = targetFns.play_collection = function(cid){
GAWrapper.send_event('collection', 'started', cid);
var url = window.api_core_url + '/api/v1/stream.json?collection_id=' + cid + '&client_token=' + client_token();
retryRequest.call(this, url,
{client_token: client_token()},
function(e){
RecentlyPlayedTrack.add(e.track);
this.play_normalized(e.track, 'collection');
}
);
}
}
if(Player.prototype.play_artist){
Player.prototype.play_artist = targetFns.play_artist = function(a){
GAWrapper.send_event('artist', 'started', a.artistId)
var url = window.api_core_url + '/v1/artists/' + a.artistId + '/retrieve_tracks';
retryRequest.call(this, url,
{client_token: client_token()},
function(e){
// RecentlyPlayedTrack.add(e[0]);
this.play_normalized(
_extends(e[0], {
artistId: a.artistId,
bio: a.artistBio
}),
'artist'
);
}
);
}
}
if(Player.prototype.highlight_favorited_if_needed){
Player.prototype.highlight_favorited_if_needed = targetFns.highlight_favorited_if_needed = function(){
console.log('[MOD] Player.highlight_favorited_if_needed is deprecated');
}
}
//Fixes a bug where the artist info wouldn't fully update when the artist page is already shown.
if(ArtistAbout.prototype.componentDidUpdate){
var updateAll = function(){
this.fetch_artist_info();
this.fetch_and_display_albums();
this.fetch_and_display_related_recommendations();
GAWrapper.send_event('artist', 'artist-view-info', this.props.artistId);
};
ArtistAbout.prototype.componentDidMount = updateAll;
ArtistAbout.prototype.componentDidUpdate = function(e) {
if(e.artistId != this.props.artistId){
updateAll.call(this);
}
}
delete ArtistAbout.prototype.componentWillUpdate;
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment