// ==UserScript==
// @name Youtube outro skip
// @namespace http://tampermonkey.net/
// @version 2.0
// @description Set outro for any youtube channel and will automatically skip to next video when time is reached.
// @author Daile Alimo
// @match https://www.youtube.com/*
// @grant GM_setValue
// @grant GM_getValue
// @require http://code.jquery.com/jquery-latest.js
// ==/UserScript==
/* globals $ whenReady */
const version = 2.0;
const debug = true;
const log = function(line) {
if (debug) {
console.log(line);
}
}
// whenReady - Keep checking the DOM until these given selectors are found.
// Invokes a callback function on complete that contains an object containing the JQuery element(s) for the given selectors accessable with aliases if given.
//
// selectors[] - Each selector to await.
// aliases[] - An alias for each selector/mutator.
// mutators{} - Associative array/object that given alias as key and function as value and selector as arguments returns a calculated result in place of its selector.
// callback - Function that is called when all selectors, containing each selector or its mutators returned value if applicable.
// error - Function that is called when an error such as retries exceeded occurs.
// maxRetries - The total number of times the whenReady will recur before calling the error function.
const whenReady = function({selectors = [], aliases = [], mutators = {}, callback = (selectors = {}), error, maxRetries = 5}) {
let ready = {};
let found = 0;
for(let i in selectors){
let $sel = $(selectors[i]);
if ($sel.length) {
let index = aliases[i] ? aliases[i]: i;
if (mutators[index]) {
ready[index] = mutators[index]($sel);
if (ready[index]){
found++;
}
} else {
ready[index] = $sel;
found++;
}
}
}
if (found === selectors.length) {
return callback(ready);
}
setTimeout(function(){
if (maxRetries >= 1) {
return whenReady({
selectors: selectors,
aliases: aliases,
mutators: mutators,
callback: callback,
maxRetries: --maxRetries
});
}
if (error !== undefined) {
error("max retries exceeded");
}
}, 500);
};
//
let initializationRequired = false;
const destroy = function(afterDetroyed){
//
// make sure we have no events binded, in the case fn() was called by interval on URL change
// this will ensure that we can create clean controls for the current playlist without accidentally
// having events persisting in the background.
//
log("destroying..");
if ($(".video-stream").unbind()) {log("unbinding .video-stream");}
if ($("#set-outro").unbind()){log("unbinding #set-outro");}
if ($("#outro-controls").remove()){log("removed controls");}
if ($("#outro-bar").remove()){log("removed progressbar");}
initializationRequired = false;
afterDetroyed();
}
// validateChannel - ensure we get a channel name out of the channel name element
const validateChannel = function(selector) {
let channel = selector.first().text();
log("validating channel: " + channel);
if (channel === "") {
return false;
}
return channel;
}
//
const progressBar_ID = "progress-bar";
// add indicators to the progress bar.
const setupProgressBar = function(selector) {
destroyProgressBar();
// add intro indicator to progress bar
selector.prepend(
$("<div id='" + progressBar_ID + "-intro'>").addClass("ytp-load-progress").css({
"left": "0%",
"transform": "scaleX(0)",
})
);
// add outro indicator to progress bar
selector.prepend(
$("<div id='" + progressBar_ID + "-outro'>").addClass("ytp-load-progress").css({
"left": "100%",
"transform": "scaleX(0)",
})
);
return [progressBar_ID + "-intro", progressBar_ID + "-outro"];
}
// destroy the indicators added to the progressbar.
const destroyProgressBar = function() {
if($("#" + progressBar_ID + "-intro").remove()){log("removed intro bar");}
if($("#" + progressBar_ID + "-outro").remove()){log("removed outro bar");}
}
// create the indecators on the progressbar.
const createProgressBars = function(intro, outro, duration) {
// update the intro progress bar
let introBar = $("#" + progressBar_ID + "-intro");
var introFraction = intro / duration;
introBar.css({
"left": "0%",
"transform": "scaleX(" + introFraction + ")",
"background-color": "green",
});
// update the outro progress bar
let outroBar = $("#" + progressBar_ID + "-outro");
var outroFraction = outro / duration;
outroBar.css({
"left": (100 - (outroBar * 100)) + "%",
"transform": "scaleX(" + outroFraction + ")",
"background-color": "green",
});
}
//
const controlUI_ID = "outro-controls";
const introTime_ID = "intro-set";
const outroTime_ID = "outro-set";
const introLen_ID = "intro-length";
const outroLen_ID = "outro-length";
const channelTxt_ID = "channel_txt";
const apply_ID = "apply";
const setupControls = function(selector) {
destroyControls();
return selector.prepend(
$("<div id='" + controlUI_ID + "'>").append(
$("<h3>Youtube Skip Outro Controller " + version + "</h3>")
).append(
$("<input type='number' min='0' id='" + introLen_ID + "' placeholder='loading channel'/>")
).append(
$("<input type='number' min='0' id='" + outroLen_ID + "' placeholder='loading channel'/>")
).append(
$("<button id='" + apply_ID + "'>apply</button>")
).append(
$("<div><span id='channel_txt'>loading</span> intro set: <span id='" + introTime_ID + "'>0</span> seconds outro set: <span id='" + outroTime_ID + "'>0</span> seconds</div>").css({
"padding": "2px",
})
).css({
"margin": "2px",
"textAlign": "right",
"color": "#666666",
})
);
}
const destroyControls = function(){
if($("#" + controlUI_ID).remove()){log("removed controls");}
}
const updateControls = ({introPlaceholderTxt, outroPlaceholderTxt, channelTxt, introTxt, outroTxt}) => {
if (introPlaceholderTxt) {
$("#" + introLen_ID).attr("placeholder", introPlaceholderTxt);
}
if (outroPlaceholderTxt) {
$("#" + outroLen_ID).attr("placeholder", outroPlaceholderTxt);
}
if (channelTxt) {
$("#" + channelTxt_ID).text(channelTxt);
}
if (introTxt) {
$("#" + introTime_ID).text(introTxt);
}
if (outroTxt) {
$("#" + outroTime_ID).text(outroTxt);
}
}
(function(setupAndBind) {
"use strict";
//
// detect page change hashchange not working
// so check every 3 seconds if current URL matches URL we started with.
// handle appropriately.
//
var l = document.URL;
if (l.includes("watch")) {
setupAndBind();
}
setInterval(function() {
// check initializationRequired flag and if set, destroy and reinitialize.
if (initializationRequired) {
log("forced to destroy");
destroy(function() {
log("rebuilding..");
setupAndBind();
});
}
if (l != document.URL){
l = document.URL;
if (l === "https://www.youtube.com/") {
// ignore home
destroy(function() {
log("complete destruction");
});
} else if (l.includes("watch")) {
log("channel changed");
initializationRequired = true
}
}
}, 3000);
})(function() {
// ignore home
if (document.URL === "https://www.youtube.com/"){
log("ignoring home");
return;
}
//
whenReady({ // .ytp-progress-list
selectors: [".video-stream", "#primary > #primary-inner", ".ytp-progress-bar", "#meta-contents #text.ytd-channel-name,.ytp-ce-channel-title > a"],
aliases: ["stream", "container", "progressBar", "channel"],
mutators: {
"container": setupControls,
"progressBar": setupProgressBar,
"channel": validateChannel,
},
callback: function(selectors) {
//
let channel = selectors.channel;
let introTargetId = channel.split(" ").join("_") + "-intro";
let outroTargetId = channel.split(" ").join("_") + "-outro";
log("loaded channel: " + channel);
//
var loadedIntroSetInSeconds = GM_getValue(introTargetId) || 0;
var loadedOutroSetInSeconds = GM_getValue(outroTargetId) || 0;
log("intro set: " + loadedIntroSetInSeconds);
log("outro set: " + loadedOutroSetInSeconds);
//
updateControls({
introPlaceholderTxt: (loadedIntroSetInSeconds <= 0)? "Set intro here..": loadedIntroSetInSeconds,
outroPlaceholderTxt: (loadedOutroSetInSeconds <= 0)? "Set outro here..": loadedOutroSetInSeconds,
channelTxt: channel,
introTxt: loadedIntroSetInSeconds,
outroTxt: loadedOutroSetInSeconds,
});
//
//
const bindToStream = function(){
// hook video timeupdate, wait for outro and hit next button when time reached
// if update time less than intro, skip to intro time
log("binding events");
let progressBarDone = false;
var loadedIntroSetInSeconds = GM_getValue(introTargetId) || 0;
var loadedOutroSetInSeconds = GM_getValue(outroTargetId) || 0;
log("intro set: " + loadedIntroSetInSeconds);
log("outro set: " + loadedOutroSetInSeconds);
//
// set duration here and call writeProgressBars
selectors.stream.unbind("timeupdate").on("timeupdate", function(e){
var currentTime = this.currentTime;
var duration = this.duration;
//
if (duration && !progressBarDone) {
progressBarDone = true;
createProgressBars(loadedIntroSetInSeconds, loadedOutroSetInSeconds, duration);
}
// If current time less than intro, skip past intro.
if(currentTime < loadedIntroSetInSeconds) {
this.currentTime = loadedIntroSetInSeconds;
}
// If current time greater or equal to outro, click next button.
if(currentTime >= duration - loadedOutroSetInSeconds){
$(".ytp-next-button")[0].click();
}
});
};
//
// handle apply outro in seconds
//
log("bind to click");
$("#" + apply_ID).on("click", function(e) {
log("updating intro/outro skip");
var introSeconds = $("#" + introLen_ID).val().toString();
var outroSeconds = $("#" + outroLen_ID).val().toString();
if(introSeconds && introSeconds != "" && parseInt(introSeconds) != NaN){
if (introSeconds < 0) {
introSeconds = 0;
}
// save outro in local storage
GM_setValue(introTargetId, introSeconds);
}
if(outroSeconds && outroSeconds != "" && parseInt(outroSeconds) != NaN){
if (outroSeconds < 0) {
outroSeconds = 0;
}
// save outro in local storage
GM_setValue(outroTargetId, outroSeconds);
}
// update the intro/outro time on the controls
updateControls({
introTxt: introSeconds,
outroTxt: outroSeconds
});
bindToStream();
});
bindToStream();
},
error: function(e) {
log(e);
destroyControls();
destroyProgressBar();
},
});
});