Youtube Automatic BS Skip

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

当前为 2023-11-10 提交的版本,查看 最新版本

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

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

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

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴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.7a
// @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-latest.js
// ==/UserScript==
//
/* globals $ whenReady  */

const app = "YouTube Automatic BS Skip";
const version = '2.9.7a';
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>
        `);
    } else {
        log('controls detected');
    }
    if (document.getElementById(modal_ID) == null) {
        log('adding controls 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;
};

const destroyControls = function(){
    log("destroying controls");
    document.querySelectorAll(`#${controlUI_ID}`).forEach(e => e.remove());
    document.querySelectorAll(`#${modal_ID}`).forEach(e => e.remove());
};

const updateControls = ({introPlaceholderTxt, outroPlaceholderTxt, channelTxt, introTxt, outroTxt, actions}) => {
    if (introPlaceholderTxt) {
        $(`#${introLen_ID}`).val(introPlaceholderTxt);
    }
    if (outroPlaceholderTxt) {
        $(`#${outroLen_ID}`).val(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;
    setInterval(function() {
        if (l != document.URL){
            if (l === "https://www.youtube.com/") { // ignore home
                destroy(function() {
                    log("complete destruction");
                });
            }
        }
        l = document.URL;
    }, 200);

    // Quick channel loading - Hook into Youtube's events.
    const ytapp = document.querySelector('body > ytd-app');
    ytapp.addEventListener("yt-page-data-fetched", (e) => {
        const page = e.detail.pageData.page; // browse, watch
        log(page);
        if (page !== 'watch') {
            return destroy()
        }
        const ownerChannelName = e.detail.pageData.playerResponse.microformat.playerMicroformatRenderer.ownerChannelName;
        log("channel name set: " + ownerChannelName);
        if (ownerChannelName !== channel) {
            channel = ownerChannelName
        }
        destroy(
            () => setTimeout(setupAndBind, 1000)
        )
    });
})(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();
        },
    });
});

// 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);
    color: var(--yt-live-chat-secondary-text-color);
    border-radius: 6px 6px 6px;
    border: 1px solid white;
    padding: 15px;
    z-index: 1001;
}
#${introLen_ID},#${outroLen_ID} {
    font-size: 1.2em;
    padding: .4em;
    border-radius: .5em;
    border: 1px solid var(--yt-spec-inverted-background);
    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;
    color: white;
}
.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;
}
`);