Youtube Automatic BS Skip

A script to deal with the automatic skipping of fixed length intros/outros for your favourite Youtube channels.

当前为 2022-05-10 提交的版本,查看 最新版本

您需要先安装一个扩展,例如 篡改猴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    https://greasyfork.org/en/scripts/392459-youtube-automatic-bs-skip
// @version      2.9
// @description  A script to deal with the automatic skipping of fixed length intros/outros for your favourite Youtube channels.
// @author       Daile Alimo
// @license MIT
// @match        https://www.youtube.com/*
// @icon         https://www.google.com/s2/favicons?sz=64&domain=youtube.com
// @grant        GM.setValue
// @grant        GM.getValue
// @grant        GM.addStyle
// @require      http://code.jquery.com/jquery-latest.js
// ==/UserScript==
 
/* globals $ whenReady  */
 
const app = "YouTube Automatic BS Skip";
const version = 2.9;
const debug = false;
 
// Elements
const controlUI_ID = "outro-controls";
const modal_ID = "modal";
const progressBar_ID = "progress-bar";
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";
const apply_ID = "apply";
 
const log = function(line) {
    if (debug) {
        console.log(line);
    }
};
 
let initializationRequired = false;
 
// 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);
};
// 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;
};
//
// add indicators to the progress bar.
const setupProgressBar = function(selector) {
    log('called setupProgressBar');
    // add intro indicator to progress bar
    if (document.getElementById(`${progressBar_ID}-intro`) == null){
        log('created intro indicator');
        selector.prepend(
            $(`<div id="${progressBar_ID}-intro">`).addClass("ytp-load-progress").css({
                "left": "0%",
                "transform": "scaleX(0)",
            })
        );
    }
    // add outro indicator to progress bar
    if (document.getElementById(`${progressBar_ID}-outro`) == null) {
        log('created outro indicator');
        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() {
    log("destroying progressbars");
    if($(`#${progressBar_ID}-intro`).remove()){log("removed intro bar");}
    if($(`#${progressBar_ID}-outro`).remove()){log("removed outro bar");}
};
// update the indecators on the progressbar.
const updateProgressbars = 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 setupControls = function(selector) {
    // Its easier to modify if we don't chain jquery.append($()) to build the html components
    var controls = document.getElementById(controlUI_ID);
    if (controls == null) {
        log('adding controls to video');
        controls = selector.prepend(`
        <button id="${controlUI_ID}" class="ytp-button" 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>
        `);
    }
    if (document.getElementById(modal_ID) == null) {
        log('adding modal to DOM');
        $('body').append(`
       <div id="${modal_ID}">
          <div id="${modal_ID}-escape"></div>
             <div id="${modal_ID}-content">
                <div id="${channelTxt_ID}">Loading Channel</div>
                    <h2 id="${controlUI_ID}-title" class="d-flex justify-space-between">YouTube Automatic BS Skip ${version}
                       <a href="https://www.buymeacoffee.com/JustDai" target="_blank">
                          <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"></path></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"></path></g>
                          </svg>
                       </a>
                    </h2>
                   <div id="${controlUI_ID}-control-wrapper">
                      <div class="w-100 d-flex justify-space-around align-center">
                      <label for="${introLen_ID}">Intro</label>
                      <input type="number" min="0" id="${introLen_ID}" placeholder="unset" class="input">
                   </div>
                   <div class="w-100 d-flex justify-space-around align-center">
                      <label for="${outroLen_ID}">Outro</label>
                      <input type="number" min="0" id="${outroLen_ID}" placeholder="unset" class="input">
                   </div>
                   <div class="pa">
                      <div>
                      <label for="${controlUI_ID}-outro-action-group">Action on outro:</label>
                   </div>
                   <fieldset id="${controlUI_ID}-outro-action-group" class="d-flex">
                      <div>
                          <label for="${pauseOnOutro}">Pause Video</label>
                          <input type="radio" name="outro-action-group" id="${pauseOnOutro}">
                      </div>
                      <div>
                          <label for="${nextOnOutro}">Play Next Video</label>
                          <input type="radio" name="outro-action-group" id="${nextOnOutro}" checked="checked">
                      </div>
                   </fieldset>
                </div>
             <tp-yt-paper-button id="${apply_ID}" class="style-scope ytd-video-secondary-info-renderer d-flex justify-center align-center" style-target="host" role="button" elevation="3" aria-disabled="false">${apply_ID}</tp-yt-paper-button>
          </div>
       </div>
    </div>`
      );
    }
    return controls;
};
// Write the CSS rules to the DOM
GM.addStyle(`
#${modal_ID}-escape {
    position: fixed;
    left: 0;
    top: 0;
    width: 100vw;
    height: 100vh;
    z-index: 1000;
}
#${modal_ID} {
    display: none;
    position: fixed;
    left: 0;
    top: 0;
    width: 100vw;
    height: 100vh;
    z-index: 999;
    background: rgba(0,0,0,.8);
}
#${modal_ID}.show {
    display: flex;
}
#${modal_ID}-content {
    margin: auto;
    width: 30%;
    height: auto;
    background-color: var(--yt-live-chat-action-panel-background-color);
    border-radius: 6px 6px 6px;
    border: 1px solid white;
    padding: 15px;
    color: white;
    z-index: 1001;
}
#${introLen_ID},#${outroLen_ID} {
    font-size: 1.2em;
    padding: .4em;
    border-radius: .5em;
    border: none;
    width: 80%
}
#${apply_ID} {
    position: relative;
    border: 1px solid white;
    transition: background-color .2s ease-in-out
}
#${apply_ID}:hover {
    background-color: rgba(255,255,255,0.3);
}
#${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}-outro-action-group {
    padding: .5em;
}
#${controlUI_ID}-outro-action-group > div {
 display: block;
 margin: auto;
 text-align-last: justify;
}
#${controlUI_ID}-control-wrapper > * {
    padding-top: 1em;
}
#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;
}
#${channelTxt_ID} {
    position: relative;
    top: -3.5em;
    margin-bottom: -1.5em;
}
.w-100 {
    width: 100% !important;
}
.input {
    padding: .2em;
}
.d-flex {
    display: flex;
}
.justify-center {
    justify-content: center;
}
.justify-space-around {
    justify-content: space-around;
}
.justify-space-between {
    justify-content: space-between;
}
.align-center {
    align-items: center;
}
.pa {
    padding: 1em;
}
`);
const destroyControls = function(){
    log("destroying controls");
    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 (introTxt) {
        $(`#${introTime_ID}`).text(introTxt);
    }
    if (outroTxt) {
        $(`#${outroTime_ID}`).text(outroTxt);
    }
    if (channelTxt) {
        $(`#${channelTxt_ID}`).text(channelTxt);
    }
    if (actions) {
        (actions.outro)? $(`#${nextOnOutro}`).attr("checked", "checked") : $(`#${pauseOnOutro}`).attr("checked", "checked");
    }
    $(`#${modal_ID}`).removeClass("show");
};
const destroy = function(afterDetroyed){
    log("destroying..");
    destroyProgressBar();
    destroyControls();
    initializationRequired = false;
    log("destruction complete");
    afterDetroyed();
};
(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")) {
        log("on watch - calling setupAndBind");
        try {
            setupAndBind();
        } catch (e) {log(e.message)}
    }
    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
            }
        }
    }, 1500);
})(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)? "unset": loadedIntroSetInSeconds,
                outroPlaceholderTxt: (loadedOutroSetInSeconds <= 0)? "unset": 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;
                        updateProgressbars(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();
                        }
                    }
                });
            };
            const modal = $(`#${modal_ID}`);
            //
            // handle apply outro in seconds
            //
            log("bind to click");
            // Control popup toggle button click listener
            $(`#${controlUI_ID}`).on('click', () => {
                log("toggle modal");
                modal.toggleClass("show");
            });
            $(`#${modal_ID}-escape`).on('click', () => {
                log("clicked outside of modal");
                modal.removeClass("show");
            });
            // Apply button click listener
            $(`#${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();
            });
            // Pause on outro radio button change
            $(`#${pauseOnOutro}`).on("change", function(){
                // pause on outro
                playNextOnOutro = false;
                GM.setValue(outroAction, playNextOnOutro);
            });
            // Next on outro radio button change
            $(`#${nextOnOutro}`).on("change", function(){
                // skip to next on outro
                playNextOnOutro = true;
                GM.setValue(outroAction, playNextOnOutro);
            });
            //
            bindToStream();
        },
        error: function(e) {
            log(e);
            destroyControls();
            destroyProgressBar();
        },
    });
});