Youtube Automatic BS Skip

Set outro for any youtube channel and will automatically skip to next video when time is reached.

当前为 2022-03-27 提交的版本,查看 最新版本

您需要先安装一个扩展,例如 篡改猴Greasemonkey暴力猴,之后才能安装此脚本。

You will need to install an extension such as Tampermonkey to install this script.

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴Userscripts ,之后才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。

您需要先安装用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name         Youtube Automatic BS Skip
// @namespace    http://tampermonkey.net/
// @version      2.6
// @description  Set outro for any youtube channel and will automatically skip to next video when time is reached.
// @author       Daile Alimo
// @license MIT
// @match        https://www.youtube.com/*
// @grant        GM.setValue
// @grant        GM.getValue
// @grant        GM.addStyle
// @require      http://code.jquery.com/jquery-latest.js
// @require      https://unpkg.com/micromodal/dist/micromodal.min.js
// ==/UserScript==

/* globals $ whenReady MicroModal */

const app = "YouTube Automatic BS Skip";
const version = 2.6;
const debug = false;

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 - (outroFraction * 100)}%`,
        "transform": `scaleX(${outroFraction})`,
        "background-color": "green",
    });
}
//
const controlUI_ID = "outro-controls";
const modal_ID = "modal";
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";
// actions
const pauseOnOutro = "pause-on-outro";
const nextOnOutro = "next-on-outro";
// end actions
const apply_ID = "apply";
const logoWidth = 94;
const logoHeight = 50;

console.log(MicroModal);

const setupControls = function(selector) {
    destroyControls();
    // Its easier to modify if we don't chain jquery.append($()) to build the html components
    //
    let controls = selector.prepend(`
        <button id="${controlUI_ID}" class="ytp-button" data-tooltip-target-id="ytp-autonav-toggle-button" style="" title="YABSS" aria-label="YABSS">
           <div class="ytp-autonav-toggle-button-container">
              <svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 0 24 24" width="24"><path d="M0 0h24v24H0z" fill="none"/><path fill="white" d="M19 9l1.25-2.75L23 5l-2.75-1.25L19 1l-1.25 2.75L15 5l2.75 1.25L19 9zm-7.5.5L9 4 6.5 9.5 1 12l5.5 2.5L9 20l2.5-5.5L17 12l-5.5-2.5zM19 15l-1.25 2.75L15 19l2.75 1.25L19 23l1.25-2.75L23 19l-2.75-1.25L19 15z"/></svg>
           </div>
        </button>
    `);
    $('body').append(`
       <div id="${modal_ID}">
          <header>
             <svg width="${logoWidth}" height="${logoHeight}" viewBox="-34 -10 452.5 112.5">
               <g fill="#ff0000" id="SvgjsG1230" featurekey="symbolFeature-0" transform="matrix(0.2018017853791305,0,0,0.2018017853791305,-13.823422298470438,0.17657770152956154)">
                 <g xmlns="http://www.w3.org/2000/svg">
                   <path d="M136.3,349.2L287,198.4c-7.1-2.6-12.3-9.4-12.3-17.4c0-10.4,8.4-18.8,18.8-18.8c8.1,0,14.8,5.1,17.4,12.3l20.4-20.4   c-3.3-2.8-6.5-5.6-10-8.1l26.1-63.2l-34.7-14.3l-25.2,61.1c-10.1-3-20.7-4.8-31.6-4.8c-10.9,0-21.5,1.8-31.6,4.8l-25.2-61.1   l-34.7,14.3l26.1,63.2c-18.7,13.1-34.3,31.6-45.7,53.7H87.3v37.5h43.5c-3,12-4.8,24.5-5.5,37.5H68.5v37.5h58.4   C128.8,325.2,132,337.5,136.3,349.2z M218.5,162.2c10.4,0,18.8,8.4,18.8,18.8c0,10.4-8.4,18.8-18.8,18.8s-18.8-8.4-18.8-18.8   C199.8,170.6,208.1,162.2,218.5,162.2z"></path><path d="M443.5,95.1L95.1,443.5h53l30.7-30.7c21.7,19.2,48.3,30.7,77.1,30.7c40.1,0,75.9-21.9,100-56.3h68.8v-37.5h-49.2   c4.4-11.8,7.6-24.4,9.6-37.5h58.4v-37.5h-56.8c-0.6-13-2.4-25.5-5.5-37.5h43.5v-37.5h-32.8l51.6-51.6V95.1z"></path>
                 </g>
               </g>
               <g fill="#ff0000" id="SvgjsG1231" featurekey="nameFeature-0" transform="matrix(3.452357175253672,0,0,3.452357175253672,96.00000164621218,-37.28546802849764)">
                 <path d="M10.8 11.399999999999999 l1.28 0 l-5.4 10.84 l0 17.76 l-1.28 0 l0 -17.76 l-5.4 -10.84 l1.28 0 l4.76 9.52 z M30.515 40 l-1.12 -4.52 l-10.96 0 l-1.12 4.52 l-1.2 0 l7.08 -28.6 l1.44 0 l7.08 28.6 l-1.2 0 z M18.715 34.28 l10.4 0 l-5.2 -20.88 z M39.63 20.88 c4.08 1.16 7.04 4.92 7.04 9.36 c0 5.4 -4.36 9.76 -9.76 9.76 l-1.16 0 l0 -28.6 l1.16 0 c2.84 0 5.12 2.28 5.12 5.12 c0 1.84 -0.96 3.44 -2.4 4.36 z M36.91 12.559999999999999 l0 7.92 c2.2 0 3.96 -1.76 3.96 -3.96 s-1.76 -3.96 -3.96 -3.96 z M36.91 38.84 c4.76 0 8.6 -3.88 8.6 -8.6 c0 -4.76 -3.84 -8.6 -8.6 -8.6 l0 17.2 l0 0 z M54.705 40.56 c-2.36 0 -4.6 -1.12 -6 -3.04 l0.92 -0.68 c1.2 1.6 3.08 2.56 5.08 2.56 c3.4 0 6.2 -2.8 6.2 -6.24 c0 -3.56 -2.72 -6.16 -5.36 -8.72 c-2.56 -2.4 -5.16 -4.92 -5.16 -8.24 c0 -2.96 2.44 -5.4 5.4 -5.4 c1.72 0 3.36 0.84 4.4 2.24 l-0.96 0.68 c-0.8 -1.08 -2.08 -1.76 -3.44 -1.76 c-2.32 0 -4.24 1.92 -4.24 4.24 c0 2.8 2.32 5.04 4.8 7.4 c2.8 2.72 5.72 5.52 5.72 9.56 c0 4.08 -3.32 7.4 -7.36 7.4 z M72.1 40.56 c-2.36 0 -4.6 -1.12 -6 -3.04 l0.92 -0.68 c1.2 1.6 3.08 2.56 5.08 2.56 c3.4 0 6.2 -2.8 6.2 -6.24 c0 -3.56 -2.72 -6.16 -5.36 -8.72 c-2.56 -2.4 -5.16 -4.92 -5.16 -8.24 c0 -2.96 2.44 -5.4 5.4 -5.4 c1.72 0 3.36 0.84 4.4 2.24 l-0.96 0.68 c-0.8 -1.08 -2.08 -1.76 -3.44 -1.76 c-2.32 0 -4.24 1.92 -4.24 4.24 c0 2.8 2.32 5.04 4.8 7.4 c2.8 2.72 5.72 5.52 5.72 9.56 c0 4.08 -3.32 7.4 -7.36 7.4 z"></path>
               </g>
             </svg>
          </header>
          <div id="modal-content">
            <h3 id="${controlUI_ID}-title">${app} v${version} <a href="https://www.buymeacoffee.com/JustDai" target="_blank" style="position: relative; top: -5px; float: right">
              <svg xmlns="http://www.w3.org/2000/svg" enable-background="new 0 0 24 24" height="24" viewBox="0 0 24 24" width="24"><g><path d="M0,0h24v24H0V0z" fill="none"/></g>
              <g fill="#ffffff"><path d="M18.5,3H6C4.9,3,4,3.9,4,5v5.71c0,3.83,2.95,7.18,6.78,7.29c3.96,0.12,7.22-3.06,7.22-7v-1h0.5c1.93,0,3.5-1.57,3.5-3.5 S20.43,3,18.5,3z M16,5v3H6V5H16z M18.5,8H18V5h0.5C19.33,5,20,5.67,20,6.5S19.33,8,18.5,8z M4,19h16v2H4V19z"/></g></svg>
              </a>
            </h3>
            <div id="${controlUI_ID}-control-wrapper">
            <input type="number" min="0" id="${introLen_ID}" placeholder="loading channel"/>
            <input type="number" min="0" id="${outroLen_ID}" placeholder="loading channel"/>
            <fieldset id="${controlUI_ID}-outro-action-group">
                <div>
                 <label for="${pauseOnOutro}">Pause</label>
                 <input type="radio" name="outro-action-group" id="${pauseOnOutro}" />
                </div>
                <div>
                 <label for="${nextOnOutro}">Skip to next</label>
                 <input type="radio" name="outro-action-group" id="${nextOnOutro}" checked />
                <div>
            </fieldset>
            <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>
            <tp-yt-paper-button id="${apply_ID}" class="style-scope ytd-video-secondary-info-renderer">
               <span class="style-scope ytd-subscribe-button-renderer">Apply</span>
            </tp-yt-paper-button>
            </div>
          </div>
      </div>`
    );
    $(`#${controlUI_ID}`).on('click', () => {
       console.log("toggle modal");
       $(`#${modal_ID}`).toggleClass("show");
    });
    return controls;
}
// Write the CSS rules to the DOM
GM.addStyle(`
#${controlUI_ID}-control-wrapper {
    display: block;
    clear: both;
}
#${controlUI_ID}-control-wrapper > * {
    display: inline-block;
    margin-bottom: 1em;
}
#${modal_ID} {
    display: none;
    min-width: 480px;
    max-width: 510px;
    box-shadow: 15px 19px 23px;
    border-radius: 8px;
    margin: auto;
    position: fixed;
    left: 18%;
    top: 20%;
    height: 14%;
    width: 60%;
}
#${modal_ID}.show {
    display: flex;
}
#modal-content {
    width: 100%;
    background-color: var(--yt-live-chat-moderator-color);
    border-radius: 6px 6px 6px;
    padding: 15px;
    color: white;
}
#${apply_ID} {
    position: relative;
}
#${controlUI_ID} {
    height: 100%;
    padding: 0;
    margin: 0;
    bottom: 45%;
    position: relative;
}
#${controlUI_ID} svg {
    position: relative;
    top: 20%;
    left: 20%;
}
#${controlUI_ID}-panel {
 margin-right: 1em;
 vertical-align:top
}
#${controlUI_ID} > * {
 display: inline-block;
 max-height: 100%;
}
#${controlUI_ID}-title {
 padding: 2px;
}
#${controlUI_ID}-logo {
 display: inline-block;
 background-color: #c00;
 border-radius: 2px;
 cursor: pointer;
 text-align: center;
 color: white;
}
#${controlUI_ID}-logo {}
#${controlUI_ID}-logo svg {
 max-height: 4em;
 opacity: .5;
}
#${controlUI_ID}-logo svg:hover {
 opacity: 1;
}
#${controlUI_ID}-outro-action-group {
    float: right;
    padding: .5em;
}
#${controlUI_ID}-outro-action-group > div {
 display: block;
 margin: auto;
 text-align-last: justify;
}
#${controlUI_ID}-logo #expand-actions {
 opacity: .5;
 text-align: center;
 position: relative;
 top: -.5em;
}
#${controlUI_ID}-logo #expand-actions:hover {
 opacity: 1;
}
#action-radios {
  display: none;
}
#action-radios .actions{
  padding-left: 2px;
  text-align: left;
  background-color: black;
  color: white;
}
#${introLen_ID},#${outroLen_ID} {
 margin-right: 2px;
}
`);

const destroyControls = function(){
    $(`#${modal_ID}`).removeClass("show");

    if($(`#${controlUI_ID}`).remove()){log("removed controls");}
    if($(`#${modal_ID}`).remove()){log("removed modal");}
}
const updateControls = ({introPlaceholderTxt, outroPlaceholderTxt, channelTxt, introTxt, outroTxt, actions}) => {
    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);
    }
    if (actions) {
        (actions.outro)? $(`#${nextOnOutro}`).attr("checked", "checked") : $(`#${pauseOnOutro}`).attr("checked", "checked");
    }
    $(`#${modal_ID}`).removeClass("show");
}
(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", ".ytp-right-controls", ".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: async function(selectors) {
            //
            var channel = selectors.channel;
            var introTargetId = channel.split(" ").join("_") + "-intro";
            var outroTargetId = channel.split(" ").join("_") + "-outro";
            var outroAction = channel.split(" ").join("_") + "-outro-action";
            log("loaded channel: " + channel);
            //
            var loadedIntroSetInSeconds = await GM.getValue(introTargetId, 0);
            var loadedOutroSetInSeconds = await GM.getValue(outroTargetId, 0);
            var playNextOnOutro = await GM.getValue(outroAction, true);
            //
            log("intro set: " + loadedIntroSetInSeconds);
            log("outro set: " + loadedOutroSetInSeconds);
            log(`outro action: ${(playNextOnOutro)? "skip to next video": "pause"}`);
            //
            updateControls({
                introPlaceholderTxt: (loadedIntroSetInSeconds <= 0)? "Set intro here..": loadedIntroSetInSeconds,
                outroPlaceholderTxt: (loadedOutroSetInSeconds <= 0)? "Set outro here..": loadedOutroSetInSeconds,
                channelTxt: channel,
                introTxt: loadedIntroSetInSeconds,
                outroTxt: loadedOutroSetInSeconds,
                actions: {
                    outro: playNextOnOutro,
                },
            });
            //
            //
            const bindToStream = async 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;
                let paused = false;
                let loadedIntroSetInSeconds = await GM.getValue(introTargetId, 0);
                let loadedOutroSetInSeconds = await 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){
                    // use pause to prevent timeupdate after we have clicked pause button
                    // there is a slight delay from when pause button is clicked, to when the timeupdates are stopped.
                    if (paused) {
                        return setTimeout(1000, () => {paused = false});
                    }
                    //
                    let currentTime = this.currentTime;
                    let 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 or pause the stream.
                    if(currentTime >= duration - loadedOutroSetInSeconds){
                        if (playNextOnOutro) {
                            $(".ytp-next-button")[0].click();
                        } else {
                            paused = true;
                            $(".ytp-play-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();
            });
            // options
            $(`#${pauseOnOutro}`).on("change", function(){
                // pause on outro
                playNextOnOutro = false;
                GM.setValue(outroAction, playNextOnOutro);
            });
            $(`#${nextOnOutro}`).on("change", function(){
                // skip to next on outro
                playNextOnOutro = true;
                GM.setValue(outroAction, playNextOnOutro);
            });
            //
            bindToStream();
        },
        error: function(e) {
            log(e);
            destroyControls();
            destroyProgressBar();
        },
    });
});