Simple Sponsor Skipper Fixed

Skips annoying intros, sponsors and other YouTube segments using the SponsorBlock API. Fixed config page and safer runtime.

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==UserScript==
// @name        Simple Sponsor Skipper Fixed
// @author      mthsk, fixed version
// @homepage    https://codeberg.org/mthsk/userscripts/src/branch/master/simple-sponsor-skipper
// @match       *://m.youtube.com/*
// @match       *://youtu.be/*
// @match       *://www.youtube.com/*
// @match       *://www.youtube-nocookie.com/embed/*
// @match       *://odysee.com/*
// @match       *://yt.artemislena.eu/*
// @match       *://tube.cadence.moe/*
// @match       *://y.com.sb/*
// @match       *://invidious.esmailelbob.xyz/*
// @match       *://invidious.flokinet.to/*
// @match       *://inv.frail.com.br/*
// @match       *://invidious.garudalinux.org/*
// @match       *://invidious.kavin.rocks/*
// @match       *://inv.nadeko.net/*
// @match       *://invidious.namazso.eu/*
// @match       *://iv.nboeck.de/*
// @match       *://invidious.nerdvpn.de/*
// @match       *://youtube.owacon.moe/*
// @match       *://inv.pistasjis.net/*
// @match       *://invidious.projectsegfau.lt/*
// @match       *://inv.bp.projectsegfau.lt/*
// @match       *://inv.in.projectsegfau.lt/*
// @match       *://inv.us.projectsegfau.lt/*
// @match       *://vid.puffyan.us/*
// @match       *://invidious.sethforprivacy.com/*
// @match       *://invidious.slipfox.xyz/*
// @match       *://invidious.snopyta.org/*
// @match       *://inv.vern.cc/*
// @match       *://invidious.weblibre.org/*
// @match       *://youchu.be/*
// @match       *://yewtu.be/*
// @grant       GM.addElement
// @grant       GM.getValue
// @grant       GM.setValue
// @grant       GM.notification
// @grant       GM.openInTab
// @grant       GM.registerMenuCommand
// @grant       GM.xmlHttpRequest
// @allFrames   true
// @connect     sponsor.ajay.app
// @connect     sponsorblock.kavin.rocks
// @connect     sponsorblock.gleesh.net
// @connect     sb.theairplan.com
// @connect     *
// @require     https://greasemonkey.github.io/gm4-polyfill/gm4-polyfill.js
// @run-at      document-start
// @version     2026.05-fixed
// @license     AGPL-3.0-or-later
// @description Skips annoying intros, sponsors and other YouTube segments using the SponsorBlock API. Fixed config page and safer runtime.
// @namespace   https://greasyfork.org/users/751327
// ==/UserScript==

(async function () {
    "use strict";

    const SCRIPT_NAME = "Simple Sponsor Skipper";

    const DEFAULT_SETTINGS = {
        categories: [
            "preview",
            "sponsor",
            "outro",
            "music_offtopic",
            "selfpromo",
            "poi_highlight",
            "interaction",
            "intro"
        ],
        upvotes: -2,
        notifications: true,
        disable_hashing: false,
        instance: "sponsor.ajay.app",
        darkmode: -1
    };

    const CATEGORY_OPTIONS = [
        { id: "sponsor", label: "Skip sponsor segments" },
        { id: "intro", label: "Skip intro segments" },
        { id: "outro", label: "Skip outro segments" },
        { id: "interaction", label: "Skip interaction reminder segments" },
        { id: "selfpromo", label: "Skip self-promotion segments" },
        { id: "preview", label: "Skip preview segments" },
        { id: "music_offtopic", label: "Skip non-music segments in music videos" },
        { id: "filler", label: "Skip filler segments, very aggressive" }
    ];

    const INSTANCE_OPTIONS = [
        { value: "sponsor.ajay.app", label: "sponsor.ajay.app (Official)" },
        { value: "sponsorblock.kavin.rocks", label: "sponsorblock.kavin.rocks" },
        { value: "sponsorblock.gleesh.net", label: "sponsorblock.gleesh.net" },
        { value: "sb.theairplan.com", label: "sb.theairplan.com" }
    ];

    const PLAYER_SELECTOR = [
        "#movie_player video",
        "video#player_html5_api",
        "video#player",
        "video#video",
        "video#vjs_video_3_html5_api",
        "video"
    ].join(", ");

    let s3settings = null;
    let activeVideoId = "";
    let activeCleanup = null;
    let navigationTimer = null;

    if (typeof GM.registerMenuCommand === "undefined") {
        GM.registerMenuCommand = function () {};
    }

    if (typeof GM.notification === "undefined") {
        GM.notification = function (notification) {
            console.log(`${SCRIPT_NAME}: ${notification.title || ""} ${notification.text || ""}`);
        };
    }

    function log(message) {
        console.log(`${new Date().toTimeString().split(" ")[0]} - ${SCRIPT_NAME}: ${message}`);
    }

    function isIntegerLike(value) {
        return !Number.isNaN(value) &&
            parseInt(Number(value), 10) == value &&
            !Number.isNaN(parseInt(value, 10));
    }

    function normalizeCategories(categories, notifications) {
        if (isIntegerLike(categories)) {
            const bitmask = Number(categories);
            const converted = [];

            if (bitmask & 2) converted.push("intro");
            if (bitmask & 4) converted.push("outro");
            if (bitmask & 8) converted.push("interaction");
            if (bitmask & 16) converted.push("selfpromo");
            if (bitmask & 32) converted.push("preview");
            if (bitmask & 64) converted.push("music_offtopic");
            if (bitmask & 128) converted.push("filler");
            if ((bitmask & 1) || converted.length === 0) converted.push("sponsor");
            if (notifications) converted.push("poi_highlight");

            return [...new Set(converted)];
        }

        if (!Array.isArray(categories)) {
            return [...DEFAULT_SETTINGS.categories];
        }

        const valid = new Set([
            "sponsor",
            "intro",
            "outro",
            "interaction",
            "selfpromo",
            "preview",
            "music_offtopic",
            "filler",
            "poi_highlight"
        ]);

        const cleaned = categories.filter(category => valid.has(category));

        if (!cleaned.length) {
            cleaned.push("sponsor");
        }

        return [...new Set(cleaned)];
    }

    function sanitizeInstance(value) {
        let instance = String(value || DEFAULT_SETTINGS.instance).trim();

        instance = instance.replace(/\s*\(Official\)\s*/gi, "");
        instance = instance.replace(/^https?:\/\//i, "");
        instance = instance.split("/")[0];
        instance = instance.split("?")[0];
        instance = instance.split("#")[0];
        instance = instance.trim();

        if (!/^[a-z0-9.-]+(?::[0-9]+)?$/i.test(instance)) {
            return DEFAULT_SETTINGS.instance;
        }

        return instance || DEFAULT_SETTINGS.instance;
    }

    async function loadSettings() {
        let stored = await GM.getValue("s3settings");

        const isPaleMoon =
            navigator.userAgent.toLowerCase().includes("pale moon") ||
            navigator.userAgent.toLowerCase().includes("mypal") ||
            navigator.userAgent.toLowerCase().includes("male poon");

        if (!stored || typeof stored !== "object" || Object.keys(stored).length === 0) {
            stored = { ...DEFAULT_SETTINGS };
            if (isPaleMoon) stored.disable_hashing = true;
            await GM.setValue("s3settings", stored);
            log("Default settings saved.");
        }

        const normalized = {
            ...DEFAULT_SETTINGS,
            ...stored,
            categories: normalizeCategories(stored.categories, stored.notifications),
            upvotes: Number.isFinite(Number(stored.upvotes)) ? parseInt(stored.upvotes, 10) : DEFAULT_SETTINGS.upvotes,
            notifications: Boolean(stored.notifications),
            disable_hashing: Boolean(stored.disable_hashing),
            instance: sanitizeInstance(stored.instance),
            darkmode: [-1, 0, 1].includes(parseInt(stored.darkmode, 10)) ? parseInt(stored.darkmode, 10) : -1
        };

        await GM.setValue("s3settings", normalized);
        return normalized;
    }

    function openConfigUrl() {
        const url = new URL(location.href);
        url.hostname = url.hostname.replace("youtube-nocookie.com", "youtube.com");

        if (url.pathname.startsWith("/embed/")) {
            const id = url.pathname.replace("/embed/", "").split("/")[0];
            url.pathname = "/watch";
            url.search = `?v=${encodeURIComponent(id)}`;
        }

        if (url.pathname.startsWith("/v/")) {
            const id = url.pathname.replace("/v/", "").split("/")[0];
            url.pathname = "/watch";
            url.search = `?v=${encodeURIComponent(id)}`;
        }

        url.hash = "s3config";
        return url.toString();
    }

    function isConfigPage() {
        return location.hash.toLowerCase() === "#s3config";
    }

    function onReady(callback) {
        if (document.readyState === "loading") {
            document.addEventListener("DOMContentLoaded", callback, { once: true });
        } else {
            callback();
        }
    }

    function add(parent, tag, attrs = {}) {
        return GM.addElement(parent, tag, attrs);
    }

    function applyTheme(value) {
        const dark =
            value === 1 ||
            (value === -1 &&
                window.matchMedia &&
                window.matchMedia("(prefers-color-scheme: dark)").matches);

        document.body.classList.toggle("dark-theme", dark);
    }

    function renderConfig() {
        const docHtml = document.createElement("html");
        const head = add(docHtml, "head");

        add(head, "meta", { charset: "utf-8" });
        add(head, "title", { textContent: `${SCRIPT_NAME} Configuration` });

        add(head, "style", {
            textContent: `
                :root {
                    color-scheme: light dark;
                    font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
                }

                body {
                    margin: 0;
                    min-height: 100vh;
                    background: #f6f7f9;
                    color: #111827;
                    display: grid;
                    place-items: center;
                    padding: 24px;
                    box-sizing: border-box;
                }

                body.dark-theme {
                    background: #0b0f19;
                    color: #f9fafb;
                }

                main {
                    width: min(720px, 100%);
                    background: white;
                    border-radius: 20px;
                    padding: 28px;
                    box-shadow: 0 20px 60px rgba(0, 0, 0, 0.12);
                    box-sizing: border-box;
                }

                body.dark-theme main {
                    background: #111827;
                    box-shadow: 0 20px 60px rgba(0, 0, 0, 0.45);
                }

                h1 {
                    margin: 0 0 6px;
                    font-size: 28px;
                }

                p {
                    margin: 0 0 24px;
                    color: #6b7280;
                }

                body.dark-theme p {
                    color: #9ca3af;
                }

                form {
                    display: grid;
                    gap: 18px;
                }

                fieldset {
                    border: 1px solid #e5e7eb;
                    border-radius: 14px;
                    padding: 16px;
                    display: grid;
                    gap: 12px;
                }

                body.dark-theme fieldset {
                    border-color: #374151;
                }

                legend {
                    padding: 0 8px;
                    font-weight: 700;
                }

                .row {
                    display: flex;
                    align-items: center;
                    gap: 10px;
                }

                .field {
                    display: grid;
                    gap: 6px;
                }

                label {
                    cursor: pointer;
                }

                input[type="text"],
                input[type="number"],
                select {
                    width: 100%;
                    box-sizing: border-box;
                    padding: 10px 12px;
                    border: 1px solid #d1d5db;
                    border-radius: 10px;
                    background: white;
                    color: #111827;
                    font: inherit;
                }

                body.dark-theme input[type="text"],
                body.dark-theme input[type="number"],
                body.dark-theme select {
                    background: #030712;
                    border-color: #374151;
                    color: #f9fafb;
                }

                .buttons {
                    display: flex;
                    gap: 12px;
                    flex-wrap: wrap;
                }

                button {
                    border: 0;
                    border-radius: 999px;
                    padding: 10px 16px;
                    font: inherit;
                    font-weight: 700;
                    cursor: pointer;
                }

                button.primary {
                    background: #111827;
                    color: white;
                }

                body.dark-theme button.primary {
                    background: #f9fafb;
                    color: #111827;
                }

                button.secondary {
                    background: #e5e7eb;
                    color: #111827;
                }

                body.dark-theme button.secondary {
                    background: #374151;
                    color: #f9fafb;
                }

                .hint {
                    font-size: 13px;
                    color: #6b7280;
                    margin: 0;
                }

                body.dark-theme .hint {
                    color: #9ca3af;
                }
            `
        });

        const body = add(docHtml, "body");
        const main = add(body, "main");

        add(main, "h1", { textContent: SCRIPT_NAME });
        add(main, "p", { textContent: "Configure which SponsorBlock segments should be skipped." });

        const form = add(main, "form");

        const categoryFieldset = add(form, "fieldset");
        add(categoryFieldset, "legend", { textContent: "Segments" });

        for (const option of CATEGORY_OPTIONS) {
            const row = add(categoryFieldset, "div", { className: "row" });
            add(row, "input", {
                type: "checkbox",
                id: option.id
            });
            add(row, "label", {
                for: option.id,
                textContent: option.label
            });
        }

        const behaviorFieldset = add(form, "fieldset");
        add(behaviorFieldset, "legend", { textContent: "Behavior" });

        const upvotesField = add(behaviorFieldset, "div", { className: "field" });
        add(upvotesField, "label", {
            for: "upvotes",
            textContent: "Minimum segment votes"
        });
        add(upvotesField, "input", {
            type: "number",
            id: "upvotes",
            step: "1"
        });
        add(upvotesField, "p", {
            className: "hint",
            textContent: "-2 is permissive. Higher values skip only more trusted segments."
        });

        const notificationsRow = add(behaviorFieldset, "div", { className: "row" });
        add(notificationsRow, "input", {
            type: "checkbox",
            id: "notifications"
        });
        add(notificationsRow, "label", {
            for: "notifications",
            textContent: "Enable desktop notifications and point-of-interest hints"
        });

        const hashingRow = add(behaviorFieldset, "div", { className: "row" });
        add(hashingRow, "input", {
            type: "checkbox",
            id: "disable_hashing"
        });
        add(hashingRow, "label", {
            for: "disable_hashing",
            textContent: "Disable video ID hashing, only needed for old browser compatibility"
        });

        const instanceField = add(behaviorFieldset, "div", { className: "field" });
        add(instanceField, "label", {
            for: "instance",
            textContent: "SponsorBlock database instance"
        });

        add(instanceField, "input", {
            id: "instance",
            type: "text",
            list: "instances",
            autocomplete: "off",
            spellcheck: "false"
        });

        const dataList = add(instanceField, "datalist", { id: "instances" });
        for (const instance of INSTANCE_OPTIONS) {
            add(dataList, "option", {
                value: instance.value,
                label: instance.label
            });
        }

        const themeField = add(behaviorFieldset, "div", { className: "field" });
        add(themeField, "label", {
            for: "darkmode",
            textContent: "Theme"
        });

        const themeSelect = add(themeField, "select", { id: "darkmode" });
        add(themeSelect, "option", { value: "-1", textContent: "Auto" });
        add(themeSelect, "option", { value: "0", textContent: "Light" });
        add(themeSelect, "option", { value: "1", textContent: "Dark" });

        const buttons = add(form, "div", { className: "buttons" });
        add(buttons, "button", {
            type: "button",
            id: "btnsave",
            className: "primary",
            textContent: "Save settings"
        });
        add(buttons, "button", {
            type: "button",
            id: "btnclose",
            className: "secondary",
            textContent: "Close"
        });
        add(buttons, "button", {
            type: "button",
            id: "btnreset",
            className: "secondary",
            textContent: "Reset defaults"
        });

        document.documentElement.replaceWith(docHtml);
        document.title = `${SCRIPT_NAME} Configuration`;

        for (const option of CATEGORY_OPTIONS) {
            document.getElementById(option.id).checked = s3settings.categories.includes(option.id);
        }

        document.getElementById("upvotes").value = String(s3settings.upvotes);
        document.getElementById("notifications").checked = Boolean(s3settings.notifications);
        document.getElementById("disable_hashing").checked = Boolean(s3settings.disable_hashing);
        document.getElementById("instance").value = sanitizeInstance(s3settings.instance);
        document.getElementById("darkmode").value = String(s3settings.darkmode);

        applyTheme(s3settings.darkmode);

        document.getElementById("darkmode").addEventListener("change", event => {
            applyTheme(parseInt(event.target.value, 10));
        });

        document.getElementById("btnsave").addEventListener("click", saveConfig);
        document.getElementById("btnreset").addEventListener("click", resetConfig);
        document.getElementById("btnclose").addEventListener("click", closeConfig);
    }

    async function saveConfig() {
        const categories = [];

        for (const option of CATEGORY_OPTIONS) {
            if (document.getElementById(option.id).checked) {
                categories.push(option.id);
            }
        }

        if (!categories.length) {
            categories.push("sponsor");
        }

        const notifications = document.getElementById("notifications").checked;

        if (notifications) {
            categories.push("poi_highlight");
        }

        s3settings = {
            categories: [...new Set(categories)],
            upvotes: parseInt(document.getElementById("upvotes").value, 10),
            notifications,
            disable_hashing: document.getElementById("disable_hashing").checked,
            instance: sanitizeInstance(document.getElementById("instance").value),
            darkmode: parseInt(document.getElementById("darkmode").value, 10)
        };

        if (!Number.isFinite(s3settings.upvotes)) {
            s3settings.upvotes = DEFAULT_SETTINGS.upvotes;
        }

        await GM.setValue("s3settings", s3settings);

        const button = document.getElementById("btnsave");
        button.textContent = "Saved";
        button.disabled = true;

        setTimeout(() => {
            button.textContent = "Save settings";
            button.disabled = false;
        }, 1500);

        log("Settings saved.");
    }

    async function resetConfig() {
        s3settings = { ...DEFAULT_SETTINGS };
        await GM.setValue("s3settings", s3settings);
        renderConfig();
        log("Settings reset.");
    }

    function closeConfig() {
        const url = new URL(location.href);
        url.hash = "";
        location.replace(url.toString());
    }

    function durationString(seconds) {
        const safeSeconds = Math.max(0, Math.floor(Number(seconds) || 0));
        const hours = Math.floor(safeSeconds / 3600);
        const minutes = Math.floor((safeSeconds % 3600) / 60);
        const secs = safeSeconds % 60;

        if (hours > 0) {
            return `${hours}:${String(minutes).padStart(2, "0")}:${String(secs).padStart(2, "0")}`;
        }

        return `${minutes}:${String(secs).padStart(2, "0")}`;
    }

    function shuffleCopy(array) {
        const copy = [...array];

        for (let i = copy.length - 1; i > 0; i--) {
            const j = Math.floor(Math.random() * (i + 1));
            [copy[i], copy[j]] = [copy[j], copy[i]];
        }

        return copy;
    }

    async function sha256(message) {
        const msgBuffer = new TextEncoder().encode(message);
        const hashBuffer = await crypto.subtle.digest("SHA-256", msgBuffer);
        return Array
            .from(new Uint8Array(hashBuffer))
            .map(byte => byte.toString(16).padStart(2, "0"))
            .join("");
    }

    function requestJson(url) {
        return new Promise((resolve, reject) => {
            GM.xmlHttpRequest({
                method: "GET",
                url,
                headers: {
                    Accept: "application/json"
                },
                timeout: 15000,
                onload(response) {
                    if (response.status >= 200 && response.status < 300) {
                        resolve(response.responseText);
                    } else if (response.status === 404) {
                        resolve("[]");
                    } else {
                        reject(new Error(`HTTP ${response.status}`));
                    }
                },
                onerror() {
                    reject(new Error("Network error"));
                },
                ontimeout() {
                    reject(new Error("Request timed out"));
                }
            });
        });
    }

    async function fetchSegments(videoId) {
        const instance = sanitizeInstance(s3settings.instance);
        const categories = shuffleCopy(s3settings.categories);
        const encodedCategories = encodeURIComponent(JSON.stringify(categories));

        let url;

        if (s3settings.disable_hashing) {
            url = `https://${instance}/api/skipSegments?videoID=${encodeURIComponent(videoId)}&categories=${encodedCategories}`;
            const text = await requestJson(url);
            const segments = JSON.parse(text);
            return [{
                videoID: videoId,
                segments: Array.isArray(segments) ? segments : []
            }];
        }

        const hash = await sha256(videoId);
        url = `https://${instance}/api/skipSegments/${hash.substring(0, 4)}?categories=${encodedCategories}`;

        const text = await requestJson(url);
        const parsed = JSON.parse(text);

        return Array.isArray(parsed) ? parsed : [];
    }

    function getVotes(segment) {
        return Number(segment.votes ?? segment.upvotes ?? 0);
    }

    function processSegments(segments) {
        if (!Array.isArray(segments)) {
            return {
                skipSegments: [],
                highlightTime: null,
                beforeCount: 0
            };
        }

        const sorted = [...segments].sort((a, b) => {
            const aStart = Number(a.segment?.[0] ?? 0);
            const bStart = Number(b.segment?.[0] ?? 0);
            return aStart - bStart;
        });

        const skipSegments = [];
        let highlight = null;
        let highlightVotes = s3settings.upvotes - 1;

        for (const segment of sorted) {
            if (!Array.isArray(segment.segment) || segment.segment.length < 2) {
                continue;
            }

            const start = Number(segment.segment[0]);
            const end = Number(segment.segment[1]);
            const votes = getVotes(segment);

            if (!Number.isFinite(start) || !Number.isFinite(end) || end <= start) {
                continue;
            }

            if (segment.category === "poi_highlight") {
                if (votes > highlightVotes) {
                    highlight = segment;
                    highlightVotes = votes;
                }
                continue;
            }

            if (votes < s3settings.upvotes) {
                continue;
            }

            const last = skipSegments[skipSegments.length - 1];

            if (last && last.segment[1] >= start) {
                if (end > last.segment[1]) {
                    last.segment[1] = end;
                    last.category = last.category === segment.category ? last.category : "combined";
                }
                continue;
            }

            skipSegments.push({
                ...segment,
                segment: [start, end]
            });
        }

        return {
            skipSegments,
            highlightTime: highlight ? Number(highlight.segment[0]) : null,
            beforeCount: segments.length
        };
    }

    function getVideoIdFromUrl() {
        const url = new URL(location.href);
        const host = url.hostname;

        if (host === "youtu.be") {
            return url.pathname.slice(1).split("/")[0] || "";
        }

        if (url.searchParams.has("v")) {
            return url.searchParams.get("v") || "";
        }

        if (url.pathname.startsWith("/embed/")) {
            return url.pathname.replace("/embed/", "").split("/")[0] || "";
        }

        if (url.pathname.startsWith("/v/")) {
            return url.pathname.replace("/v/", "").split("/")[0] || "";
        }

        return "";
    }

    function waitForPlayer(timeoutMs = 12000) {
        return new Promise(resolve => {
            const start = Date.now();

            const timer = setInterval(() => {
                const player = document.querySelector(PLAYER_SELECTOR);

                if (player && player.readyState >= 1) {
                    clearInterval(timer);
                    resolve(player);
                    return;
                }

                if (Date.now() - start > timeoutMs) {
                    clearInterval(timer);
                    resolve(null);
                }
            }, 100);
        });
    }

    function getFavicon() {
        const icon = document.head?.querySelector("link[rel='icon'][href], link[rel='shortcut icon'][href]");
        return icon ? icon.href : undefined;
    }

    function notify(notification) {
        if (!s3settings.notifications || window.self !== window.top) {
            return;
        }

        try {
            GM.notification({
                silent: true,
                timeout: 5000,
                ...notification
            });
        } catch (error) {
            log(`Notification failed: ${error.message}`);
        }
    }

    async function go(videoId) {
        if (!videoId || videoId === activeVideoId) {
            return;
        }

        activeVideoId = videoId;

        if (typeof activeCleanup === "function") {
            activeCleanup();
            activeCleanup = null;
        }

        log(`New video ID: ${videoId}`);

        let apiResponse;

        try {
            apiResponse = await fetchSegments(videoId);
        } catch (error) {
            log(`SponsorBlock request failed: ${error.message}`);
            return;
        }

        const matching = apiResponse.find(item => item.videoID === videoId);

        if (!matching) {
            log("No matching SponsorBlock result found.");
            return;
        }

        const {
            skipSegments,
            highlightTime,
            beforeCount
        } = processSegments(matching.segments);

        const player = await waitForPlayer();

        if (!player) {
            log("No video player found.");
            return;
        }

        const favicon = getFavicon();

        const showHighlightNotification = () => {
            if (highlightTime !== null && player.currentTime < highlightTime) {
                notify({
                    title: "Point of interest found",
                    text: `This video has a highlight at ${durationString(highlightTime)}.\nClick to skip to it.\n\u00AD\n${document.title} (${videoId})`,
                    image: favicon,
                    onclick: () => {
                        player.currentTime = highlightTime;
                    }
                });
            }
        };

        if (!skipSegments.length) {
            showHighlightNotification();

            const playHandler = showHighlightNotification;
            player.addEventListener("play", playHandler);

            activeCleanup = () => {
                player.removeEventListener("play", playHandler);
            };

            return;
        }

        let newDuration = Number(skipSegments[0].videoDuration || player.duration || 0);

        if (Number.isFinite(newDuration) && newDuration > 0) {
            for (const segment of skipSegments) {
                newDuration -= segment.segment[1] - segment.segment[0];
            }
        }

        notify({
            title: "Skippable segments found",
            text:
                `Received ${beforeCount} segment${beforeCount === 1 ? "" : "s"}, ` +
                `${skipSegments.length} active after processing.` +
                (Number.isFinite(newDuration) && newDuration > 0 ? `\nDuration after skips: ${durationString(newDuration)}` : "") +
                (highlightTime !== null ? `\nHighlight: ${durationString(highlightTime)}` : "") +
                `\n\u00AD\n${document.title} (${videoId})`,
            image: favicon,
            onclick: highlightTime !== null
                ? () => {
                    player.currentTime = highlightTime;
                }
                : undefined
        });

        let index = 0;
        let previousTime = -1;

        const timeUpdateHandler = () => {
            const currentId = getVideoIdFromUrl();

            if (currentId && currentId !== videoId) {
                if (typeof activeCleanup === "function") {
                    activeCleanup();
                    activeCleanup = null;
                }
                return;
            }

            const currentTime = player.currentTime;

            if (currentTime < previousTime) {
                index = skipSegments.findIndex(segment => currentTime < segment.segment[1]);
                if (index < 0) index = skipSegments.length;
            }

            while (index < skipSegments.length && currentTime >= skipSegments[index].segment[1]) {
                index++;
            }

            if (
                !player.paused &&
                index < skipSegments.length &&
                currentTime >= skipSegments[index].segment[0] &&
                currentTime < skipSegments[index].segment[1]
            ) {
                const segment = skipSegments[index];
                player.currentTime = segment.segment[1];

                notify({
                    title: `Skipped ${segment.category.replace("music_offtopic", "non-music").replace("selfpromo", "self-promotion")}`,
                    text: `Segment ${index + 1} of ${skipSegments.length}\n\u00AD\n${document.title} (${videoId})`,
                    image: favicon
                });

                log(`Skipped ${segment.category} from ${segment.segment[0]} to ${segment.segment[1]}`);
                index++;
            }

            previousTime = player.currentTime;
        };

        const playHandler = showHighlightNotification;

        player.addEventListener("timeupdate", timeUpdateHandler);
        player.addEventListener("play", playHandler);

        activeCleanup = () => {
            player.removeEventListener("timeupdate", timeUpdateHandler);
            player.removeEventListener("play", playHandler);
        };
    }

    function checkCurrentVideoSoon() {
        clearTimeout(navigationTimer);

        navigationTimer = setTimeout(() => {
            const videoId = getVideoIdFromUrl();

            if (!videoId) {
                activeVideoId = "";
                if (typeof activeCleanup === "function") {
                    activeCleanup();
                    activeCleanup = null;
                }
                return;
            }

            go(videoId);
        }, 250);
    }

    function startWatchers() {
        checkCurrentVideoSoon();

        window.addEventListener("yt-navigate-finish", checkCurrentVideoSoon, true);
        window.addEventListener("yt-page-data-updated", checkCurrentVideoSoon, true);
        window.addEventListener("popstate", checkCurrentVideoSoon, true);
        window.addEventListener("hashchange", () => {
            if (isConfigPage()) {
                renderConfig();
            } else {
                checkCurrentVideoSoon();
            }
        }, true);

        onReady(() => {
            if (!document.body) return;

            const observer = new MutationObserver(() => {
                checkCurrentVideoSoon();
            });

            observer.observe(document.body, {
                childList: true,
                subtree: true
            });
        });
    }

    s3settings = await loadSettings();

    if (window.self === window.top) {
        GM.registerMenuCommand("Configuration", () => {
            location.href = openConfigUrl();
            setTimeout(() => location.reload(), 50);
        });
    }

    if (isConfigPage() && window.self === window.top) {
        onReady(renderConfig);
        return;
    }

    startWatchers();
})();