Youtube Automatic BS Skip

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

目前为 2023-11-02 提交的版本。查看 最新版本

// ==UserScript==
// @name         Youtube Automatic BS Skip
// @namespace    https://gf.qytechs.cn/en/scripts/392459-youtube-automatic-bs-skip
// @version      2.9.6
// @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      https://code.jquery.com/jquery-3.7.1.min.js
// ==/UserScript==
//
/* globals $ whenReady  */

const app = "YouTube Automatic BS Skip";
const version = '2.9.6';
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 instantNextOnFinish = "instant-next";
const apply_ID = "apply";

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;
            log(`found selector ${selectors[i]}`);
            if (mutators[index]) {
                ready[index] = mutators[index]($sel);
                if (ready[index]){
                    found++;
                }
            } else {
                ready[index] = $sel;
                found++;
            }
        }
    }

    log(`found ${found} of ${selectors.length}`);
    if (found === selectors.length) {
        log("all selectors found");
        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">
                      <label for="${controlUI_ID}-outro-action-group">Action on outro:</label>
                      <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>
                   <div class="py" >
                      <label for="${controlUI_ID}-ended-action-group">Action on finish:</label>
                      <fieldset id="${controlUI_ID}-ended-action-group" class="d-flex">
                         <div style="margin: auto; text-align: right;">
                             <label for="${instantNextOnFinish}">Instantly play next</label>
                             <input type="checkbox" name="outro-action-group" id="${instantNextOnFinish}">
                         </div>
                      </fieldset>
                   </div>
                </div>
             <tp-yt-paper-button id="${apply_ID}" class="style-scope py 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: .5em;
}
.py {
    padding: .5em 0em;
}
`);

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");
        (actions.onFinish)? $(`#${instantNextOnFinish}`).attr("checked", "checked") : $(`#${instantNextOnFinish}`).removeAttr("checked");
    }
    $(`#${modal_ID}`).removeClass("show");
};

const destroy = function(afterDetroyed){
    log("destroying..");
    destroyProgressBar();
    destroyControls();
    log("destruction complete");
    afterDetroyed();
};

// No longer on page loading to find channel name.
// Instead the channel name is loaded by binding to Youtubes yt-page-data-fetched event.
var channel = '';

(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)}
    }

    // Quick channel loading - Hook into Youtube's events.
    const ytapp = document.querySelector('body > ytd-app');
    ytapp.addEventListener("yt-page-data-fetched", (e) => {
        const ownerChannelName = e.detail.pageData.playerResponse.microformat.playerMicroformatRenderer.ownerChannelName;
        log("channel name set: " + ownerChannelName);
        if (ownerChannelName !== channel) {
            channel = ownerChannelName
        }
        destroy(function() {
            setTimeout(() => {
                log("rebuilding..");
                setupAndBind();
            }, 500)
        });
    });
})(function() {
    // ignore home
    if (document.URL === "https://www.youtube.com/"){
        log("ignoring home");
        return;
    }

    whenReady({
        selectors: [".video-stream", ".ytp-right-controls", ".ytp-progress-bar"],
        aliases: ["stream", "container", "progressBar"],
        mutators: {
            "container": setupControls,
            "progressBar": setupProgressBar,
        },
        callback: async function(selectors) {
            var storeId = channel.split(" ").join("_");
            var introTargetId = storeId + "-intro";
            var outroTargetId = storeId + "-outro";
            var outroActionId = storeId + "-outro-action";
            var finishedActionId = storeId + "-finished-action";
            var loadedIntroSetInSeconds = await GM.getValue(introTargetId, 0);
            var loadedOutroSetInSeconds = await GM.getValue(outroTargetId, 0);
            var playNextOnOutro = await GM.getValue(outroActionId, true);
            var instantNextOnFinished = await GM.getValue(finishedActionId, true);
            //
            log({
                [introTargetId]: loadedIntroSetInSeconds,
                [outroTargetId]: loadedOutroSetInSeconds,
                [finishedActionId]: instantNextOnFinished
            });
            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,
                    onFinish: instantNextOnFinished,
                },
            });

            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);
                var ready = true;
                //
                // happens when a video's playback is aborted
                selectors.stream.unbind("abort").on("abort", function(e){
                    log("stream abort");
                    ready = false;
                });
                selectors.stream.unbind("ended").on("ended", function(e){
                    log("stream ended");
                    ready = false;
                    if (instantNextOnFinished) {
                        $(".ytp-next-button")[0].click();
                    }
                });
                //
                // set duration here and call writeProgressBars
                selectors.stream.unbind("timeupdate").on("timeupdate", function(e){
                    log("bind timeupdate");
                    // 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&& ready) {
                        log("intro skipped", loadedIntroSetInSeconds);
                        this.currentTime = loadedIntroSetInSeconds;
                    }
                    // If current time greater or equal to outro, click next button or pause the stream.
                    if(currentTime >= duration - loadedOutroSetInSeconds && loadedOutroSetInSeconds > 0){
                        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}`).unbind('click').on('click', () => {
                log("toggle modal");
                modal.toggleClass("show");
            });
            $(`#${modal_ID}-escape`).unbind('click').on('click', () => {
                log("clicked outside of modal");
                modal.removeClass("show");
            });
            // Apply button click listener
            $(`#${apply_ID}`).unbind('click').on("click", function() {
                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}`).unbind('click').on("change", function(){
                // pause on outro
                playNextOnOutro=false
                GM.setValue(outroActionId, playNextOnOutro);
            });
            // Next on outro radio button change
            $(`#${nextOnOutro}`).unbind('click').on("change", function(){
                // skip to next on outro
                playNextOnOutro=true
                GM.setValue(outroActionId, playNextOnOutro);
            });
            // Next on finish checkbox changed
            $(`#${instantNextOnFinish}`).unbind('click').on("change", function(e){
                // instantly play next video when finished.
                instantNextOnFinished=e.target.checked
                GM.setValue(finishedActionId, instantNextOnFinished);
            });
            //
            bindToStream();
        },
        error: function(e) {
            log(e);
            destroyControls();
            destroyProgressBar();
        },
    });
});

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址