Aniworld.to & S.to Autoplay

Autoplay for Aniworld.to and S.to with lots of functions like Outro skip, Intro skip, persistent volume between providers, remember language, Playback Position Memory and some more

// ==UserScript==
// @name             Aniworld.to & S.to Autoplay
// @name:de          Autoplay AniWorld & S.to
// @description      Autoplay for Aniworld.to and S.to with lots of functions like Outro skip, Intro skip, persistent volume between providers, remember language, Playback Position Memory and some more
// @description:de   Autoplay für Aniworld.to und S.to mit vielen Funktionen wie Outro-Überspringen, Intro-Überspringen, Sprachspeicherung, Konstante Lautstärke zwischen providern, Wiedergabepositionsspeicher und mehr
// @version          4.9.3a
// @match            https://aniworld.to/*
// @match            https://s.to/*
// @match            https://186.2.175.5/
// @match            *://*/*
// @author           AniPlayer
// @namespace        https://greasyfork.dpdns.org/users/1400386
// @license          GPL-3.0-or-later; https://spdx.org/licenses/GPL-3.0-or-later.html
// @icon             https://i.imgur.com/CEZGcX6.png
// @require          https://cdn.jsdelivr.net/npm/[email protected]/dist/notiflix-aio-3.2.8.min.js#sha512-XsGxeeCSQNP2+WGCUScwIO6sznCBBee4we6n8n6yoFgB+shnCXJZCY2snFqu+fgIbPd79ldRR1/5zQFMUQVSpg==
// @require          https://cdnjs.cloudflare.com/ajax/libs/keyboardjs/2.7.0/keyboard.min.js#sha512-UrxaOZAJw5p38NProL/UrffryqdMdXFcEdyLt6eU89pH0N7KnmAe8G3ghNbH1qW5cDYdnaoEw1TcbHn8wuqAvw==
// @require          https://cdn.jsdelivr.net/npm/[email protected]/dist/tweakpane.min.js#sha512-ugca4SpzfDh4VV8oj0yscIUlKxZhJd9LD5HOX4o7jOMlI/1iGYr7S4Q4Fnvx/GFXCwAivLrdHOo/7t4iYV4ehw==
// @grant            GM_addStyle
// @grant            GM_addValueChangeListener
// @grant            GM_deleteValue
// @grant            GM_getValue
// @grant            GM_listValues
// @grant            GM_removeValueChangeListener
// @grant            GM_setValue
// @grant            GM.getValue
// @grant            unsafeWindow
// @run-at           document-body
// ==/UserScript==

/**
 * Hi! This script works best with Violentmonkey, as using other managers
 * causes unexpected bugs. Please, consider installing Violentmonkey,
 * you can use it along with your current script manager.
 *
 * Don't change (or even resave) anything here because
 * doing so in Tampermonkey will turn off the script updates.
 * Not sure about other script managers.
 * This can be restored in settings, but it might be hard to find,
 * so it's better to reinstall the script if you're not sure.
 */

/* jshint esversion: 11 */
/* global Notiflix, Tweakpane, keyboardJS */


(async function() {
    'use strict';


    const VIOLENTMONKEY_WARNING = [
        `${GM_info.script.name} warning`, 'This script works best with Violentmonkey, as using other script managers causes unexpected bugs. Please, consider installing Violentmonkey, you can use it along with your current script manager. This message won\'t show up again'
    ];

    // Domains list the script should work for
    const TOP_SCOPE_DOMAINS = [
        'aniworld.to',
        's.to',
        '186.2.175.5',
    ];

    // Needed for proper tracking of position memory
    const TOP_SCOPE_DOMAINS_IDS = {
        'aniworld.to': 'aniworld',
        's.to': 'sto',
        '186.2.175.5': 'sto',
    };

    // Names should be the exact same as in the providers list of the website
    const VIDEO_PROVIDERS_MAP = {
        Doodstream: 'Doodstream',
        LoadX: 'LoadX',
        SpeedFiles: 'SpeedFiles',
        Vidoza: 'Vidoza',
        VOE: 'VOE',
    };

    const VIDEO_PROVIDERS_IDS = {
        '0': VIDEO_PROVIDERS_MAP.LoadX,
        '1': VIDEO_PROVIDERS_MAP.VOE,
        '2': VIDEO_PROVIDERS_MAP.SpeedFiles,
        '3': VIDEO_PROVIDERS_MAP.Vidoza,
        '4': VIDEO_PROVIDERS_MAP.Doodstream,
    };

    // Providers supported by the script, ordered by a default priority
    const VIDEO_PROVIDERS_DEFAULT_ORDER = [
        VIDEO_PROVIDERS_MAP.LoadX,
        VIDEO_PROVIDERS_MAP.VOE,
        VIDEO_PROVIDERS_MAP.Doodstream,
        VIDEO_PROVIDERS_MAP.SpeedFiles,
        VIDEO_PROVIDERS_MAP.Vidoza,
    ];

    const CORE_SETTINGS_MAP = {
        currentLargeSkipSizeS: 'currentLargeSkipSizeS',
        currentOutroSkipThresholdS: 'currentOutroSkipThresholdS',
        isAutoplayEnabled: 'isAutoplayEnabled',
        isMuted: 'isMuted',
        shouldAutoSkipOnStart: 'shouldAutoSkipOnStart',
        autoSkipSecondsOnStart: 'autoSkipSecondsOnStart',
        persistentVolumeLvl: 'persistentVolumeLvl',
        providersPriority: 'providersPriority',
        videoLanguagePreferredID: 'videoLanguagePreferredID',
    };

    // Note that defaults are applied only on a very first run of the script
    const CORE_SETTINGS_DEFAULTS = {
        // Default value doesn't matter because it fallbacks to
        // ADVANCED_SETTINGS_DEFAULTS.defaultLargeSkipSizeS anyway
        [CORE_SETTINGS_MAP.currentLargeSkipSizeS]: 87,
        [CORE_SETTINGS_MAP.currentOutroSkipThresholdS]: 90, // same logic
        [CORE_SETTINGS_MAP.shouldAutoSkipOnStart]: true,
        [CORE_SETTINGS_MAP.autoSkipSecondsOnStart]: 0,
        [CORE_SETTINGS_MAP.isAutoplayEnabled]: false,
        [CORE_SETTINGS_MAP.isMuted]: false,
        [CORE_SETTINGS_MAP.persistentVolumeLvl]: 0.5,
        [CORE_SETTINGS_MAP.providersPriority]: (
            VIDEO_PROVIDERS_DEFAULT_ORDER.map(name => Object.keys(VIDEO_PROVIDERS_IDS).find(
                key => VIDEO_PROVIDERS_IDS[key] === name
            ))
        ),
        [CORE_SETTINGS_MAP.videoLanguagePreferredID]: '1',
    };

    const HOTKEYS_SETTINGS_MAP = {
        fastBackward: 'fastBackward',
        fastForward: 'fastForward',
        fullscreen: 'fullscreen',
        largeSkip: 'largeSkip',
    };

    // Note that defaults are applied only on a very first run of the script
    const HOTKEYS_SETTINGS_DEFAULTS = {
        [HOTKEYS_SETTINGS_MAP.fastBackward]: 'left',
        [HOTKEYS_SETTINGS_MAP.fastForward]: 'right',
        [HOTKEYS_SETTINGS_MAP.fullscreen]: 'f',
        [HOTKEYS_SETTINGS_MAP.largeSkip]: 'v',
    };

    const MAIN_SETTINGS_MAP = {
        overrideDoubletapBehavior: 'overrideDoubletapBehavior',
        playbackPositionMemory: 'playbackPositionMemory',
        shouldAutoplayMuted: 'shouldAutoplayMuted',
    };

    // Note that defaults are applied only on a very first run of the script
    const MAIN_SETTINGS_DEFAULTS = {
        [MAIN_SETTINGS_MAP.overrideDoubletapBehavior]: true,
        [MAIN_SETTINGS_MAP.playbackPositionMemory]: true,
        [MAIN_SETTINGS_MAP.shouldAutoplayMuted]: true,
    };

    const ADVANCED_SETTINGS_MAP = {
        commlinkPollingIntervalMs: 'commlinkPollingIntervalMs',
        corsProxy: 'corsProxy',
        defaultLargeSkipSizeS: 'defaultLargeSkipSizeS',
        defaultOutroSkipThresholdS: 'defaultOutroSkipThresholdS',
        doubletapDistanceThresholdPx: 'doubletapDistanceThresholdPx',
        doubletapTimingThresholdMs: 'doubletapTimingThresholdMs',
        fastForwardSizeS: 'fastForwardSizeS',
        largeSkipCooldownMs: 'largeSkipCooldownMs',
        markWatchedAfterS: 'markWatchedAfterS',
        playOnLargeSkip: 'playOnLargeSkip',
        playbackPositionExpirationDays: 'playbackPositionExpirationDays',
        showDeviceSpecificSettings: 'showDeviceSpecificSettings',
    };

    // Note that defaults are applied only on a very first run of the script
    const ADVANCED_SETTINGS_DEFAULTS = {
        [ADVANCED_SETTINGS_MAP.commlinkPollingIntervalMs]: 40,
        [ADVANCED_SETTINGS_MAP.corsProxy]: 'https://aniworld-to-cors-proxy.fly.dev/',
        [ADVANCED_SETTINGS_MAP.defaultLargeSkipSizeS]: 87,
        [ADVANCED_SETTINGS_MAP.defaultOutroSkipThresholdS]: 90,
        [ADVANCED_SETTINGS_MAP.doubletapDistanceThresholdPx]: 50,
        [ADVANCED_SETTINGS_MAP.doubletapTimingThresholdMs]: 300,
        [ADVANCED_SETTINGS_MAP.fastForwardSizeS]: 10,
        [ADVANCED_SETTINGS_MAP.largeSkipCooldownMs]: 300,
        [ADVANCED_SETTINGS_MAP.markWatchedAfterS]: 0,
        [ADVANCED_SETTINGS_MAP.playOnLargeSkip]: true,
        [ADVANCED_SETTINGS_MAP.playbackPositionExpirationDays]: 30,
        [ADVANCED_SETTINGS_MAP.showDeviceSpecificSettings]: false,
    };

    const IS_MOBILE = (
        /Mobi|Android|iP(hone|[oa]d)/i.test(navigator.userAgent)
    );

    const IS_SAFARI = (
        navigator.userAgent.indexOf('Safari') > -1 && !/Chrome|CriOS/.test(navigator.userAgent)
    );

    // Can not handle nested objects
    class DataStore {
        constructor(uuid, defaultStorage = {}) {
            if (typeof uuid !== 'string' && typeof uuid !== 'number') {
                throw new Error('Expected uuid when creating DataStore');
            }

            this.__uuid = uuid;
            this.__storage = defaultStorage;

            try {
                this.__storage = JSON.parse(GM_getValue(uuid));
            } catch {
                GM_setValue(uuid, JSON.stringify(defaultStorage));
            }

            return new Proxy(this, {
                get: (obj, prop) => {
                    if (prop === 'destroy') return () => obj.__destroy();
                    if (prop === 'update') return updates => obj.__update(updates);

                    return obj.__storage[prop];
                },

                set: (obj, prop, value) => {
                    obj.__storage[prop] = value;
                    GM_setValue(obj.__uuid, JSON.stringify(obj.__storage));

                    return true;
                }
            });
        }

        __update(updates) {
            if (updates) {
                Object.assign(this.__storage, updates);
                GM_setValue(this.__uuid, JSON.stringify(this.__storage));
            } else {
                try {
                    this.__storage = JSON.parse(GM_getValue(this.__uuid)) || {};
                } catch {
                    this.__storage = {};
                }
            }
        }

        __destroy() {
            GM_deleteValue(this.__uuid);
            this.__storage = {};
        }
    }

    const advancedSettings = new DataStore('advancedSettings', ADVANCED_SETTINGS_DEFAULTS);
    const coreSettings = new DataStore('coreSettings', CORE_SETTINGS_DEFAULTS);
    const hotkeysSettings = new DataStore('hotkeysSettings', HOTKEYS_SETTINGS_DEFAULTS);
    const mainSettings = new DataStore('mainSettings', MAIN_SETTINGS_DEFAULTS);

    [
        [advancedSettings, ADVANCED_SETTINGS_DEFAULTS],
        [coreSettings, CORE_SETTINGS_DEFAULTS],
        [hotkeysSettings, HOTKEYS_SETTINGS_DEFAULTS],
        [mainSettings, MAIN_SETTINGS_DEFAULTS]
    ].forEach(([settings, defaults]) => {
        Object.entries(defaults).forEach(([key, value]) => (settings[key] ??= value));
    });

    if (
        Object.keys(VIDEO_PROVIDERS_IDS).sort().toString() !== [...coreSettings[CORE_SETTINGS_MAP.providersPriority]].sort().toString()
    ) {
        coreSettings[CORE_SETTINGS_MAP.providersPriority] = [
            ...CORE_SETTINGS_DEFAULTS[CORE_SETTINGS_MAP.providersPriority]
        ];
    }

    // -------------------------------------- /utils ---------------------------------------------

    const Notiflixx = (() => {
        GM_addStyle(`
      [id^=NotiflixBlockWrap], [id^=NotiflixConfirmWrap],
      [id^=NotiflixLoadingWrap], [id^=NotiflixNotifyWrap],
      [id^=NotiflixReportWrap] {
        -webkit-tap-highlight-color: #24242412;
      }

      div.notiflix-report-icon {
        width: 60px !important;
        height: 60px !important;
      }

      div.notiflix-report-content {
        max-width: 1010px !important;
        width: unset !important;
      }


      .notiflix-hotkeys-guide-modal {
        max-height: 70vh;
        overflow-y: auto;
        padding: 0 15px;
      }

      .notiflix-hotkeys-guide-modal h5 {
        font-size: 19px;
        margin: 25px 0 10px 0;
      }

      .notiflix-hotkeys-guide-modal h5:first-child {
        margin: 0 0 10px 0;
      }

      .notiflix-hotkeys-guide-modal div {
        color: black;
        margin-bottom: 5px;
      }

      .notiflix-hotkeys-guide-modal pre {
        background: #243743;
        border: none;
        display: inline-block;
        margin: 1px 0 1px 0;
        padding: 4px 8px;
        vertical-align: middle;
      }
    `);

        const notifyDefaultOptions = {
            closeButton: true,
            messageMaxLength: 500,
            plainText: false,
            position: 'left-top',
            zindex: 3222222,
        };

        const reportDefaultOptions = {
            titleMaxLength: 100,
            zindex: 3222223,
        };

        const disableBodyScroll = () => {
            // Order is important here
            document.body.style.paddingRight = (
                `${window.innerWidth - document.documentElement.clientWidth}px`
            );
            document.body.style.overflow = 'hidden';
        };

        const restoreBodyScroll = () => {
            document.body.style.overflow = '';
            document.body.style.paddingRight = '';
        };

        const createNotifyHandler = (notifyType) => {
            return (message, customOptions = {}) => {
                Notiflix.Notify[notifyType](message, {
                    ...notifyDefaultOptions,
                    ...customOptions,
                });
            };
        };

        const createReportHandler = (reportType) => {
            return (titleText, messageText, btnText, customOptions = {}) => {
                disableBodyScroll();

                Notiflix.Report[reportType](titleText, messageText, btnText, () => {
                    restoreBodyScroll();
                }, {
                    ...reportDefaultOptions,
                    ...customOptions,
                });

                if (customOptions.backOverlayClickToClose) {
                    const backOverlay = document.querySelector(
                        '[id^=NotiflixReportWrap] > div[class*="-overlay"]'
                    );

                    backOverlay?.addEventListener('click', () => restoreBodyScroll());
                }

                if (customOptions.delayedButton) {
                    const closeBtn = document.querySelector('a#NXReportButton');

                    closeBtn.style.background = '#b2b2b2';
                    closeBtn.style.pointerEvents = 'none';

                    setTimeout(() => {
                        closeBtn.style.background = '#26c0d3';
                        closeBtn.style.pointerEvents = '';
                    }, 2000);
                }
            };
        };

        return {
            notify: {
                failure: createNotifyHandler('failure'),
                warning: createNotifyHandler('warning'),
            },

            report: {
                info: createReportHandler('info'),
                warning: createReportHandler('warning'),
            },
        };
    })();

    function waitForElement(selector, opts = {}, cb) {
        const {
            existing = false, interval = 50, timeout = 10000
        } = opts;
        const start = Date.now();
        const timer = setInterval(() => {
            const el = document.querySelector(selector);
            if (el) {
                clearInterval(timer);
                cb(el);
            } else if (Date.now() - start > timeout) {
                clearInterval(timer);
                console.warn('[SmartLoader] Timed out waiting for', selector);
            }
        }, interval);
        if (existing) {
            const el = document.querySelector(selector);
            if (el) {
                clearInterval(timer);
                cb(el);
            }
        }
    }

    waitForElement('.inSiteWebStream', {
        existing: true
    }, function(container) {
        (function() {
            'use strict';

            const heightMap = {
                Vidmoly: '600px',
                Luluvdo: '480px',
                Filemoon: '480px'
            };
            let vidmolyIframe = null;
            let vidmolyUrl = null;
            let vidmolyReady = false;

            function log(...args) {
                console.log('%c[🔥 SmartLoader]', 'color: lime;', ...args);
            }

            function spoofVidmolyEnv() {
                window.adsbygoogle = window.adsbygoogle || [];
                window.vsd1 = {
                    skip: true,
                    adblock: true
                };
                document.cookie = "molyast21=1; path=/; domain=.vidmoly.to";

                const patch = document.createElement('script');
                patch.innerHTML = `
                (() => {
                    const og = window.jwplayer;
                    Object.defineProperty(window, 'jwplayer', {
                        configurable: true,
                        get: () => function(id) {
                            const p = og(id);
                            const s = p.setup;
                            p.setup = function(cfg) {
                                if (cfg.advertising) cfg.advertising = {};
                                return s.call(this, cfg);
                            };
                            return p;
                        }
                    });
                })();
            `;
                document.body.appendChild(patch);
            }

            function showLoader(type) {
                const old = document.querySelector('#loadingMessage');
                if (old) old.remove();
                const msg = document.createElement('div');
                msg.id = 'loadingMessage';
                msg.innerText = `⏳ Loading ${type}...`;
                Object.assign(msg.style, {
                    background: '#111',
                    color: '#fff',
                    fontFamily: 'sans-serif',
                    padding: '20px',
                    textAlign: 'center'
                });
                container.innerHTML = '';
                container.appendChild(msg);
            }

            function clearLoader() {
                const l = document.querySelector('#loadingMessage');
                if (l) l.remove();
            }

            function buildIframe(src, type) {
                const iframe = document.createElement('iframe');
                iframe.src = src;
                iframe.allowFullscreen = true;
                iframe.frameBorder = '0';
                iframe.width = '100%';
                iframe.height = heightMap[type] || '500px';
                Object.assign(iframe.style, {
                    display: 'block',
                    border: 'none',
                    position: 'relative',
                    margin: '0 auto'
                });
                return iframe;
            }

            function injectJWplayer(iframe) {
                try {
                    const win = iframe.contentWindow;
                    const tryInject = setInterval(() => {
                        try {
                            const player = win?.jwplayer?.();
                            if (player && typeof player.play === 'function') {
                                player.play();
                                clearInterval(tryInject);
                                log('▶️ JWPlayer play() called inside iframe');
                            }
                        } catch {}
                    }, 500);
                } catch (err) {
                    log('❌ JW inject failed:', err);
                }
            }

            function embedVidmoly() {
                if (!vidmolyReady || !vidmolyIframe || !vidmolyUrl) {
                    alert('Vidmoly not ready yet.');
                    return;
                }
                container.innerHTML = '';
                const realIframe = buildIframe(vidmolyUrl, 'Vidmoly');
                container.appendChild(realIframe);
                injectJWplayer(realIframe);
            }

            function embedGeneric(url, type, attempt = 1) {
                showLoader(type);
                const iframe = buildIframe(url, type);
                container.innerHTML = '';
                container.appendChild(iframe);

                const timeout = setTimeout(() => {
                    if (!iframe.dataset.loaded && attempt < 2) {
                        log(`🔁 Retrying ${type}...`);
                        return setTimeout(() => embedGeneric(url, type, attempt + 1), 1000);
                    } else if (!iframe.dataset.loaded) {
                        clearLoader();
                        window.open(url, '_blank');
                    }
                }, 8000);

                iframe.onload = () => {
                    clearTimeout(timeout);
                    iframe.dataset.loaded = 'true';
                    clearLoader();
                    log(`✅ ${type} loaded`);
                };
            }

            async function preloadVidmoly(url) {
                spoofVidmolyEnv();
                vidmolyIframe = document.createElement('iframe');
                vidmolyIframe.src = url;
                vidmolyIframe.allowFullscreen = true;
                vidmolyIframe.frameBorder = '0';
                vidmolyIframe.width = '100%';
                vidmolyIframe.height = heightMap.Vidmoly;
                vidmolyIframe.style.cssText = 'position:absolute;width:1px;height:1px;left:-9999px;top:-9999px;';
                vidmolyIframe.onload = () => {
                    vidmolyReady = true;
                    log('✅ Vidmoly iframe preloaded.');
                };
                document.body.appendChild(vidmolyIframe);
            }

            async function detectVidmoly() {
                spoofVidmolyEnv();
                const anchor = [...document.querySelectorAll('a.watchEpisode')].find(a => {
                    return a.querySelector('i.icon.Vidmoly');
                });
                const href = anchor?.getAttribute('href');
                if (!href) return;

                const url = new URL(href, location.origin);
                vidmolyUrl = url.href;
                await preloadVidmoly(vidmolyUrl);
            }

            document.addEventListener('click', async function(e) {
                const anchor = e.target.closest('a.watchEpisode');
                if (!anchor) return;
                const text = anchor.innerText.toLowerCase();
                const isVid = text.includes('vidmoly');
                const isLulu = text.includes('luluvdo');
                const isMoon = text.includes('filemoon');
                if (!isVid && !isLulu && !isMoon) return;

                e.preventDefault();
                const href = anchor.getAttribute('href');
                const type = isVid ? 'Vidmoly' : isLulu ? 'Luluvdo' : 'Filemoon';

                const fullUrl = new URL(href, location.origin).href;
                try {
                    if (type === 'Filemoon' && fullUrl.includes('/d/')) {
                        return embedGeneric(fullUrl.replace('/d/', '/e/'), type);
                    }
                    if (type === 'Vidmoly') {
                        vidmolyUrl = fullUrl;
                        return embedVidmoly();
                    }
                    return embedGeneric(fullUrl, type);
                } catch (err) {
                    console.warn('❌ Failed to load:', err);
                    alert(`Could not load ${type}`);
                }
            });

            function waitForElement(selector, opts = {}, cb) {
                const {
                    interval = 50, timeout = 10000
                } = opts;
                const start = Date.now();
                const timer = setInterval(() => {
                    const el = document.querySelector(selector);
                    if (el) {
                        clearInterval(timer);
                        cb(el);
                    } else if (Date.now() - start > timeout) {
                        clearInterval(timer);
                        console.warn(`[SmartLoader] ❌ Timed out waiting for ${selector}`);
                    }
                }, interval);
            }

            // wait until .watchEpisode buttons are loaded
            waitForElement('a.watchEpisode i.icon.Vidmoly', {
                timeout: 10000
            }, () => {
                log('🧠 Vidmoly <a> tag detected, calling detectVidmoly()');
                detectVidmoly();
            }); // 💥 this will now run after .inSiteWebStream is ready!


            async function checkIframeForLoadXWarning() {
                const iframe = document.querySelector('.inSiteWebStream iframe');
                if (!iframe || !iframe.src) {
                    setTimeout(checkIframeForLoadXWarning, 1000);
                    return;
                }

                const proxyUrl = `https://aniworld-to-cors-proxy.fly.dev/${iframe.src.replace(/^\/+/, '')}`;

                try {
                    const response = await fetch(proxyUrl);
                    const html = await response.text();

                    const hasWarning = html.includes("<h1>Warning</h1>") && html.includes("The video is not ready yet.");
                    const has404 = html.includes("<h1>404</h1>") || html.toLowerCase().includes("no video found");

                    if (!(hasWarning || has404)) return;

                    let providerOrder = ['0', '1', '2', '3', '4'];
                    try {
                        const raw = await GM.getValue("coreSettings");
                        if (raw) {
                            const parsed = JSON.parse(raw);
                            const dynamicOrder = parsed?.providersPriority;
                            if (Array.isArray(dynamicOrder) && dynamicOrder.length > 0) {
                                providerOrder = dynamicOrder;
                            }
                        }
                    } catch (e) {}

                    const loadXIndex = providerOrder.indexOf('0');
                    if (loadXIndex === -1) return;

                    for (let i = loadXIndex + 1; i < providerOrder.length; i++) {
                        const providerId = providerOrder[i];
                        const providerName = getHosterName(providerId);

                        const button = [...document.querySelectorAll('a.watchEpisode')]
                            .find(a => a.href.includes("/redirect/") && a.innerText.includes(providerName))
                            ?.querySelector('.hosterSiteVideoButton');

                        if (button) {
                            button.click();
                            await new Promise(resolve => setTimeout(resolve, 3000));

                            const iframe = document.querySelector('.inSiteWebStream iframe');
                            if (!iframe || !iframe.src) continue;

                            const proxyUrl = `https://aniworld-to-cors-proxy.fly.dev/${iframe.src.replace(/^\/+/, '')}`;
                            const response = await fetch(proxyUrl);
                            const html = await response.text();

                            const hasWarning = html.includes("<h1>Warning</h1>") && html.includes("The video is not ready yet.");
                            const has404 = html.includes("<h1>404</h1>") || html.toLowerCase().includes("no video found");

                            if (!hasWarning && !has404) {
                                return;
                            }
                        }
                    }

                } catch (err) {}
            }

            function getHosterName(id) {
                const map = {
                    '0': 'LoadX',
                    '1': 'VOE',
                    '2': 'SpeedFiles',
                    '3': 'Vidoza',
                    '4': 'Doodstream'
                };
                return map[id] || 'Unknown';
            }

            setTimeout(checkIframeForLoadXWarning, 150);

        })();
    });


    // Prevent volume scroll on player, allow page scroll, but still allow volume control
    window.addEventListener('wheel', function(e) {
        const volumeBar = e.target.closest('.vjs-volume-bar');
        const volumeIcon = e.target.closest('.vjs-mute-control');
        const playerWrapper = e.target.closest('.video-js');


        if ((volumeBar || volumeIcon)) return;

        if (playerWrapper) {
            e.stopImmediatePropagation();
        }
    }, {
        passive: false,
        capture: true
    });


    function detectDoubletap(element, callback, {
        maxIntervalMs = 300,
        tapsDistanceThresholdPx = 50,
        validPointerTypes = ['pen', 'touch'],
    } = {
        maxIntervalMs: 300,
        tapsDistanceThresholdPx: 50,
        validPointerTypes: ['pen', 'touch'],
    }) {
        let lastTapTime = 0;
        let lastTapX = 0;
        let lastTapY = 0;
        let tapped = false;

        element.addEventListener('pointerdown', (ev) => {
            if (!validPointerTypes.includes(ev.pointerType)) return;

            const currentTime = Date.now();
            const tapInterval = currentTime - lastTapTime;

            const distance = Math.sqrt(
                Math.pow(ev.clientX - lastTapX, 2) +
                Math.pow(ev.clientY - lastTapY, 2)
            );

            if (
                tapped &&
                tapInterval < maxIntervalMs &&
                distance <= tapsDistanceThresholdPx
            ) {
                callback(ev);
                tapped = false;
                lastTapTime = 0;
                lastTapX = 0;
                lastTapY = 0;
            } else {
                tapped = true;
                lastTapTime = currentTime;
                lastTapX = ev.clientX;
                lastTapY = ev.clientY;
            }
        });
    }

    function detectHold(element, callback, {
        holdTimeMs = 700,
        validPointerTypes = ['mouse', 'pen', 'touch'],
    } = {
        holdTimeMs: 700,
        validPointerTypes: ['mouse', 'pen', 'touch'],
    }) {
        let timer;

        const clearHold = () => clearTimeout(timer);
        const startHold = (ev) => {
            if (validPointerTypes.includes(ev.pointerType)) {
                timer = setTimeout(() => callback(), holdTimeMs);
            }
        };

        element.addEventListener('pointerdown', startHold);
        element.addEventListener('pointerup', clearHold);
        element.addEventListener('pointercancel', clearHold);
        element.addEventListener('pointerout', clearHold);
        element.addEventListener('pointerleave', clearHold);
    }

    function isEmbedded() {
        try {
            return window.top !== window.self;
        } catch {
            return true;
        }
    }

    function isNumeric(n) {
        return !isNaN(parseFloat(n)) && isFinite(n);
    }

    function makeId(length = 16) {
        const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
        let text = '';

        for (let i = 0; i < length; i++) {
            text += chars.charAt(Math.floor(Math.random() * chars.length));
        }

        return text;
    }

    async function sleep(ms = 0) {
        return new Promise(r => setTimeout(r, ms));
    }

    function waitForElement(query, {
        callbackOnTimeout = false,
        existing = false,
        onceOnly = false,
        rootElement = document.documentElement,
        timeout,

        // "attributes" prop is not supported
        observerOptions = {
            childList: true,
            subtree: true,
        },
    }, callback) {
        if (!query) throw new Error('Query is needed');
        if (!callback) throw new Error('Callback is needed');

        const handledElements = new WeakSet();
        const existingElements = rootElement.querySelectorAll(query);
        let timeoutId = null;

        if (existingElements.length) {
            // Mark all as handled for a proper work when `existing` is false
            // to ignore them later on
            for (const node of existingElements) {
                handledElements.add(node);
            }

            if (existing) {
                if (onceOnly) {
                    try {
                        callback(existingElements[0]);
                    } catch (e) {
                        console.error(e);
                    }

                    return;
                } else {
                    for (const node of existingElements) {
                        try {
                            callback(node);
                        } catch (e) {
                            console.error(e);
                        }
                    }
                }
            }
        }

        const observer = new MutationObserver((mutations, observer) => {
            for (const node of rootElement.querySelectorAll(query)) {
                if (handledElements.has(node)) continue;

                handledElements.add(node);

                try {
                    callback(node);
                } catch (e) {
                    console.error(e);
                }

                if (onceOnly) {
                    observer.disconnect();

                    if (timeoutId) clearTimeout(timeoutId);

                    return;
                }
            }
        });

        observer.observe(rootElement, {
            attributes: false,
            childList: observerOptions.childList || false,
            subtree: observerOptions.subtree || false,
        });

        if (timeout !== undefined) {
            timeoutId = setTimeout(() => {
                observer.disconnect();

                if (callbackOnTimeout) {
                    try {
                        callback(null);
                    } catch (e) {
                        console.error(e);
                    }
                }
            }, timeout);
        }

        return observer;
    }

    async function waitForUserInteraction() {
        return new Promise((resolve) => {
            const handler = () => {
                document.removeEventListener('pointerup', handler);
                document.removeEventListener('keydown', handler);

                resolve();
            };

            document.addEventListener('pointerup', handler, {
                once: true
            });
            document.addEventListener('keydown', handler, {
                once: true
            });
        });
    }



    // -------------------------------------- utils\ ---------------------------------------------

    /* CommLink.js
    - Version: 1.0.1
    - Author: Haka
    - Description: A userscript library for cross-window communication via the userscript storage
    - GitHub: https://github.com/AugmentedWeb/CommLink
    */
    class CommLinkHandler {
        constructor(commlinkID, configObj) {
            this.commlinkID = commlinkID;
            this.singlePacketResponseWaitTime = configObj?.singlePacketResponseWaitTime || 1500;
            this.maxSendAttempts = configObj?.maxSendAttempts || 3;
            this.statusCheckInterval = configObj?.statusCheckInterval || 1;
            this.silentMode = configObj?.silentMode || false;

            this.commlinkValueIndicator = 'commlink-packet-';
            this.commands = {};
            this.listeners = [];

            const missingGrants = [
                'GM_getValue',
                'GM_setValue',
                'GM_deleteValue',
                'GM_listValues',
            ].filter(grant => !GM_info.script.grant.includes(grant));

            if (missingGrants.length > 0 && !this.silentMode) {
                alert(
                    `[CommLink] The following userscript grants are missing: ${missingGrants.join(', ')}. CommLink will not work.`
                );
            }

            this.getStoredPackets()
                .filter(packet => Date.now() - packet.date > 2e4)
                .forEach(packet => this.removePacketByID(packet.id));
        }

        setIntervalAsync(callback, interval = this.statusCheckInterval) {
            let running = true;

            async function loop() {
                while (running) {
                    try {
                        await callback();
                        await new Promise((resolve) => setTimeout(resolve, interval));
                    } catch {
                        continue;
                    }
                }
            };

            loop();

            return {
                stop: () => {
                    running = false;
                    return false;
                }
            };
        }

        getUniqueID() {
            return ([1e7] + -1e3 + 4e3 + -8e3 + -1e11)
                .replace(/[018]/g, c => (c ^ crypto.getRandomValues(new Uint8Array(1))[0] & 15 >> c / 4).toString(16));
        }

        getCommKey(packetID) {
            return this.commlinkValueIndicator + packetID;
        }

        getStoredPackets() {
            return GM_listValues()
                .filter(key => key.includes(this.commlinkValueIndicator))
                .map(key => GM_getValue(key));
        }

        addPacket(packet) {
            GM_setValue(this.getCommKey(packet.id), packet);
        }

        removePacketByID(packetID) {
            GM_deleteValue(this.getCommKey(packetID));
        }

        findPacketByID(packetID) {
            return GM_getValue(this.getCommKey(packetID));
        }

        editPacket(newPacket) {
            GM_setValue(this.getCommKey(newPacket.id), newPacket);
        }

        send(platform, cmd, d) {
            return new Promise(async resolve => {
                const packetWaitTimeMs = this.singlePacketResponseWaitTime;
                const maxAttempts = this.maxSendAttempts;

                let attempts = 0;

                for (;;) {
                    attempts++;

                    const packetID = this.getUniqueID();
                    const attemptStartDate = Date.now();

                    const packet = {
                        command: cmd,
                        data: d,
                        date: attemptStartDate,
                        id: packetID,
                        sender: platform,
                    };

                    if (!this.silentMode) {
                        console.log(`[CommLink Sender] Sending packet! (#${attempts} attempt):`, packet);
                    }

                    this.addPacket(packet);

                    for (;;) {
                        const poolPacket = this.findPacketByID(packetID);
                        const packetResult = poolPacket?.result;

                        if (poolPacket && packetResult) {
                            if (!this.silentMode) {
                                console.log(`[CommLink Sender] Got result for a packet (${packetID}):`, packetResult);
                            }

                            resolve(poolPacket.result);

                            attempts = maxAttempts; // stop main loop

                            break;
                        }

                        if (!poolPacket || Date.now() - attemptStartDate > packetWaitTimeMs) {
                            break;
                        }

                        await new Promise(res => setTimeout(res, this.statusCheckInterval));
                    }

                    this.removePacketByID(packetID);

                    if (attempts === maxAttempts) break;
                }

                return resolve(null);
            });
        }

        registerSendCommand(name, obj) {
            this.commands[name] = async (data) => {
                return await this.send(obj?.commlinkID || this.commlinkID, name, obj?.data || data);
            };
        }

        registerListener(sender, commandHandler) {
            const listener = {
                sender,
                commandHandler,
                intervalObj: this.setIntervalAsync(this.receivePackets.bind(this), this.statusCheckInterval),
            };

            this.listeners.push(listener);
        }

        receivePackets() {
            this.getStoredPackets().forEach(packet => {
                this.listeners.forEach(listener => {
                    if (packet.sender === listener.sender && !packet.hasOwnProperty('result')) {
                        const result = listener.commandHandler(packet);

                        packet.result = result;

                        this.editPacket(packet);

                        if (!this.silentMode) {
                            if (packet.result === null) {
                                console.log('[CommLink Receiver] Possibly failed to handle packet:', packet);
                            } else {
                                console.log('[CommLink Receiver] Successfully handled a packet:', packet);
                            }
                        }
                    }
                });
            });
        }

        kill() {
            this.listeners.forEach(listener => listener.intervalObj.stop());
        }
    }


    class IframeMessenger {
        constructor() {
            this.commLink = null;
            this.topScopeId = null;
        }

        static get messages() {
            return {
                AUTOPLAY_NEXT: 'AUTOPLAY_NEXT',
                REQUEST_CURRENT_FRANCHISE_DATA: 'REQUEST_CURRENT_FRANCHISE_DATA',
                REQUEST_FULLSCREEN_STATE: 'REQUEST_FULLSCREEN_STATE',
                MARK_CURRENT_VIDEO_WATCHED: 'MARK_CURRENT_VIDEO_WATCHED',
                OPEN_HOTKEYS_GUIDE: 'OPEN_HOTKEYS_GUIDE',
                TOGGLE_FULLSCREEN: 'TOGGLE_FULLSCREEN',
                TOP_NOTIFLIX_REPORT_INFO: 'TOP_NOTIFLIX_REPORT_INFO',
                UPDATE_CORE_SETTINGS: 'UPDATE_CORE_SETTINGS',
            };
        }

        async initCrossFrameConnection() {
            const iframeId = makeId();
            const topScopeIdPromise = new Promise((resolve) => {
                // Top scope using GM_setValue will write its own id using iframeId as a key
                const valueChangeListenerId = GM_addValueChangeListener(iframeId, (
                    _key,
                    _oldValue,
                    newValue,
                ) => {
                    GM_removeValueChangeListener(valueChangeListenerId);
                    GM_deleteValue(iframeId);

                    resolve(newValue);
                });
            });

            // This should be almost immediately picked up by a top scope
            GM_setValue('unboundIframeId', iframeId);

            const topScopeId = await topScopeIdPromise;

            if (!iframeId || !topScopeId) throw new Error('Something went wrong');

            this.topScopeId = topScopeId;
            this.commLink = new CommLinkHandler(iframeId, {
                silentMode: true,
                statusCheckInterval: advancedSettings[ADVANCED_SETTINGS_MAP.commlinkPollingIntervalMs],
            });

            this.commLink.registerSendCommand(IframeMessenger.messages.AUTOPLAY_NEXT);
            this.commLink.registerSendCommand(IframeMessenger.messages.REQUEST_CURRENT_FRANCHISE_DATA);
            this.commLink.registerSendCommand(IframeMessenger.messages.REQUEST_FULLSCREEN_STATE);
            this.commLink.registerSendCommand(IframeMessenger.messages.MARK_CURRENT_VIDEO_WATCHED);
            this.commLink.registerSendCommand(IframeMessenger.messages.OPEN_HOTKEYS_GUIDE);
            this.commLink.registerSendCommand(IframeMessenger.messages.TOGGLE_FULLSCREEN);
            this.commLink.registerSendCommand(IframeMessenger.messages.TOP_NOTIFLIX_REPORT_INFO);
            this.commLink.registerSendCommand(IframeMessenger.messages.UPDATE_CORE_SETTINGS);
        }

        registerConnectionListener(callback) {
            return this.commLink.registerListener(this.topScopeId, callback);
        }

        sendMessage(message, msgData) {
            this.commLink.commands[message](msgData);

            return;
        }
    }

    class IframeInterface {
        constructor(messenger) {
            this.commLink = null;
            this.currentFranchiseId = null;
            this.currentVideoId = null;
            this.ignoreMissingFranchiseOnce = true;
            this.isInFullscreen = null;
            this.messenger = messenger;
            this.topScopeDomainId = '';

            coreSettings[CORE_SETTINGS_MAP.currentLargeSkipSizeS] = (
                advancedSettings[ADVANCED_SETTINGS_MAP.defaultLargeSkipSizeS]
            );

            coreSettings[CORE_SETTINGS_MAP.currentOutroSkipThresholdS] = (
                advancedSettings[ADVANCED_SETTINGS_MAP.defaultOutroSkipThresholdS]
            );
        }

        static get franchiseSpecificDataGMPrefix() {
            return 'franchiseSpecificData_';
        }

        static makePlaybackPositionGMKey(topScopeDomainId, episodeId) {
            if (!topScopeDomainId || !episodeId) throw new Error('Something is missing');

            return `playbackTimestamp_${topScopeDomainId}_${episodeId}`;
        }

        // It is better not to be async
        handleTopScopeMessages(packet) {
            (async function() {
                try {
                    switch (packet.command) {
                        case TopScopeInterface.messages.CURRENT_FRANCHISE_DATA: {
                            // At least one value is going to be present
                            this.currentVideoId = packet.data.currentVideoId || null;
                            this.topScopeDomainId = packet.data.topScopeDomainId || '';

                            if (packet.data.currentFranchiseId) {
                                this.currentFranchiseId = packet.data.currentFranchiseId;

                                const {
                                    largeSkipSizeS,
                                    outroSkipThresholdS
                                } = GM_getValue(
                                    `${IframeInterface.franchiseSpecificDataGMPrefix}${this.currentFranchiseId}`
                                ) || {};

                                if (isNumeric(largeSkipSizeS)) {
                                    coreSettings[CORE_SETTINGS_MAP.currentLargeSkipSizeS] = largeSkipSizeS;
                                } else {
                                    coreSettings[CORE_SETTINGS_MAP.currentLargeSkipSizeS] = (
                                        advancedSettings[ADVANCED_SETTINGS_MAP.defaultLargeSkipSizeS]
                                    );
                                }

                                if (isNumeric(outroSkipThresholdS)) {
                                    coreSettings[CORE_SETTINGS_MAP.currentOutroSkipThresholdS] = outroSkipThresholdS;
                                } else {
                                    coreSettings[CORE_SETTINGS_MAP.currentOutroSkipThresholdS] = (
                                        advancedSettings[ADVANCED_SETTINGS_MAP.defaultOutroSkipThresholdS]
                                    );
                                }

                                this.settingsPane?.refresh();
                                this.ignoreMissingFranchiseOnce = false;
                            }

                            break;
                        }

                        case TopScopeInterface.messages.FULLSCREEN_STATE: {
                            if (IS_SAFARI) break;

                            this.isInFullscreen = packet.data.isInFullscreen;
                            this.updateFullscreenBtn({
                                isInFullscreen: this.isInFullscreen
                            });

                            break;
                        }

                        default:
                            break;
                    }
                } catch (e) {
                    console.error(e);
                }
            }.bind(this)());

            return {
                status: `${this.constructor.name} received a message`,
            };
        }

        async init(player) {
            this.messenger.registerConnectionListener(this.handleTopScopeMessages.bind(this));

            this.messenger.sendMessage(IframeMessenger.messages.REQUEST_CURRENT_FRANCHISE_DATA);

            await this.preparePlayer(player);
        }


        createAutoplayButton() {
            const button = document.createElement('button');
            const toggleContainer = document.createElement('div');
            const toggleDot = document.createElement('div');
            const isAutoplayEnabled = coreSettings[CORE_SETTINGS_MAP.isAutoplayEnabled];
            let lastClickTime = 0;

            button.addEventListener('click', () => {
                const now = Date.now();

                // Prevent double-clicks unwanted behavior
                if (now - lastClickTime < 300) return;

                lastClickTime = now;

                if (!GM_getValue('firstRunTextWasShown')) {
                    GM_setValue('firstRunTextWasShown', true);

                    this.messenger.sendMessage(IframeMessenger.messages.TOP_NOTIFLIX_REPORT_INFO, {
                        args: [
                            `${GM_info.script.name} info`,
                            `${IS_MOBILE ? 'Hold-release' : 'Right click'} the toggle button to open autoplay settings. ${IS_MOBILE ? '' : `Press "${hotkeysSettings[HOTKEYS_SETTINGS_MAP.largeSkip]}" when an intro starts to skip it. `}Fullscreen is scrollable, allowing to switch providers on the go`,
                            'Okay', {
                                delayedButton: true,
                            },
                        ],
                    });
                }

                const wasEnabled = coreSettings[CORE_SETTINGS_MAP.isAutoplayEnabled];

                coreSettings[CORE_SETTINGS_MAP.isAutoplayEnabled] = !wasEnabled;

                button.setAttribute('aria-checked', (!wasEnabled).toString());
                button.title = (
                    !isAutoplayEnabled ? 'Autoplay is disabled' : 'Autoplay is enabled'
                );

                toggleDot.style.backgroundColor = wasEnabled ? '#e1e1e1' : '#fff';
                toggleDot.style.transform = wasEnabled ? 'translateX(0px)' : 'translateX(12px)';
            });

            button.type = 'button';
            button.title = (
                !isAutoplayEnabled ? 'Autoplay is disabled' : 'Autoplay is enabled'
            );
            button.appendChild(toggleContainer);
            button.setAttribute('aria-checked', (isAutoplayEnabled).toString());
            button.className = 'Autoplay-button';

            toggleContainer.className = 'Autoplay-button--toggle';
            toggleContainer.appendChild(toggleDot);

            toggleDot.className = 'Autoplay-button--toggle-dot';
            toggleDot.style.backgroundColor = !isAutoplayEnabled ? '#e1e1e1' : '#fff';
            toggleDot.style.transform = (
                !isAutoplayEnabled ? 'translateX(0px)' : 'translateX(12px)'
            );

            GM_addStyle([`
        .Autoplay-button {
          width: 36px;
          height: 36px;
          padding: 0;
          border-radius: 50%;
          border: none;
          background: none;
          cursor: pointer;
          top: 0;
          left: 0;
          transition: all 0.2s ease;
          user-select: none;
          -webkit-user-select: none;
        }

        .Autoplay-button[aria-checked="true"] .Autoplay-button--toggle-dot {
          transform: translateX(12px);
        }

        .Autoplay-button--toggle {
          width: 24px;
          height: 12px;
          margin-bottom: 3px;
          background-color: rgba(221, 221, 221, 0.5);
          border-radius: 6px;
          position: relative;
          display: inline-block;
        }

        .Autoplay-button--toggle-dot {
          width: 12px;
          height: 12px;
          background-color: #e1e1e1;
          border-radius: 50%;
          position: absolute;
          top: 0;
          left: 0;
          transition: all 0.2s ease;
        }
      `][0]);

            return button;
        }

        createSettingsPane() {
            const pane = new Tweakpane.Pane();

            pane.hidden = true;

            pane.on('change', () => {
                this.messenger.sendMessage(IframeMessenger.messages.UPDATE_CORE_SETTINGS);
            });

            document.body.addEventListener('click', (ev) => {
                if (!ev.target.closest('div.tp-dfwv')) pane.hidden = true;
            });

            GM_addStyle([
                // Main container
                `
          .tp-dfwv {
            --tp-font-family: sans-serif;
            width: 400px;
            max-width: 100%;
            top: 0;
            right: 0;
            z-index: 99999;
          }
        `,

                // A container one level below the main one
                `
          .tp-rotv {
            max-height: 85vh;
            font-size: 12px;
            overflow-y: scroll;
            scrollbar-width: thin;
            scrollbar-color: #6b6c73 #37383d;
          }
        `,

                // Any text input
                `
          .tp-txtv_i, .tp-sglv_i {
            font-size: 14px !important;
            padding: 0 8px !important;
            color: var(--in-fg) !important;
            background-color: var(--in-bg) !important;
            opacity: 1 !important;
          }
        `,

                // Checkboxes
                `
          .tp-ckbv_w {
            width: 80%;
            margin: auto;
          }
        `,
            ].join(' '));

            // Stop leaking events to the player
            (['keydown', 'keyup', 'keypress'].forEach(event =>
                pane.element.addEventListener(event, (e) => e.stopPropagation())
            ));

            const assignTooltip = (text, object) => {
                object.element.title = text;

                if (
                    object.element.firstElementChild.matches &&
                    object.element.firstElementChild.matches('div.tp-lblv_l')
                ) {
                    object.element.firstElementChild.addEventListener('click', (ev) => {
                        if (!['pen', 'touch'].includes(ev.pointerType)) return;

                        this.messenger.sendMessage(IframeMessenger.messages.TOP_NOTIFLIX_REPORT_INFO, {
                            args: [object.element.firstElementChild.innerText, text, 'Close', {
                                backOverlayClickToClose: true,
                            }],
                        });
                    });
                }
            };

            const tabs = pane.addTab({
                pages: [{
                        title: 'Preferences'
                    },
                    {
                        title: 'Advanced'
                    },
                ],
            });

            const mainTab = tabs.pages[0];
            const advancedTab = tabs.pages[1];

            const mainTabApplyBtn = mainTab.addButton({
                disabled: true,
                title: 'Apply',
            });

            const advancedTabApplyBtn = advancedTab.addButton({
                disabled: true,
                title: 'Apply',
            });

            for (const btn of [mainTabApplyBtn, advancedTabApplyBtn]) {
                btn.on('click', () => {
                    setTimeout(() => {
                        mainTabApplyBtn.disabled = true;
                        advancedTabApplyBtn.disabled = true;
                    });
                });
            }

            pane.element.addEventListener('click', () => {
                mainTabApplyBtn.disabled = false;
                advancedTabApplyBtn.disabled = false;
            });

            const priorityFolder = mainTab.addFolder({
                title: 'Providers priority'
            });

            (() => {
                const ids = coreSettings[CORE_SETTINGS_MAP.providersPriority];
                const buttons = [];

                ids.forEach((id, index) => {
                    const button = priorityFolder.addButton({
                        title: `⬆ ${index + 1}) ${VIDEO_PROVIDERS_IDS[id]}`,
                    });

                    button.on('click', () => {
                        if (index > 0) {
                            [ids[index], ids[index - 1]] = [ids[index - 1], ids[index]];

                            coreSettings[CORE_SETTINGS_MAP.providersPriority] = ids;

                            ids.forEach((id, index) => {
                                buttons[index].title = `⬆ ${index + 1}) ${VIDEO_PROVIDERS_IDS[id]}`;
                            });

                            this.messenger.sendMessage(IframeMessenger.messages.UPDATE_CORE_SETTINGS);
                        }
                    });

                    buttons.push(button);
                });
            })();

            const miscellaneousMainFolder = mainTab.addFolder({
                title: 'Miscellaneous'
            });

            miscellaneousMainFolder.on('change', (ev) => {
                if (!ev.last) return;

                if (
                    typeof ev.value === 'string' &&
                    MAIN_SETTINGS_MAP[ev.presetKey]
                ) {
                    mainSettings[ev.presetKey] = mainSettings[ev.presetKey].trim();
                    ev.target.refresh();
                }
            });

            assignTooltip((
                'Seamless autoplay is not always available due to browser restrictions. This setting makes autoplay muted which in turn makes autoplay to be always available (autoplay should be enabled for this to work), but instead it requires user input (click or keypress) to unmute. Keypress works only if a video player is in focus'
            ), miscellaneousMainFolder.addInput(mainSettings,
                MAIN_SETTINGS_MAP.shouldAutoplayMuted, {
                    label: 'Persistent muted autoplay',
                },
            ));

            assignTooltip((
                'Automatically skips the beginning of a video when it starts. Enable this to activate the skip feature.'
            ), miscellaneousMainFolder.addInput(coreSettings,
                CORE_SETTINGS_MAP.shouldAutoSkipOnStart, {
                    label: 'Auto-skip at start',
                },
            ));

            assignTooltip((
                'Saves the last playback position and restores it whenever the video player is reloaded'
            ), miscellaneousMainFolder.addInput(mainSettings,
                MAIN_SETTINGS_MAP.playbackPositionMemory, {
                    label: 'Playback position memory',
                },
            ));

            assignTooltip((
                'Number of seconds to skip from the beginning when auto-skip is enabled.'
            ), miscellaneousMainFolder.addInput(coreSettings,
                CORE_SETTINGS_MAP.autoSkipSecondsOnStart, {
                    step: 1,
                    min: 0,
                    label: 'Skip seconds on start',
                },
            ));



            if (IS_MOBILE || advancedSettings[ADVANCED_SETTINGS_MAP.showDeviceSpecificSettings]) {
                assignTooltip((
                    'If enabled, default double-tap behavior (if any) is being overrided: double-tap right/left side of a video player to fast forward/rewind. Double-tap in a middle applies an intro skip. Page reload is required for this setting to take effect!'
                ), miscellaneousMainFolder.addInput(mainSettings,
                    MAIN_SETTINGS_MAP.overrideDoubletapBehavior, {
                        label: 'Override double-tap behavior*',
                    },
                ));
            }

            (() => {
                for (const {
                        settingKey,
                        errName,
                        inputOptions,
                        tooltip,
                    }
                    of [{
                            settingKey: CORE_SETTINGS_MAP.currentLargeSkipSizeS,
                            errName: 'Intro skip size',
                            inputOptions: {
                                step: 1,
                                min: 0,
                                label: 'Intro skip size, sec',
                            },
                            tooltip: (
                                'Intro skip size. This is linked to the title and should stay the same across episodes'
                            ),
                        },

                        {
                            settingKey: CORE_SETTINGS_MAP.currentOutroSkipThresholdS,
                            errName: 'Outro skip threshold',
                            inputOptions: {
                                step: 1,
                                min: 0.5,
                                label: 'Outro skip threshold, sec',
                            },
                            tooltip: (
                                'Autoplay triggers when the video player has fewer than THIS number of seconds left to play. It is linked to the title and should stay the same across episodes'
                            ),
                        },
                    ]) {
                    const input = (
                        miscellaneousMainFolder.addInput(coreSettings, settingKey, inputOptions)
                    );

                    assignTooltip((tooltip), input);

                    input.on('change', (ev) => {
                        if (!ev.last) return;

                        if (!this.currentFranchiseId) {
                            // This is needed because 'change' event is being triggered by pane.refresh()
                            // that is called from CURRENT_FRANCHISE_DATA message handler
                            if (this.ignoreMissingFranchiseOnce) {
                                this.ignoreMissingFranchiseOnce = false;
                                return;
                            }

                            Notiflixx.notify.failure(
                                `${GM_info.script.name}: There was an error when trying to save the "${errName}". The value would reset upon player reload. Please, report the bug, with a mention of a URL of the page you're currently on`
                            );

                            return;
                        }

                        GM_setValue((
                            `${IframeInterface.franchiseSpecificDataGMPrefix}${this.currentFranchiseId}`
                        ), {
                            largeSkipSizeS: coreSettings[CORE_SETTINGS_MAP.currentLargeSkipSizeS],
                            outroSkipThresholdS: coreSettings[CORE_SETTINGS_MAP.currentOutroSkipThresholdS],
                        });
                    });
                }
            })();

            miscellaneousMainFolder.addButton({
                title: 'Reset to defaults',
            }).on('click', () => {
                mainSettings.update(MAIN_SETTINGS_DEFAULTS);

                coreSettings[CORE_SETTINGS_MAP.currentLargeSkipSizeS] = (
                    advancedSettings[ADVANCED_SETTINGS_MAP.defaultLargeSkipSizeS]
                );

                coreSettings[CORE_SETTINGS_MAP.currentOutroSkipThresholdS] = (
                    advancedSettings[ADVANCED_SETTINGS_MAP.defaultOutroSkipThresholdS]
                );

                this.currentFranchiseId && GM_deleteValue(
                    `${IframeInterface.franchiseSpecificDataGMPrefix}${this.currentFranchiseId}`
                );

                pane.refresh();
            });

            if (!IS_MOBILE || advancedSettings[ADVANCED_SETTINGS_MAP.showDeviceSpecificSettings]) {
                const hotkeysFolder = advancedTab.addFolder({
                    title: 'Hotkeys',
                    expanded: !IS_MOBILE
                });

                hotkeysFolder.on('change', (ev) => {
                    if (!ev.last) return;

                    if (
                        typeof ev.value === 'string' &&
                        HOTKEYS_SETTINGS_MAP[ev.presetKey]
                    ) {
                        hotkeysSettings[ev.presetKey] = hotkeysSettings[ev.presetKey].trim().toLowerCase();
                        ev.target.refresh();
                    }
                });

                assignTooltip((
                    'Hotkey for a fast backward. Page reload is required for this setting to take effect!'
                ), hotkeysFolder.addInput(hotkeysSettings,
                    HOTKEYS_SETTINGS_MAP.fastBackward, {
                        label: 'Fast backward*',
                    },
                ));

                assignTooltip((
                    'Hotkey for a fast forward. Page reload is required for this setting to take effect!'
                ), hotkeysFolder.addInput(hotkeysSettings,
                    HOTKEYS_SETTINGS_MAP.fastForward, {
                        label: 'Fast forward*',
                    },
                ));

                assignTooltip((
                    'Hotkey for a fullscreen mode toggle. Page reload is required for this setting to take effect!'
                ), hotkeysFolder.addInput(hotkeysSettings,
                    HOTKEYS_SETTINGS_MAP.fullscreen, {
                        label: 'Fullscreen*',
                    },
                ));

                assignTooltip((
                    'Hotkey for an intro skip. Page reload is required for this setting to take effect!'
                ), hotkeysFolder.addInput(hotkeysSettings,
                    HOTKEYS_SETTINGS_MAP.largeSkip, {
                        label: 'Intro skip*',
                    },
                ));

                const hotkeysGuideBtn = hotkeysFolder.addButton({
                    title: 'Hotkeys guide'
                });

                hotkeysGuideBtn.on('click', () => {
                    this.messenger.sendMessage(IframeMessenger.messages.OPEN_HOTKEYS_GUIDE);
                });

                hotkeysFolder.addButton({
                    title: 'Reset to defaults',
                }).on('click', () => {
                    hotkeysSettings.update(HOTKEYS_SETTINGS_DEFAULTS);
                    pane.refresh();
                });
            }

            const miscellaneousAdvancedFolder = advancedTab.addFolder({
                title: 'Miscellaneous'
            });

            miscellaneousAdvancedFolder.on('change', (ev) => {
                if (!ev.last) return;

                if (
                    typeof ev.value === 'string' &&
                    ADVANCED_SETTINGS_MAP[ev.presetKey]
                ) {
                    advancedSettings[ev.presetKey] = advancedSettings[ev.presetKey].trim();
                    ev.target.refresh();
                }
            });

            assignTooltip((
                'Default intro skip size'
            ), miscellaneousAdvancedFolder.addInput(advancedSettings,
                ADVANCED_SETTINGS_MAP.defaultLargeSkipSizeS, {
                    step: 1,
                    min: 0,
                    label: 'Default intro skip size, sec',
                },
            ));

            assignTooltip((
                'Default outro skip threshold'
            ), miscellaneousAdvancedFolder.addInput(advancedSettings,
                ADVANCED_SETTINGS_MAP.defaultOutroSkipThresholdS, {
                    step: 1,
                    min: 0.5,
                    label: 'Default outro skip threshold, sec',
                },
            ));

            assignTooltip((
                'Number of seconds of approximate playback time after which a video is being marked as watched. Set to 0 to disable and mark only by a triggered autoplay'
            ), miscellaneousAdvancedFolder.addInput(advancedSettings,
                ADVANCED_SETTINGS_MAP.markWatchedAfterS, {
                    step: 1,
                    min: 0,
                    label: 'Mark watched after, sec',
                },
            ));

            assignTooltip((
                'Number of seconds to skip or rewind using double-taps or pressing a corresponding hotkeys'
            ), miscellaneousAdvancedFolder.addInput(advancedSettings,
                ADVANCED_SETTINGS_MAP.fastForwardSizeS, {
                    step: 1,
                    min: 0,
                    label: 'Fast forward size, sec',
                },
            ));

            assignTooltip((
                'Intro skip also starts playback'
            ), miscellaneousAdvancedFolder.addInput(advancedSettings,
                ADVANCED_SETTINGS_MAP.playOnLargeSkip, {
                    label: 'Play on intro skip',
                },
            ));

            assignTooltip((
                'Show settings that usually have no use on your device. For example, if you\'re on mobile, hotkeys settings are hidden by default because there is no PC keyboard on mobile. Page reload is required for this setting to take effect!'
            ), miscellaneousAdvancedFolder.addInput(advancedSettings,
                ADVANCED_SETTINGS_MAP.showDeviceSpecificSettings, {
                    label: 'Show device specific settings*',
                },
            ));

            if (IS_MOBILE || advancedSettings[ADVANCED_SETTINGS_MAP.showDeviceSpecificSettings]) {
                assignTooltip((
                    'Adjusts the maximum time (in milliseconds) allowed between two taps for them to be recognized as a double-tap. A lower value requires faster taps, while a higher value allows more delay. Page reload is required for this setting to take effect!'
                ), miscellaneousAdvancedFolder.addInput(advancedSettings,
                    ADVANCED_SETTINGS_MAP.doubletapTimingThresholdMs, {
                        step: 20,
                        min: 100,
                        max: 1000,
                        label: 'Double-tap timing threshold, ms*',
                    },
                ));
            }

            if (IS_MOBILE || advancedSettings[ADVANCED_SETTINGS_MAP.showDeviceSpecificSettings]) {
                assignTooltip((
                    'Defines the maximum distance (in pixels) between two taps for them to be considered a double-tap. A smaller value requires taps to be closer together, while a larger value allows more separation. Page reload is required for this setting to take effect!'
                ), miscellaneousAdvancedFolder.addInput(advancedSettings,
                    ADVANCED_SETTINGS_MAP.doubletapDistanceThresholdPx, {
                        step: 10,
                        min: 10,
                        max: 5000,
                        label: 'Double-tap distance threshold, px*',
                    },
                ));
            }

            if (!IS_MOBILE || advancedSettings[ADVANCED_SETTINGS_MAP.showDeviceSpecificSettings]) {
                assignTooltip((
                    'Cooldown for an intro skip hotkey, to prevent an accidental double skip. Page reload is required for this setting to take effect!'
                ), miscellaneousAdvancedFolder.addInput(advancedSettings,
                    ADVANCED_SETTINGS_MAP.largeSkipCooldownMs, {
                        step: 1,
                        min: 0,
                        label: 'Intro skip cooldown, ms*',
                    },
                ));
            }

            assignTooltip((
                'How many DAYS need to pass before a playback position is removed from the memory'
            ), miscellaneousAdvancedFolder.addInput(advancedSettings,
                ADVANCED_SETTINGS_MAP.playbackPositionExpirationDays, {
                    step: 1,
                    min: 1,
                    max: 365,
                    label: 'Playback position expiration',
                },
            ));

            assignTooltip((
                'To keep possible VOE-to-VOE unmuted autoplay working, the script needs to route a very small number of web requests through its own proxy server. Leave the input empty to disable this or set your own proxy'
            ), miscellaneousAdvancedFolder.addInput(advancedSettings,
                ADVANCED_SETTINGS_MAP.corsProxy, {
                    label: 'CORS proxy',
                },
            ));

            assignTooltip((
                'Reflects messaging responsiveness between a player and a top scope. Might impact CPU usage if set too low. 40 should be enough. Page reload is required for this setting to take effect!'
            ), miscellaneousAdvancedFolder.addInput(advancedSettings,
                ADVANCED_SETTINGS_MAP.commlinkPollingIntervalMs, {
                    step: 10,
                    min: 10,
                    max: 500,
                    label: 'Commlink polling interval, ms*',
                },
            ));

            miscellaneousAdvancedFolder.addButton({
                title: 'Reset to defaults',
            }).on('click', () => {
                advancedSettings.update(ADVANCED_SETTINGS_DEFAULTS);
                pane.refresh();
            });

            return pane;
        }

        async handleAutoplay(player) {
            if (!coreSettings[CORE_SETTINGS_MAP.isAutoplayEnabled]) return;

            let muteWasApplied = false;

            // If play fails it tries to fix it but throws the problem error anyway
            const playOrFix = async () => {
                try {
                    await player.play();

                } catch (e) {
                    if (e.name === 'NotAllowedError') {
                        // Muted usually is allowed to play,
                        // and if it's not allowed, nothing could be done here
                        if (player.muted) {
                            console.error('Muted and not allowed');
                            throw e;
                        }

                        if (mainSettings[MAIN_SETTINGS_MAP.shouldAutoplayMuted] && !muteWasApplied) {
                            player.muted = true;
                            muteWasApplied = true;

                            // Restore setting altered by forced mute. See this.setupPersistentVolume()
                            setTimeout(() => (coreSettings[CORE_SETTINGS_MAP.isMuted] = false));

                            // Should not be awaited
                            (async () => {
                                await waitForUserInteraction();

                                // If interaction was unmute button, try to not overtake it
                                // because it might result in mute -> unmute -> mute again.
                                // Different players require a different delay
                                await sleep(100);

                                if (player.muted) player.muted = false;
                            })();
                        }
                    }

                    throw e;
                }
            };

            const startTime = Date.now();
            let lastError = null;

            while ((Date.now() - startTime) < (10 * 1000)) {
                try {
                    await sleep(200);
                    await playOrFix();

                    return;
                } catch (e) {
                    lastError = e;
                }
            }

            throw lastError;
        }

        setupDoubletapBehavior(player, doubletapTarget = player) {
            if (!mainSettings[MAIN_SETTINGS_MAP.overrideDoubletapBehavior]) return;

            detectDoubletap(doubletapTarget, (ev) => {
                const xViewport = ev.clientX;
                const rect = ev.target.getBoundingClientRect();

                // Get X relative to the target just in case.
                // It is not really needed since the player takes the whole size of an iframe
                const xTarget = xViewport - rect.left;

                if (xTarget < rect.width * 0.35) {
                    if (advancedSettings[ADVANCED_SETTINGS_MAP.fastForwardSizeS]) {
                        player.currentTime -= advancedSettings[ADVANCED_SETTINGS_MAP.fastForwardSizeS];
                    }
                } else if (xTarget > rect.width - (rect.width * 0.35)) {
                    if (advancedSettings[ADVANCED_SETTINGS_MAP.fastForwardSizeS]) {
                        player.currentTime += advancedSettings[ADVANCED_SETTINGS_MAP.fastForwardSizeS];
                    }
                } else {
                    if (coreSettings[CORE_SETTINGS_MAP.currentLargeSkipSizeS]) {
                        player.currentTime += coreSettings[CORE_SETTINGS_MAP.currentLargeSkipSizeS];

                        if (advancedSettings[ADVANCED_SETTINGS_MAP.playOnLargeSkip]) {
                            player.play();
                        }
                    }
                }
            }, {
                maxIntervalMs: advancedSettings[ADVANCED_SETTINGS_MAP.doubletapTimingThresholdMs],
                tapsDistanceThresholdPx: (
                    advancedSettings[ADVANCED_SETTINGS_MAP.doubletapDistanceThresholdPx]
                ),
            });
        }

        setupHotkeys(player) {
            keyboardJS.bind('space', () => player.paused ? player.play() : player.pause());

            if (hotkeysSettings[HOTKEYS_SETTINGS_MAP.fastForward]) {
                keyboardJS.bind(hotkeysSettings[HOTKEYS_SETTINGS_MAP.fastForward], () => {
                    if (advancedSettings[ADVANCED_SETTINGS_MAP.fastForwardSizeS]) {
                        player.currentTime += advancedSettings[ADVANCED_SETTINGS_MAP.fastForwardSizeS];
                    }
                });
            }

            if (hotkeysSettings[HOTKEYS_SETTINGS_MAP.fastBackward]) {
                keyboardJS.bind(hotkeysSettings[HOTKEYS_SETTINGS_MAP.fastBackward], () => {
                    if (advancedSettings[ADVANCED_SETTINGS_MAP.fastForwardSizeS]) {
                        player.currentTime -= advancedSettings[ADVANCED_SETTINGS_MAP.fastForwardSizeS];
                    }
                });
            }

            if (hotkeysSettings[HOTKEYS_SETTINGS_MAP.fullscreen]) {
                keyboardJS.bind(hotkeysSettings[HOTKEYS_SETTINGS_MAP.fullscreen], (ev) => {
                    ev.preventRepeat();
                    this.messenger.sendMessage(IframeMessenger.messages.TOGGLE_FULLSCREEN);
                });
            }

            if (hotkeysSettings[HOTKEYS_SETTINGS_MAP.largeSkip]) {
                const cooldownTime = advancedSettings[ADVANCED_SETTINGS_MAP.largeSkipCooldownMs];
                let lastSkipTime = 0;

                keyboardJS.bind(hotkeysSettings[HOTKEYS_SETTINGS_MAP.largeSkip], () => {
                    if (coreSettings[CORE_SETTINGS_MAP.currentLargeSkipSizeS]) {
                        const now = Date.now();

                        if (now - lastSkipTime < cooldownTime) return;

                        lastSkipTime = now;

                        player.currentTime += coreSettings[CORE_SETTINGS_MAP.currentLargeSkipSizeS];

                        if (advancedSettings[ADVANCED_SETTINGS_MAP.playOnLargeSkip]) {
                            player.play();
                        }
                    }
                });
            }
        }

        setupOutroSkipHandling(player) {
            let outroHasBeenReached = false;

            setInterval(() => {
                if (outroHasBeenReached || !coreSettings[CORE_SETTINGS_MAP.isAutoplayEnabled]) return;

                const timeLeft = player.duration - player.currentTime;

                if (timeLeft <= coreSettings[CORE_SETTINGS_MAP.currentOutroSkipThresholdS]) {
                    outroHasBeenReached = true;
                    this.messenger.sendMessage(IframeMessenger.messages.AUTOPLAY_NEXT);
                }
            }, 250);
        }

        setupPersistentVolume(player) {
            player.muted = coreSettings[CORE_SETTINGS_MAP.isMuted];
            player.volume = coreSettings[CORE_SETTINGS_MAP.persistentVolumeLvl];

            player.addEventListener('volumechange', () => {
                coreSettings[CORE_SETTINGS_MAP.isMuted] = player.muted;
                coreSettings[CORE_SETTINGS_MAP.persistentVolumeLvl] = player.volume;
            });
        }

        setupWatchedStateLabeling(player) {
            const intervalMs = 250;
            let approximatePlayTimeS = 0;
            let currentVideoWasWatched = false;
            let lastPlayerTime = player.currentTime;

            setInterval(() => {
                if (player.currentTime === lastPlayerTime) return;

                lastPlayerTime = player.currentTime;
                approximatePlayTimeS += intervalMs / 1000;

                if (
                    !currentVideoWasWatched &&
                    advancedSettings[ADVANCED_SETTINGS_MAP.markWatchedAfterS] &&
                    approximatePlayTimeS >= advancedSettings[ADVANCED_SETTINGS_MAP.markWatchedAfterS]
                ) {
                    currentVideoWasWatched = true;
                    this.messenger.sendMessage(IframeMessenger.messages.MARK_CURRENT_VIDEO_WATCHED);
                }
            }, intervalMs);
        }

        async setupVideoPlaybackPositionMemory(player) {
            const self = this;

            await (async function waitForVideoData(start = Date.now()) {
                if (!self.currentVideoId || !self.topScopeDomainId) {
                    if ((Date.now() - start) > (10 * 1000)) {
                        throw new Error('Video data didn\'t arrive in time');
                    }

                    await sleep();

                    return waitForVideoData(start);
                }
            }());

            // This has to wait indefinitely because players like VOE do not have the value
            // until the play button has been pressed or an autoplay has been triggered
            await (async function waitForVideoDuration() {
                if (!player.duration) {
                    await sleep();
                    return waitForVideoDuration();
                }
            }());

            const timestampDataGMKey = (
                IframeInterface.makePlaybackPositionGMKey(this.topScopeDomainId, this.currentVideoId)
            );
            const timestampData = GM_getValue(timestampDataGMKey, {});

            if (timestampData.value) {
                const elapsedTime = Date.now() - timestampData.updateDate;
                const expirationThreshold = advancedSettings[
                    ADVANCED_SETTINGS_MAP.playbackPositionExpirationDays
                ] * 24 * 60 * 60 * 1000;

                if (elapsedTime < expirationThreshold) {
                    const outroSkipThresholdS = coreSettings[CORE_SETTINGS_MAP.currentOutroSkipThresholdS];
                    const potentialTimeLeftToPlay = player.duration - timestampData.value;

                    // Skip saved playback position if it's in a range of (outroSkipThresholdS + 20)
                    if (potentialTimeLeftToPlay > (outroSkipThresholdS + 20)) {
                        player.currentTime = timestampData.value;
                    }
                }
            }

            let lastCheckedTime = player.currentTime;

            setInterval(() => {
                if (
                    !mainSettings[MAIN_SETTINGS_MAP.playbackPositionMemory] ||
                    (player.currentTime === lastCheckedTime)
                ) return;

                lastCheckedTime = player.currentTime;

                GM_setValue(timestampDataGMKey, {
                    value: lastCheckedTime,
                    updateDate: Date.now(),
                });
            }, 1000);
        }
    }


    class DoodstreamIframeInterface extends IframeInterface {
        constructor(messenger) {
            super(messenger);

            const unwantedElements = [
                '#checkresume_div', // prompt to restore built in playback position memory
                'div[style*="z-index: 2147483647"]', // ads
            ];

            // Remove on-screen controls to avoid double-tap conflicts
            if (IS_MOBILE && mainSettings[MAIN_SETTINGS_MAP.overrideDoubletapBehavior]) {
                unwantedElements.push('button.vjs-seek-button');
            }

            waitForElement(unwantedElements.join(', '), {
                existing: true,
            }, (el) => el.remove());

            (function() {
                const originalAddEventListener = EventTarget.prototype.addEventListener;

                EventTarget.prototype.addEventListener = function(type, listener, options) {
                    // Get rid of ads
                    if (
                        ['click', 'mousedown', 'mouseup', 'contextmenu'].includes(type) &&
                        (this === document || this === unsafeWindow)
                    ) {
                        return;
                    }

                    return originalAddEventListener.call(this, type, listener, options);
                };
            }());
        }

        static get queries() {
            return {
                fullscreenBtn: 'button.vjs-fullscreen-control',
                player: 'video#video_player_html5_api',
            };
        }

        async preparePlayer(player) {
            this.setupDoubletapBehavior(player);
            this.setupHotkeys(player);
            this.setupOutroSkipHandling(player);
            this.setupWatchedStateLabeling(player);
            this.setupVideoPlaybackPositionMemory(player);
            this.restylePlayer(player);

            let hasSkippedInitial = false;

            player.addEventListener('timeupdate', function skipFirst30s() {
                if (!hasSkippedInitial && player.currentTime < 30) {
                    player.currentTime = 30;
                    hasSkippedInitial = true;
                }
            });

            this.setupPersistentVolume(player);
            this.handleAutoplay(player); // should go after setupPersistentVolume

            // Attach autoplay button and change fullscreen button behavior...
            waitForElement(DoodstreamIframeInterface.queries.fullscreenBtn, {
                existing: true,
                onceOnly: true,
            }, (fsBtn) => {
                // Prevent focused buttons from being toggled by pressing space/enter
                fsBtn.parentElement.addEventListener('keydown', (ev) => ev.preventDefault());
                fsBtn.parentElement.addEventListener('keyup', (ev) => ev.preventDefault());

                const newFsBtn = fsBtn.cloneNode(true);
                const autoplayBtn = this.createAutoplayButton();
                const settingsPane = this.settingsPane = this.createSettingsPane();

                autoplayBtn.style.width = 'auto';
                autoplayBtn.style.height = 'auto';
                autoplayBtn.style.padding = '20px 13px 20px 20px';

                fsBtn.before(autoplayBtn);

                IS_SAFARI ? fsBtn.remove() : fsBtn.replaceWith(newFsBtn);

                const toggleSettingsPane = (ev) => {
                    ev?.preventDefault();
                    ev?.stopImmediatePropagation();

                    settingsPane.hidden = !settingsPane.hidden;

                    return false;
                };

                if (IS_MOBILE) {
                    autoplayBtn.oncontextmenu = () => false;
                    detectHold(autoplayBtn, toggleSettingsPane);
                } else {
                    autoplayBtn.oncontextmenu = toggleSettingsPane;
                }

                if (IS_SAFARI === false) {
                    newFsBtn.addEventListener('click', () => {
                        this.messenger.sendMessage(IframeMessenger.messages.TOGGLE_FULLSCREEN);
                    });

                    this.messenger.sendMessage(IframeMessenger.messages.REQUEST_FULLSCREEN_STATE);
                }
            });
        }

        restylePlayer() {
            const newStyles = [
                `
          div.vjs-volume-panel {
            margin-right: auto !important;
          }

          div.vjs-brand-container {
            display: none;
          }

          div.video-js .vjs-remaining-time {
            padding-right: 4px;
          }

          div.video-js .vjs-control-bar .vjs-progress-control {
            left: 12px;
            right: 78px;
          }
        `,
            ];

            GM_addStyle(newStyles.join(' '));
        }

        updateFullscreenBtn({
            isInFullscreen
        }) {
            const player = document.querySelector(DoodstreamIframeInterface.queries.player);

            if (isInFullscreen) {
                player.parentElement.classList.add('vjs-fullscreen');
            } else {
                player.parentElement.classList.remove('vjs-fullscreen');
            }
        }
    }

    class LoadXIframeInterface extends IframeInterface {
        constructor(messenger) {
            super(messenger);

            // Remove on-screen controls to avoid double-tap conflicts
            if (mainSettings[MAIN_SETTINGS_MAP.overrideDoubletapBehavior]) {
                waitForElement([
                    'div[class*=display-icon-rewind], div[class*=display-icon-next]',
                ].join(', '), {
                    existing: true,
                }, (controls) => controls.remove());
            }

            (function() {
                const originalAddEventListener = EventTarget.prototype.addEventListener;

                EventTarget.prototype.addEventListener = function(type, listener, options) {
                    if (
                        // Get rid of ads
                        (['click', 'mousedown'].includes(type) && this === document) ||
                        // Intercept original hotkeys to avoid conflicts with the script hotkeys
                        (type === 'keydown' && this.matches && this.matches('div#player'))
                    ) {
                        return;
                    }

                    // Intercept double-tap to fullscreen handler
                    if (
                        IS_MOBILE &&
                        mainSettings[MAIN_SETTINGS_MAP.overrideDoubletapBehavior] &&
                        (type === 'click' && this.matches && this.matches('div#player > div > div.jw-media'))
                    ) {
                        let timerId = null;

                        return originalAddEventListener.call(this, type, () => {
                            clearTimeout(timerId);

                            const playerContainer = document.querySelector('div#player');

                            if (playerContainer.classList.contains('jw-flag-user-inactive')) {
                                playerContainer.classList.remove('jw-flag-user-inactive');

                                timerId = setTimeout(() => {
                                    playerContainer.classList.add('jw-flag-user-inactive');
                                }, 2000);
                            } else {
                                playerContainer.classList.add('jw-flag-user-inactive');
                            }
                        }, options);
                    }

                    return originalAddEventListener.call(this, type, listener, options);
                };
            }());
        }

        static get queries() {
            return {
                fullscreenBtn: 'div.jw-tooltip-fullscreen',
                player: 'video.jw-video',
            };
        }

        async preparePlayer(player) {
            this.setupDoubletapBehavior(player);
            this.setupHotkeys(player);
            this.setupOutroSkipHandling(player);
            this.setupWatchedStateLabeling(player);
            this.setupVideoPlaybackPositionMemory(player);

            let hasSkippedInitial = false;

            player.addEventListener('timeupdate', function autoStartSkip() {
                if (!hasSkippedInitial && coreSettings[CORE_SETTINGS_MAP.shouldAutoSkipOnStart]) {
                    const skipSeconds = Number(coreSettings[CORE_SETTINGS_MAP.autoSkipSecondsOnStart]) || 0;
                    if (player.currentTime < skipSeconds) {
                        player.currentTime = skipSeconds;
                    }
                    hasSkippedInitial = true;
                }
            });


            this.setupPersistentVolume(player);
            this.handleAutoplay(player); // should go after setupPersistentVolume

            // Attach autoplay button and change fullscreen button behavior...
            waitForElement(LoadXIframeInterface.queries.fullscreenBtn, {
                existing: true,
                onceOnly: true,
            }, (fsBtn) => {
                fsBtn = fsBtn.parentElement;

                const newFsBtn = fsBtn.cloneNode(true);
                const autoplayBtn = this.createAutoplayButton();
                const settingsPane = this.settingsPane = this.createSettingsPane();

                autoplayBtn.style.width = '44px';
                autoplayBtn.style.height = '44px';

                fsBtn.before(autoplayBtn);

                IS_SAFARI ? fsBtn.remove() : fsBtn.replaceWith(newFsBtn);

                const toggleSettingsPane = (ev) => {
                    ev?.preventDefault();
                    ev?.stopImmediatePropagation();

                    settingsPane.hidden = !settingsPane.hidden;

                    return false;
                };

                if (IS_MOBILE) {
                    autoplayBtn.oncontextmenu = () => false;
                    detectHold(autoplayBtn, toggleSettingsPane);
                } else {
                    autoplayBtn.oncontextmenu = toggleSettingsPane;
                }

                if (IS_SAFARI === false) {
                    newFsBtn.addEventListener('click', () => {
                        this.messenger.sendMessage(IframeMessenger.messages.TOGGLE_FULLSCREEN);
                    });

                    this.messenger.sendMessage(IframeMessenger.messages.REQUEST_FULLSCREEN_STATE);
                }
            });
        }

        updateFullscreenBtn({
            isInFullscreen
        }) {
            const fsBtn = document.querySelector(LoadXIframeInterface.queries.fullscreenBtn);

            if (isInFullscreen) {
                fsBtn.parentElement.classList.add('jw-off');
            } else {
                fsBtn.parentElement.classList.remove('jw-off');
            }
        }
    }

    class VidozaIframeInterface extends IframeInterface {
        constructor(messenger) {
            super(messenger);

            waitForElement([
                'div[id^=asg-]',
                'div.prevent-first-click',
                'div.vjs-adblock-overlay',
                'iframe[data-asg-handled^="asg-"]',
                'iframe[style*="z-index: 2147483647"]',
            ].join(', '), {
                existing: true,
            }, (ads) => ads.remove());

            (function() {
                const originalAddEventListener = EventTarget.prototype.addEventListener;

                EventTarget.prototype.addEventListener = function(type, listener, options) {
                    // Get rid of ads
                    if (type === 'mousedown' && (this === document || this === unsafeWindow)) {
                        return;
                    }

                    return originalAddEventListener.call(this, type, listener, options);
                };
            }());
        }

        static get queries() {
            return {
                fullscreenBtn: 'button.vjs-fullscreen-control',
                player: 'video#player_html5_api.vjs-tech',
            };
        }

        async preparePlayer(player) {
            this.setupDoubletapBehavior(player);
            this.setupHotkeys(player);
            this.setupOutroSkipHandling(player);
            this.setupWatchedStateLabeling(player);
            this.setupVideoPlaybackPositionMemory(player);
            this.restylePlayer(player);

            let hasSkippedInitial = false;

            player.addEventListener('timeupdate', function autoStartSkip() {
                if (!hasSkippedInitial && coreSettings[CORE_SETTINGS_MAP.shouldAutoSkipOnStart]) {
                    const skipSeconds = Number(coreSettings[CORE_SETTINGS_MAP.autoSkipSecondsOnStart]) || 0;
                    if (player.currentTime < skipSeconds) {
                        player.currentTime = skipSeconds;
                    }
                    hasSkippedInitial = true;
                }
            });


            this.setupPersistentVolume(player);
            this.handleAutoplay(player); // should go after setupPersistentVolume

            // Attach autoplay button and change fullscreen button behavior...
            waitForElement(VidozaIframeInterface.queries.fullscreenBtn, {
                existing: true,
                onceOnly: true,
            }, (fsBtn) => {
                // Prevent focused buttons from being toggled by pressing space/enter
                fsBtn.parentElement.addEventListener('keydown', (ev) => ev.preventDefault());
                fsBtn.parentElement.addEventListener('keyup', (ev) => ev.preventDefault());

                const newFsBtn = fsBtn.cloneNode(true);
                const autoplayBtn = this.createAutoplayButton();
                const settingsPane = this.settingsPane = this.createSettingsPane();

                autoplayBtn.style.paddingBottom = '1px';

                fsBtn.before(autoplayBtn);

                IS_SAFARI ? fsBtn.remove() : fsBtn.replaceWith(newFsBtn);

                const toggleSettingsPane = (ev) => {
                    ev?.preventDefault();
                    ev?.stopImmediatePropagation();

                    settingsPane.hidden = !settingsPane.hidden;

                    return false;
                };

                if (IS_MOBILE) {
                    autoplayBtn.oncontextmenu = () => false;
                    detectHold(autoplayBtn, toggleSettingsPane);
                } else {
                    autoplayBtn.oncontextmenu = toggleSettingsPane;
                }

                if (IS_SAFARI === false) {
                    newFsBtn.addEventListener('click', () => {
                        this.messenger.sendMessage(IframeMessenger.messages.TOGGLE_FULLSCREEN);
                    });

                    this.messenger.sendMessage(IframeMessenger.messages.REQUEST_FULLSCREEN_STATE);
                }
            });
        }

        restylePlayer() {
            GM_addStyle([
                `
          div.vjs-resolution-button, button.vjs-disable-ads-button {
            display: none !important;
          }
        `,

                `
          div.video-js div.vjs-control-bar {
            background-color: unset !important;
          }
        `,

                `
          div.video-js .vjs-slider {
            background-color: rgb(112, 112, 112, 0.8) !important;
          }
        `,

                `
          div.video-js .vjs-play-progress {
            background-color: #2979ff !important;
            border-radius: 1em !important;
            height: 0.4em !important;
          }

          div.video-js .vjs-play-progress:before {
            font-size: 0.9em !important;
            top: -.25em !important;
          }
        `,

                `
          div.video-js .vjs-load-progress {
            background-color: #808080 !important;
            height: 0.4em !important;
          }
        `,

                `
          div.video-js .vjs-progress-control .vjs-progress-holder {
            height: 0.4em !important;
          }
        `,

                `
          div.video-js .vjs-time-control, div.vjs-playback-rate .vjs-playback-rate-value, div.vjs-resolution-button .vjs-resolution-button-label {
            line-height: 3em !important;
          }
        `,

                `
          div.video-js .vjs-big-play-button {
            background-color: rgb(0 132 255 / 75%) !important;
          }

          div.video-js .vjs-big-play-button:hover {
            background-color: rgb(40 160 255 / 95%) !important;
          }
        `,

                `
          div.video-js .vjs-progress-control:hover .vjs-mouse-display:after, div.video-js .vjs-progress-control:hover .vjs-play-progress:after, div.video-js .vjs-progress-control:hover .vjs-time-tooltip, div.video-js .vjs-volume-panel .vjs-volume-control.vjs-volume-vertical, div.vjs-menu-button-popup .vjs-menu .vjs-menu-content {
            background-color: rgb(0 132 255 / 75%) !important;
          }
        `,

                `
          #vplayer .video-js .vjs-time-control {
            padding-right: 3.5em !important;
          }
        `,

                `
          div.video-js .vjs-play-control {
            margin-left: 0.5em !important;
          }
        `,

                `
          div.video-js .vjs-progress-control {
            margin-left: 0.8em !important;
          }
        `,

                `
          div.video-js .vjs-fullscreen-control {
            margin-right: 0.5em !important;
          }
        `,
            ].join(' '));

            const currentTime = document.querySelector('div.vjs-current-time');
            const remainingTime = document.querySelector('div.vjs-remaining-time');

            remainingTime.replaceWith(currentTime);
        }

        updateFullscreenBtn({
            isInFullscreen
        }) {
            const player = document.querySelector(VidozaIframeInterface.queries.player);

            if (isInFullscreen) {
                player.parentElement.classList.add('vjs-fullscreen');
            } else {
                player.parentElement.classList.remove('vjs-fullscreen');
            }
        }
    }

    class VOEJWPIframeInterface extends IframeInterface {
        constructor(messenger) {
            super(messenger);

            const playbackPositionStorageKey = (
                `skip-forward-${location.pathname.split('/').pop()}`
            );

            try {
                this.builtinPlaybackPositionMemory = JSON.parse(localStorage.getItem(
                    playbackPositionStorageKey
                ));
            } catch {}

            localStorage.removeItem(playbackPositionStorageKey);

            waitForElement([
                'div.guestMode',
                'iframe[style*="z-index: 2147483647"]',
            ].join(', '), {
                existing: true,
            }, (ads) => ads.remove());

            (function() {
                const originalAddEventListener = EventTarget.prototype.addEventListener;

                EventTarget.prototype.addEventListener = function(type, listener, options) {
                    if (
                        // Get rid of ads
                        (['click', 'mousedown'].includes(type) && this === document) ||
                        // Intercept original hotkeys to avoid conflicts with the script hotkeys
                        (type === 'keydown' && this.matches && this.matches('div#vp'))
                    ) {
                        return;
                    }

                    // Intercept double-tap to fullscreen handler
                    if (
                        IS_MOBILE &&
                        mainSettings[MAIN_SETTINGS_MAP.overrideDoubletapBehavior] &&
                        (type === 'click' && this.matches && this.matches('div#vp > div > div.jw-media'))
                    ) {
                        let timerId = null;

                        return originalAddEventListener.call(this, type, () => {
                            clearTimeout(timerId);

                            const playerContainer = document.querySelector('div#vp');

                            if (playerContainer.classList.contains('jw-flag-user-inactive')) {
                                playerContainer.classList.remove('jw-flag-user-inactive');

                                timerId = setTimeout(() => {
                                    playerContainer.classList.add('jw-flag-user-inactive');
                                }, 2000);
                            } else {
                                playerContainer.classList.add('jw-flag-user-inactive');
                            }
                        }, options);
                    }

                    return originalAddEventListener.call(this, type, listener, options);
                };
            }());
        }

        static get queries() {
            return {
                fullscreenBtn: 'div.jw-tooltip-fullscreen',
                player: 'video.jw-video',
            };
        }

        async preparePlayer(player) {
            this.setupDoubletapBehavior(player);
            this.setupHotkeys(player);
            this.setupOutroSkipHandling(player);
            this.setupWatchedStateLabeling(player);
            this.setupVideoPlaybackPositionMemory(player);

            let hasSkippedInitial = false;

            player.addEventListener('timeupdate', function autoStartSkip() {
                if (!hasSkippedInitial && coreSettings[CORE_SETTINGS_MAP.shouldAutoSkipOnStart]) {
                    const skipSeconds = Number(coreSettings[CORE_SETTINGS_MAP.autoSkipSecondsOnStart]) || 0;
                    if (player.currentTime < skipSeconds) {
                        player.currentTime = skipSeconds;
                    }
                    hasSkippedInitial = true;
                }
            });


            this.setupPersistentVolume(player);
            this.handleAutoplay(player); // should go after setupPersistentVolume

            // Attach autoplay button and change fullscreen button behavior...
            waitForElement(VOEJWPIframeInterface.queries.fullscreenBtn, {
                existing: true,
                onceOnly: true,
            }, (fsBtn) => {
                fsBtn = fsBtn.parentElement;

                const newFsBtn = fsBtn.cloneNode(true);
                const autoplayBtn = this.createAutoplayButton();
                const settingsPane = this.settingsPane = this.createSettingsPane();

                autoplayBtn.style.width = '44px';
                autoplayBtn.style.height = '44px';
                autoplayBtn.style.paddingTop = '3px';
                autoplayBtn.style.flex = '0 0 auto';
                autoplayBtn.style.outline = 'none';

                fsBtn.before(autoplayBtn);

                IS_SAFARI ? fsBtn.remove() : fsBtn.replaceWith(newFsBtn);

                const toggleSettingsPane = (ev) => {
                    ev?.preventDefault();
                    ev?.stopImmediatePropagation();

                    settingsPane.hidden = !settingsPane.hidden;

                    return false;
                };

                if (IS_MOBILE) {
                    autoplayBtn.oncontextmenu = () => false;
                    detectHold(autoplayBtn, toggleSettingsPane);
                } else {
                    autoplayBtn.oncontextmenu = toggleSettingsPane;
                }

                if (IS_SAFARI === false) {
                    newFsBtn.addEventListener('click', () => {
                        this.messenger.sendMessage(IframeMessenger.messages.TOGGLE_FULLSCREEN);
                    });

                    this.messenger.sendMessage(IframeMessenger.messages.REQUEST_FULLSCREEN_STATE);
                }
            });
        }

        async setupVideoPlaybackPositionMemory(player) {
            const self = this;

            await (async function waitForVideoData(start = Date.now()) {
                if (!self.currentVideoId || !self.topScopeDomainId) {
                    if ((Date.now() - start) > (10 * 1000)) {
                        throw new Error('Video data didn\'t arrive in time');
                    }

                    await sleep();

                    return waitForVideoData(start);
                }
            }());

            const timestampDataGMKey = (
                IframeInterface.makePlaybackPositionGMKey(this.topScopeDomainId, this.currentVideoId)
            );

            if (
                this.builtinPlaybackPositionMemory &&
                this.builtinPlaybackPositionMemory.value
            ) {
                const {
                    expire,
                    value
                } = this.builtinPlaybackPositionMemory;
                let updateDate = Date.now();

                // 10 days is the built in position memory expiration time
                if (expire) {
                    updateDate = (
                        new Date((new Date(expire)).getTime() - 10 * 24 * 60 * 60 * 1000).getTime()
                    );
                }

                GM_setValue(timestampDataGMKey, {
                    value,
                    updateDate
                });
            }

            // This has to wait indefinitely because players like VOE do not have the value
            // until the play button has been pressed or an autoplay has been triggered
            await (async function waitForVideoDuration() {
                if (!player.duration) {
                    await sleep();
                    return waitForVideoDuration();
                }
            }());

            const timestampData = GM_getValue(timestampDataGMKey, {});

            if (timestampData.value) {
                const elapsedTime = Date.now() - timestampData.updateDate;
                const expirationThreshold = advancedSettings[
                    ADVANCED_SETTINGS_MAP.playbackPositionExpirationDays
                ] * 24 * 60 * 60 * 1000;

                if (elapsedTime < expirationThreshold) {
                    const outroSkipThresholdS = coreSettings[CORE_SETTINGS_MAP.currentOutroSkipThresholdS];
                    const potentialTimeLeftToPlay = player.duration - timestampData.value;

                    // Skip saved playback position if it's in a range of (outroSkipThresholdS + 20)
                    if (potentialTimeLeftToPlay > (outroSkipThresholdS + 20)) {
                        player.currentTime = timestampData.value;
                    }
                }
            }

            let lastCheckedTime = player.currentTime;

            setInterval(() => {
                if (
                    !mainSettings[MAIN_SETTINGS_MAP.playbackPositionMemory] ||
                    (player.currentTime === lastCheckedTime)
                ) return;

                lastCheckedTime = player.currentTime;

                GM_setValue(timestampDataGMKey, {
                    value: lastCheckedTime,
                    updateDate: Date.now(),
                });
            }, 1000);
        }

        updateFullscreenBtn({
            isInFullscreen
        }) {
            const fsBtn = document.querySelector(VOEJWPIframeInterface.queries.fullscreenBtn);

            if (isInFullscreen) {
                fsBtn.parentElement.classList.add('jw-off');
            } else {
                fsBtn.parentElement.classList.remove('jw-off');
            }
        }
    }

    class VOEPlyrIframeInterface extends IframeInterface {
        constructor(messenger) {
            super(messenger);

            waitForElement([
                'div.guestMode',
                'iframe[style*="z-index: 2147483647"]',
            ].join(', '), {
                existing: true,
            }, (ads) => ads.remove());

            (function() {
                const originalAddEventListener = EventTarget.prototype.addEventListener;

                EventTarget.prototype.addEventListener = function(type, listener, options) {
                    if (
                        // Get rid of ads
                        (['click', 'mousedown'].includes(type) && this === document) ||
                        // Intercept original hotkeys to avoid conflicts with the script hotkeys
                        (type === 'keypress' && this === document) ||
                        (type === 'keydown' && this.matches && this.matches('div.plyr'))
                    ) {
                        return;
                    }

                    return originalAddEventListener.call(this, type, listener, options);
                };
            }());
        }

        static get queries() {
            return {
                fullscreenBtn: 'button[data-plyr="fullscreen"]',
                player: 'video#voe-player',
                playerContainer: 'div.plyr__video-wrapper',
                playerControls: 'div.plyr__controls',
            };
        }

        createAutoplayButton() {
            const button = document.createElement('button');
            const toggleContainer = document.createElement('div');
            const toggleDot = document.createElement('div');
            const tooltip = document.createElement('span');
            const isAutoplayEnabled = coreSettings[CORE_SETTINGS_MAP.isAutoplayEnabled];
            let lastClickTime = 0;

            button.addEventListener('click', () => {
                const now = Date.now();

                // Prevent double-clicks unwanted behavior
                if (now - lastClickTime < 300) return;

                lastClickTime = now;

                if (!GM_getValue('firstRunTextWasShown')) {
                    GM_setValue('firstRunTextWasShown', true);

                    this.messenger.sendMessage(IframeMessenger.messages.TOP_NOTIFLIX_REPORT_INFO, {
                        args: [
                            `${GM_info.script.name} info`,
                            `${IS_MOBILE ? 'Hold-release' : 'Right click'} the toggle button to open autoplay settings. ${IS_MOBILE ? '' : `Press "${hotkeysSettings[HOTKEYS_SETTINGS_MAP.largeSkip]}" when an intro starts to skip it. `}Fullscreen is scrollable, allowing to switch providers on the go`,
                            'Okay', {
                                delayedButton: true,
                            },
                        ],
                    });
                }

                const wasEnabled = coreSettings[CORE_SETTINGS_MAP.isAutoplayEnabled];

                coreSettings[CORE_SETTINGS_MAP.isAutoplayEnabled] = !wasEnabled;

                button.setAttribute('aria-checked', (!wasEnabled).toString());

                tooltip.textContent = wasEnabled ? 'Autoplay is disabled' : 'Autoplay is enabled';
                toggleDot.style.backgroundColor = wasEnabled ? '#bbb' : '#fff';
                toggleDot.style.transform = wasEnabled ? 'translateX(0px)' : 'translateX(12px)';
            });

            button.type = 'button';
            button.appendChild(toggleContainer);
            button.appendChild(tooltip);
            button.setAttribute('aria-checked', (isAutoplayEnabled).toString());
            button.className = (
                'plyr__controls__item plyr__control Autoplay-button'
            );

            toggleContainer.className = 'Autoplay-button--toggle';
            toggleContainer.appendChild(toggleDot);

            toggleDot.className = 'Autoplay-button--toggle-dot';

            tooltip.className = 'plyr__tooltip';

            tooltip.textContent = (
                !isAutoplayEnabled ? 'Autoplay is disabled' : 'Autoplay is enabled'
            );
            toggleDot.style.backgroundColor = !isAutoplayEnabled ? '#bbb' : '#fff';
            toggleDot.style.transform = (
                !isAutoplayEnabled ? 'translateX(0px)' : 'translateX(12px)'
            );

            GM_addStyle([`
        .Autoplay-button {
          width: 36px !important;
          height: 36px;
          padding: 0 !important;
          background-color: #bbb;
          border-radius: 50%;
          position: absolute;
          top: 0;
          left: 0;
          transition: all 0.2s ease;
          user-select: none;
          -webkit-user-select: none;
        }

        .Autoplay-button[aria-checked="true"] .Autoplay-button--toggle-dot {
          transform: translateX(12px);
        }

        .Autoplay-button:hover .plyr__tooltip {
          opacity: 1;
        }

        .Autoplay-button--toggle {
          width: 24px;
          height: 12px;
          vertical-align: -1px;
          background-color: rgba(221, 221, 221, 0.5);
          border-radius: 6px;
          position: relative;
          cursor: pointer;
          display: inline-block;
        }

        .Autoplay-button--toggle-dot {
          width: 12px;
          height: 12px;
          background-color: #bbb;
          border-radius: 50%;
          position: absolute;
          top: 0;
          left: 0;
          transition: all 0.2s ease;
        }
      `][0]);

            return button;
        }

        async preparePlayer(player) {
            this.setupDoubletapBehavior(player, player.nextElementSibling);
            this.setupHotkeys(player);
            this.setupOutroSkipHandling(player);
            this.setupWatchedStateLabeling(player);
            this.setupVideoPlaybackPositionMemory(player);
            this.restylePlayer(player);


            let hasSkippedInitial = false;

            player.addEventListener('timeupdate', function autoStartSkip() {
                if (!hasSkippedInitial && coreSettings[CORE_SETTINGS_MAP.shouldAutoSkipOnStart]) {
                    const skipSeconds = Number(coreSettings[CORE_SETTINGS_MAP.autoSkipSecondsOnStart]) || 0;
                    if (player.currentTime < skipSeconds) {
                        player.currentTime = skipSeconds;
                    }
                    hasSkippedInitial = true;
                }
            });


            this.setupPersistentVolume(player);
            this.handleAutoplay(player); // should go after setupPersistentVolume

            IS_MOBILE && this.setupControlsHiding(player);

            // Hide cursor on a fullscreen playback
            waitForElement(VOEPlyrIframeInterface.queries.playerContainer, {
                existing: true,
                onceOnly: true,
            }, (playerContainer) => {
                let lastMove = Date.now();

                playerContainer.addEventListener('mousemove', () => {
                    lastMove = Date.now();
                    playerContainer.style.cursor = 'default';
                });

                setInterval(() => {
                    if (
                        this.isInFullscreen &&
                        !player.paused && (Date.now() - lastMove > 2000)
                    ) {
                        playerContainer.style.cursor = 'none';
                    }
                }, 100);
            });

            // Attach autoplay button and change fullscreen button behavior...
            waitForElement(VOEPlyrIframeInterface.queries.playerControls, {
                existing: true,
                onceOnly: true,
            }, (playerControls) => {
                // Prevent focused buttons from being toggled by pressing space/enter
                playerControls.addEventListener('keydown', (ev) => ev.preventDefault());
                playerControls.addEventListener('keyup', (ev) => ev.preventDefault());

                const fsBtn = playerControls.querySelector(
                    VOEPlyrIframeInterface.queries.fullscreenBtn
                );
                const newFsBtn = fsBtn.cloneNode(true);
                const autoplayBtn = this.createAutoplayButton();
                const settingsPane = this.settingsPane = this.createSettingsPane();

                fsBtn.before(autoplayBtn);

                IS_SAFARI ? fsBtn.remove() : fsBtn.replaceWith(newFsBtn);

                const toggleSettingsPane = (ev) => {
                    ev?.preventDefault();
                    ev?.stopImmediatePropagation();

                    settingsPane.hidden = !settingsPane.hidden;

                    return false;
                };

                if (IS_MOBILE) {
                    autoplayBtn.oncontextmenu = () => false;
                    detectHold(autoplayBtn, toggleSettingsPane);
                } else {
                    autoplayBtn.oncontextmenu = toggleSettingsPane;
                }

                if (IS_SAFARI === false) {
                    newFsBtn.addEventListener('click', () => {
                        this.messenger.sendMessage(IframeMessenger.messages.TOGGLE_FULLSCREEN);
                    });

                    this.messenger.sendMessage(IframeMessenger.messages.REQUEST_FULLSCREEN_STATE);
                }
            });

            await (async function waitForPlyr(start = Date.now()) {
                if (!player.plyr) {
                    if ((Date.now() - start) > (10 * 1000)) {
                        throw new Error('Plyr API didn\'t arrive in time');
                    }

                    await sleep();

                    return waitForPlyr(start);
                }
            }());

            // Prevent double-tap to fullscreen behavior
            for (let i = player.plyr.eventListeners.length - 1; i >= 0; i--) {
                const listenerObject = player.plyr.eventListeners[i];

                if (listenerObject.type === 'dblclick') {
                    listenerObject.element.removeEventListener('dblclick', listenerObject.callback);
                    player.plyr.eventListeners.splice(i, 1);
                    break;
                }
            }
        }

        restylePlayer() {
            const newStyles = [
                // Remove annoying highlights when control buttons are in focus
                `
          div.plyr--video .plyr__control:focus-visible {
            background: none;
          }

          button.plyr__control:focus-visible {
            outline: none;
          }

          div.plyr .plyr__control:focus-visible .plyr__tooltip {
            opacity: 0;
          }
        `,
            ];

            if (IS_MOBILE) {
                // Remove 2.5s extra delay before hiding controls
                newStyles.push(`
          div.plyr--video.plyr--hide-controls .plyr__controls {
            animation-duration: 0s !important;
          }
        `);
            }

            GM_addStyle(newStyles.join(' '));
        }

        setupControlsHiding(player) {
            const controlsCheckIntervalMs = 100;
            let controlsLastHiddenTime = 0;

            setInterval(() => {
                if (
                    player.parentElement.parentElement.classList.contains('plyr--hide-controls')
                ) {
                    controlsLastHiddenTime = Date.now();
                }
            }, controlsCheckIntervalMs);

            player.parentElement.addEventListener('click', (ev) => {
                if (!['pen', 'touch'].includes(ev.pointerType)) return;

                if (
                    !player.paused &&
                    (Date.now() - controlsLastHiddenTime) > (controlsCheckIntervalMs * 2) &&
                    !player.parentElement.parentElement.classList.contains('plyr--hide-controls')
                ) {
                    player.parentElement.parentElement.classList.add('plyr--hide-controls');
                }
            });
        }

        updateFullscreenBtn({
            isInFullscreen
        }) {
            const btn = document.querySelector(VOEPlyrIframeInterface.queries.fullscreenBtn);

            if (isInFullscreen) {
                btn.classList.add('plyr__control--pressed');
            } else {
                btn.classList.remove('plyr__control--pressed');
            }
        }
    }

    class SpeedfilesIframeInterface extends IframeInterface {
        constructor(messenger) {
            super(messenger);

            waitForElement([
                'iframe[style*="z-index: 2147483647"]',
            ].join(', '), {
                existing: true,
            }, (ads) => ads.remove());

            (function() {
                const originalAddEventListener = EventTarget.prototype.addEventListener;

                EventTarget.prototype.addEventListener = function(type, listener, options) {
                    if (
                        type === 'dblclick' && this.matches && this.matches('#my-video_html5_api')
                    ) {
                        return;
                    }

                    return originalAddEventListener.call(this, type, listener, options);
                };
            }());
        }

        static get queries() {
            return {
                fullscreenBtn: 'button.vjs-fullscreen-control',
                player: 'video#my-video_html5_api.vjs-tech',
            };
        }

        async preparePlayer(player) {
            this.setupDoubletapBehavior(player);
            this.setupHotkeys(player);
            this.setupOutroSkipHandling(player);
            this.setupWatchedStateLabeling(player);
            this.setupVideoPlaybackPositionMemory(player);

            let hasSkippedInitial = false;

            player.addEventListener('timeupdate', function autoStartSkip() {
                if (!hasSkippedInitial && coreSettings[CORE_SETTINGS_MAP.shouldAutoSkipOnStart]) {
                    const skipSeconds = Number(coreSettings[CORE_SETTINGS_MAP.autoSkipSecondsOnStart]) || 0;
                    if (player.currentTime < skipSeconds) {
                        player.currentTime = skipSeconds;
                    }
                    hasSkippedInitial = true;
                }
            });


            this.setupPersistentVolume(player);
            this.handleAutoplay(player); // should go after setupPersistentVolume

            // Attach autoplay button and change fullscreen button behavior...
            waitForElement(SpeedfilesIframeInterface.queries.fullscreenBtn, {
                existing: true,
                onceOnly: true,
            }, (fsBtn) => {
                const newFsBtn = fsBtn.cloneNode(true);
                const autoplayBtn = this.createAutoplayButton();
                const settingsPane = this.settingsPane = this.createSettingsPane();

                autoplayBtn.style.width = '40px';
                autoplayBtn.style.paddingBottom = '1px';

                fsBtn.before(autoplayBtn);

                IS_SAFARI ? fsBtn.remove() : fsBtn.replaceWith(newFsBtn);

                // Prevent focused buttons from being toggled by pressing space/enter
                newFsBtn.addEventListener('keydown', (ev) => ev.preventDefault());
                newFsBtn.addEventListener('keyup', (ev) => ev.preventDefault());

                const toggleSettingsPane = (ev) => {
                    ev?.preventDefault();
                    ev?.stopImmediatePropagation();

                    settingsPane.hidden = !settingsPane.hidden;

                    return false;
                };

                if (IS_MOBILE) {
                    autoplayBtn.oncontextmenu = () => false;
                    detectHold(autoplayBtn, toggleSettingsPane);
                } else {
                    autoplayBtn.oncontextmenu = toggleSettingsPane;
                }

                if (IS_SAFARI === false) {
                    newFsBtn.addEventListener('click', () => {
                        this.messenger.sendMessage(IframeMessenger.messages.TOGGLE_FULLSCREEN);
                    });

                    this.messenger.sendMessage(IframeMessenger.messages.REQUEST_FULLSCREEN_STATE);
                }
            });
        }

        updateFullscreenBtn({
            isInFullscreen
        }) {
            const player = document.querySelector(SpeedfilesIframeInterface.queries.player);

            if (isInFullscreen) {
                player.parentElement.classList.add('vjs-fullscreen');
            } else {
                player.parentElement.classList.remove('vjs-fullscreen');
            }
        }
    }

    class TopScopeInterface {
        constructor() {
            this.commLink = null;
            this.currentIframeId = null;
            this.domainId = TOP_SCOPE_DOMAINS_IDS[location.hostname] || '';
            this.iframeSrcChangesListener = null;
            this.id = makeId();
            this.ignoreIframeSrcChangeOnce = false;
            this.isPendingConnection = false;

            // Ugly shitcode fix for a playback positions. This assigns their value
            // to both the aniworld and s.to at the same time.
            // This is needed because these prefixes were missing before v4.8.3
            // causing saved positions being shared between different websites
            if (!GM_getValue('playbackPositionsMemory482wereFixed', false)) {
                this.applyPlaybackPositionsFix();
                GM_setValue('playbackPositionsMemory482wereFixed', true);
            }
        }

        static get messages() {
            return {
                CURRENT_FRANCHISE_DATA: 'CURRENT_FRANCHISE_DATA',
                FULLSCREEN_STATE: 'FULLSCREEN_STATE',
            };
        }

        static get queries() {
            return {
                animeTitle: 'div.hostSeriesTitle',
                episodeDedicatedLink: 'div.hosterSiteVideo a.watchEpisode',
                episodeTitle: 'div.hosterSiteTitle',
                hostersPlayerContainer: 'div.hosterSiteVideo',
                navLinksContainer: 'div#stream.hosterSiteDirectNav',
                playerIframe: 'div.inSiteWebStream iframe',
                providerChangeBtn: 'div.generateInlinePlayer',
                providerName: 'div.hosterSiteVideo > ul a > h4',
                providersList: 'div.hosterSiteVideo > ul',
                selectedLanguageBtn: 'img.selectedLanguage',
            };
        }

        applyPlaybackPositionsFix() {
            const oldPlaybackPositionsGMPrefix = 'playbackTimestamp_';
            const oldPlaybackPositionsKeys = (
                GM_listValues().filter(
                    v => v.startsWith(oldPlaybackPositionsGMPrefix) && v.split('_').length === 2
                )
            );
            const uniqueTopScopeDomainsIds = [...new Set(Object.values(TOP_SCOPE_DOMAINS_IDS))];

            for (const oldKey of oldPlaybackPositionsKeys) {
                const episodeId = oldKey.slice(oldPlaybackPositionsGMPrefix.length);
                const oldValue = GM_getValue(oldKey);

                for (const domainId of uniqueTopScopeDomainsIds) {
                    const newKey = IframeInterface.makePlaybackPositionGMKey(domainId, episodeId);

                    GM_setValue(newKey, oldValue);
                }

                GM_deleteValue(oldKey);
            }
        }

        // It is better not to be async
        handleIframeMessages(packet) {
            (async function() {
                try {
                    switch (packet.command) {
                        case IframeMessenger.messages.AUTOPLAY_NEXT: {
                            // This is here because it bugges out the episodes navigation panel
                            // if try and use MARK_CURRENT_VIDEO_WATCHED. Watched episode is being
                            // marked as non watched
                            try {
                                await this.markCurrentVideoWatched();
                            } catch (e) {
                                console.error(e);
                            }

                            try {
                                await this.goToNextVideo();
                            } catch (e) {
                                console.error(e);

                                Notiflixx.notify.warning(
                                    `${GM_info.script.name}: The script got an error trying autoplay. Try again, and if the problem persists, report the bug, or you can try switching video player providers if possible`
                                );
                            }

                            break;
                        }

                        case IframeMessenger.messages.REQUEST_CURRENT_FRANCHISE_DATA: {
                            const episodeId = document.querySelector(
                                TopScopeInterface.queries.episodeTitle
                            ).dataset.episodeId;
                            const releaseYear = document.querySelector(
                                'div.series-title span[itemprop="startDate"]'
                            ).innerText;
                            const title = document.querySelector('div.series-title > h1').innerText;
                            const currentFranchiseId = (
                                title ? `${title}${releaseYear ? `::${releaseYear}` : ''}` : null
                            );

                            if (currentFranchiseId || episodeId) {
                                this.commLink.commands[
                                    TopScopeInterface.messages.CURRENT_FRANCHISE_DATA
                                ]({
                                    currentFranchiseId,
                                    currentVideoId: episodeId || null,
                                    topScopeDomainId: this.domainId,
                                });
                            }

                            break;
                        }

                        // Would not work on Safari
                        // but this should not be called on Safari anyway
                        case IframeMessenger.messages.REQUEST_FULLSCREEN_STATE: {
                            if (IS_SAFARI) break;

                            this.commLink.commands[TopScopeInterface.messages.FULLSCREEN_STATE]({
                                isInFullscreen: !!document.fullscreenElement,
                            });

                            break;
                        }

                        case IframeMessenger.messages.MARK_CURRENT_VIDEO_WATCHED: {
                            await this.markCurrentVideoWatched();

                            break;
                        }

                        case IframeMessenger.messages.OPEN_HOTKEYS_GUIDE: {
                            let content = [
                                '<h5>🔹 Basic hotkeys</h5>',
                                '<div><b>Single key: </b><pre>a</pre> → Triggers when <pre>a</pre> is pressed</div>',
                                '<div><b>Combo keys: </b><pre>ctrl + shift + a</pre> → Triggers when all keys are held together</div>',
                                '<h5>🔹 Sequences (pressing keys in order)</h5>',
                                '<div><b>Sequence: </b><pre>a > b</pre> → Press <pre>a</pre>, then <pre>b</pre></div>',
                                '<div><b>Chained sequence: </b><pre>ctrl + a > b</pre> → Hold <pre>ctrl</pre>, press <pre>a</pre>, release, then press <pre>b</pre></div>',
                                '<h5>🔹 Multiple options</h5>',
                                '<div><pre>a + b > c, x + y > z</pre> → Either <pre>a</pre> & <pre>b</pre> then <pre>c</pre> OR <pre>x</pre> & <pre>y</pre> then <pre>z</pre></div>',
                                '<h5>🔹 Special keys (most of them)</h5>',
                            ].join('');

                            content += [
                                'cancel', 'backspace', 'tab', 'clear', 'enter', 'shift', 'ctrl',
                                'alt', 'menu', 'pause', 'break', 'capslock', 'pageup', 'pagedown',
                                'space', 'spacebar', 'escape', 'esc', 'end', 'home', 'left', 'up',
                                'right', 'down', 'select', 'printscreen', 'execute', 'snapshot',
                                'insert', 'ins', 'delete', 'del', 'help', 'scrolllock', 'scroll',
                                'comma', ',', 'period', '.', 'openbracket', '[', 'backslash', '\\',
                                'slash', 'forwardslash', '/', 'closebracket', ']', 'apostrophe',
                                '\'', 'zero', '0', 'one', '1', 'two', '2', 'three', '3', 'four',
                                '4', 'five', '5', 'six', '6', 'seven', '7', 'eight', '8', 'nine',
                                '9', 'numzero', 'num0', 'numone', 'num1', 'numtwo', 'num2',
                                'numthree', 'num3', 'numfour', 'num4', 'numfive', 'num5', 'numsix',
                                'num6', 'numseven', 'num7', 'numeight', 'num8', 'numnine', 'num9',
                                'nummultiply', 'num*', 'numadd', 'num+', 'numenter', 'numsubtract',
                                'num-', 'numdecimal', 'num.', 'numdivide', 'num/', 'numlock', 'num',
                                'f1', 'f2', 'f3', 'f4', 'f5', 'f6', 'f7', 'f8', 'f9', 'f10', 'f11',
                                'f12', 'f13', 'f14', 'f15', 'f16', 'f17', 'f18', 'f19', 'f20', 'f21',
                                'f22', 'f23', 'f24', 'tilde', '~', 'exclamation', 'exclamationpoint',
                                '!', 'at', '@', 'number', '#', 'dollar', 'dollars', 'dollarsign',
                                '$', 'percent', '%', 'caret', '^', 'ampersand', 'and', '&', 'asterisk',
                                '*', 'openparen', '(', 'closeparen', ')', 'underscore', '_', 'plus',
                                '+', 'opencurlybrace', 'opencurlybracket', '{', 'closecurlybrace',
                                'closecurlybracket', '}', 'verticalbar', '|', 'colon', ':',
                                'quotationmark', '\'', 'openanglebracket', '<', 'closeanglebracket',
                                '>', 'questionmark', '?', 'semicolon', ';', 'dash', '-', 'equal',
                                'equalsign', '=',
                            ].map(s => `<pre>${s}</pre>`).join(' ');

                            const modal = document.createElement('div');

                            modal.className = 'notiflix-hotkeys-guide-modal';
                            modal.innerHTML = content;

                            Notiflixx.report.info('Hotkeys Guide', modal.outerHTML, 'Close', {
                                backOverlayClickToClose: true,
                                messageMaxLength: Infinity,
                                plainText: false,
                            });

                            break;
                        }

                        // Would not work on Safari
                        // but this should not be called from Safari anyway
                        case IframeMessenger.messages.TOGGLE_FULLSCREEN: {
                            if (IS_SAFARI) break;

                            // Notice how this then triggers a listener from this.init()
                            if (document.fullscreenElement) {
                                await document.exitFullscreen();
                            } else {
                                await document.documentElement.requestFullscreen();
                            }

                            break;
                        }

                        case IframeMessenger.messages.TOP_NOTIFLIX_REPORT_INFO: {
                            Notiflixx.report.info(...packet.data.args);

                            break;
                        }

                        // Not sure if anything except providersPriority needs to be in sync witn an iframe
                        case IframeMessenger.messages.UPDATE_CORE_SETTINGS: {
                            coreSettings.update();

                            break;
                        }

                        default:
                            break;
                    }
                } catch (e) {
                    console.error(e);
                }
            }.bind(this)());

            return {
                status: `${this.constructor.name} received a message`,
            };
        }

        async init(iframe) {
            this.iframeSrcChangesListener = new MutationObserver((mutations) => {
                for (const mutation of mutations) {
                    if (mutation.attributeName === 'src') {
                        if (this.ignoreIframeSrcChangeOnce) {
                            this.ignoreIframeSrcChangeOnce = false;

                            return;
                        }

                        this.unregisterCommlinkListener();
                        this.initCrossFrameConnection();
                    }
                }
            }).observe(iframe, {
                attributes: true
            });

            await this.initCrossFrameConnection();

            if (IS_SAFARI) {
                this.adaptFakeFullscreen();

                window.addEventListener('orientationchange', () => {
                    setTimeout(() => this.adaptFakeFullscreen(), 100);
                });
            } else {
                document.addEventListener('fullscreenchange', () => {
                    this.adaptFakeFullscreen();
                    this.commLink.commands[TopScopeInterface.messages.FULLSCREEN_STATE]({
                        isInFullscreen: !!document.fullscreenElement,
                    });
                });
            }
        }

        async initCrossFrameConnection() {
            if (this.isPendingConnection) throw new Error('Connecting already');

            this.isPendingConnection = true;

            let timeoutId;

            const iframeId = this.currentIframeId = await new Promise((resolve, reject) => {
                const valueChangeListenerId = GM_addValueChangeListener('unboundIframeId', (
                    _key,
                    _oldValue,
                    newValue,
                ) => {
                    const iframe = document.querySelector(TopScopeInterface.queries.playerIframe);

                    // Skip if top scope is a wrong one
                    if (!iframe) return;

                    GM_removeValueChangeListener(valueChangeListenerId);
                    clearTimeout(timeoutId);
                    resolve(newValue);
                });

                timeoutId = setTimeout(() => {
                    this.isPendingConnection = false;

                    GM_removeValueChangeListener(valueChangeListenerId);
                    reject(new Error('Iframe connection timeout'));
                }, 4 * 1000);
            });

            GM_setValue(iframeId, this.id);

            this.commLink = new CommLinkHandler(this.id, {
                silentMode: true,
                statusCheckInterval: advancedSettings[ADVANCED_SETTINGS_MAP.commlinkPollingIntervalMs],
            });

            this.commLink.registerSendCommand(TopScopeInterface.messages.CURRENT_FRANCHISE_DATA);
            this.commLink.registerSendCommand(TopScopeInterface.messages.FULLSCREEN_STATE);

            this.commLink.registerListener(iframeId, this.handleIframeMessages.bind(this));

            this.isPendingConnection = false;
        }


        adaptFakeFullscreen() {
            const Q = TopScopeInterface.queries;
            const hostersPlayerContainer = document.querySelector(Q.hostersPlayerContainer);
            const playerIframe = document.querySelector(Q.playerIframe);

            // Consider landscape mode as fullscreen on Safari
            const isInFullscreen = (
                IS_SAFARI ? window.innerWidth > window.innerHeight : !!document.fullscreenElement
            );

            if (isInFullscreen) {
                document.body.style.overflow = 'hidden';
                playerIframe.style.setProperty('height', '100vh', 'important');
                hostersPlayerContainer.firstElementChild.style.display = 'none';
                hostersPlayerContainer.style.cssText = (
                    'z-index: 100; position: fixed; top: 0; left: 0; padding: 0; height: 100vh; overflow-y: scroll; scrollbar-width: none;'
                );
            } else {
                document.body.style.overflow = '';
                playerIframe.style.height = '';

                // scrollTop reset must go before the cssText, it won't work otherwise
                hostersPlayerContainer.firstElementChild.style.display = '';
                hostersPlayerContainer.scrollTop = 0;
                hostersPlayerContainer.style.cssText = '';
            }
        }

        async announceEpisodeWatched(id) {
            if (!id) throw new Error('Episode ID is missing');

            await fetch(`${location.protocol}//${location.hostname}/ajax/lastseen`, {
                method: 'POST',
                body: `episode=${id}`,
                headers: {
                    'content-type': 'application/x-www-form-urlencoded; charset=UTF-8',
                },
            });
        }

        async goToNextVideo() {
            const Q = TopScopeInterface.queries;

            const [seasonsNav, episodesNav] = document.querySelectorAll(`${Q.navLinksContainer} > ul`);
            const episodesNavLinks = [...episodesNav.querySelectorAll('a')];
            const seasonNavLinks = [...seasonsNav.querySelectorAll('a')];
            const currentEpisodeIndex = episodesNavLinks.findIndex(el => el.classList.contains('active'));
            const currentSeasonIndex = seasonNavLinks.findIndex(el => el.classList.contains('active'));
            let nextEpisodeHref = null;

            if (currentEpisodeIndex < episodesNavLinks.length - 1) {
                nextEpisodeHref = episodesNavLinks[currentEpisodeIndex + 1].href;
            } else if (currentSeasonIndex < seasonNavLinks.length - 1) {
                // Do not proceed if this is a last movie
                // so it wont hop in to a season from a movie
                if (seasonNavLinks[currentSeasonIndex].href.endsWith('/filme')) return;

                const nextSeasonHref = seasonNavLinks[currentSeasonIndex + 1].href;
                const nextSeasonHtml = await (await fetch(nextSeasonHref)).text();
                const nextSeasonDom = (new DOMParser()).parseFromString(nextSeasonHtml, 'text/html');
                const firstEpisodeLink = nextSeasonDom.querySelector(
                    `${Q.navLinksContainer} > ul a[data-episode-id]`
                );

                nextEpisodeHref = firstEpisodeLink.href;
            }

            // Seems like the last episode was reached
            if (!nextEpisodeHref) return;

            const nextEpisodeHtml = await (await fetch(nextEpisodeHref)).text();
            const nextEpisodeDom = (new DOMParser()).parseFromString(nextEpisodeHtml, 'text/html');

            // Update current DOM from a next episode DOM
            ([
                'div#wrapper > div.seriesContentBox > div.container.marginBottom > ul',
                'div#wrapper > div.seriesContentBox > div.container.marginBottom > div.cf',
                'div.changeLanguageBox',
                `${Q.episodeTitle} > ul`,
                Q.animeTitle,
                Q.episodeTitle,
                Q.navLinksContainer,
                Q.providersList,
            ]).forEach((query) => {
                const currentElement = document.querySelector(query);
                const newElement = nextEpisodeDom.querySelector(query);

                if (currentElement && newElement) {
                    currentElement.outerHTML = newElement.outerHTML;
                }
            });

            document.title = nextEpisodeDom.title;
            history.pushState({}, '', nextEpisodeHref);

            try {
                // The website code copypasta to try and restore various buttons functionality
                (function repairWebsiteFeatures() {
                    document.querySelectorAll(Q.providerChangeBtn).forEach((btn) => {
                        btn.addEventListener('click', (ev) => {
                            ev.preventDefault();

                            const parent = btn.parentElement;
                            const linkTarget = parent.getAttribute('data-link-target');
                            const hosterTarget = parent.getAttribute('data-external-embed') === 'true';
                            const fakePlayer = document.querySelector('.fakePlayer');
                            const inSiteWebStream = document.querySelector('.inSiteWebStream');
                            const iframe = inSiteWebStream.querySelector('iframe');

                            if (hosterTarget) {
                                fakePlayer.style.display = 'block';
                                inSiteWebStream.style.display = 'inline-block';
                                iframe.style.display = 'none';
                            } else {
                                fakePlayer.style.display = 'none';
                                inSiteWebStream.style.display = 'inline-block';
                                iframe.src = linkTarget;
                                iframe.style.display = 'inline-block';
                            }
                        });
                    });
                }());

                const {
                    selectedLanguage
                } = this.updateVideoLanguageProcessing();
                const preferredProvidersButtons = [
                    ...document.querySelectorAll(TopScopeInterface.queries.providerChangeBtn)
                ].filter(el => el.parentElement.dataset.langKey === selectedLanguage);
                let nextProviderName = null;
                let nextVideoLink = null;

                if (preferredProvidersButtons.length) {
                    outer: for (const id of coreSettings[CORE_SETTINGS_MAP.providersPriority]) {
                        const preferredProviderName = VIDEO_PROVIDERS_IDS[id];

                        for (const btn of preferredProvidersButtons) {
                            const link = btn.firstElementChild;
                            const providerName = link.querySelector(
                                TopScopeInterface.queries.providerName
                            ).innerText;

                            if (providerName === preferredProviderName) {
                                nextProviderName = providerName;
                                nextVideoLink = link;

                                break outer;
                            }
                        }
                    }
                }

                let nextVideoHref = nextVideoLink?.href;

                // VOE has an additional redirect page,
                // so need to extract the video href from there first
                // in order to keep VOE-to-VOE autoplay unmuted
                if (nextVideoHref && nextProviderName === VIDEO_PROVIDERS_MAP.VOE) {
                    const corsProxy = advancedSettings[ADVANCED_SETTINGS_MAP.corsProxy];

                    if (corsProxy) {
                        nextVideoHref = /location\.href = '(https:\/\/.+)';/.exec(
                            await (await fetch(corsProxy + nextVideoLink.href)).text()
                        )[1];
                    }
                }

                if (!nextVideoHref) throw new Error('Embedded providers are missing or not supported');

                document.querySelector(Q.playerIframe).src = nextVideoHref;
            } catch {
                GM_setValue('lastAutoplayError', {
                    date: Date.now()
                });

                // At that point, refresh should load the next episode if the website even has it.
                // The problem is it is not seamless
                location.href = location.href;
            }
        }

        async markCurrentVideoWatched() {
            const episodeId = document.querySelector(
                TopScopeInterface.queries.episodeTitle
            ).dataset.episodeId;

            await this.announceEpisodeWatched(episodeId);
        }

        unregisterCommlinkListener() {
            if (!this.currentIframeId) return;

            this.commLink.listeners = this.commLink.listeners.filter((listener) => {
                if (listener.sender === this.currentIframeId) {
                    listener.intervalObj.stop();
                    return false;
                }

                return true;
            });

            this.currentIframeId = null;
        }

        // Partly consist of the website code
        updateVideoLanguageProcessing() {
            let changeLanguageButtons = [...document.querySelectorAll('.changeLanguageBox img')];
            let selectedLanguage = coreSettings[CORE_SETTINGS_MAP.videoLanguagePreferredID];
            const availableLangIDs = [...new Set(changeLanguageButtons.map(img => img.dataset.langKey))];

            // Checks preferred language and if it is missing, it takes first available.
            // Returns if found zero buttons with language IDs
            if (!selectedLanguage || !availableLangIDs.includes(selectedLanguage)) {
                if (availableLangIDs.length) {
                    selectedLanguage = availableLangIDs[0];
                } else {
                    return null;
                }
            }

            // Hides/unhides providers buttons based on language
            document.querySelectorAll('.hosterSiteVideo ul li[data-lang-key]').forEach((el) => {
                el.style.display = el.dataset.langKey === selectedLanguage ? 'block' : 'none';
            });

            // Highlights/unhighlights change language buttons
            changeLanguageButtons.forEach((btn) => {
                btn.classList.toggle('selectedLanguage', btn.dataset.langKey === selectedLanguage);
                btn.outerHTML = btn.outerHTML;
            });

            // HTML reset removes the nodes from the DOM so need to get them here once again
            changeLanguageButtons = [...document.querySelectorAll('.changeLanguageBox img')];

            changeLanguageButtons.forEach((btn) => {
                btn.addEventListener('click', function() {
                    const selectedLanguage = coreSettings[
                        CORE_SETTINGS_MAP.videoLanguagePreferredID
                    ] = this.getAttribute('data-lang-key');

                    // Highlights/unhighlights change language buttons
                    document.querySelectorAll('.changeLanguageBox img').forEach((btn) => {
                        btn.classList.toggle('selectedLanguage', btn.dataset.langKey === selectedLanguage);
                    });

                    // Hides/unhides providers buttons based on language
                    document.querySelectorAll('.hosterSiteVideo ul li[data-lang-key]').forEach((el) => {
                        el.style.display = el.dataset.langKey === selectedLanguage ? 'block' : 'none';
                    });

                    const preferredProvidersButtons = [
                        ...document.querySelectorAll(TopScopeInterface.queries.providerChangeBtn)
                    ].filter(el => el.parentElement.dataset.langKey === selectedLanguage);

                    if (preferredProvidersButtons.length) {
                        outer: for (const id of coreSettings[CORE_SETTINGS_MAP.providersPriority]) {
                            const preferredProviderName = VIDEO_PROVIDERS_IDS[id];

                            for (const btn of preferredProvidersButtons) {
                                const providerName = btn.firstElementChild.querySelector(
                                    TopScopeInterface.queries.providerName
                                ).innerText;

                                if (providerName === preferredProviderName) {
                                    btn.click();
                                    break outer;
                                }
                            }
                        }
                    }
                    else {
                        document.querySelectorAll('.inSiteWebStream').forEach((el) => {
                            el.style.display = 'none';
                        });

                        this.unregisterCommlinkListener();

                        if (this.iframeSrcChangesListener) this.ignoreIframeSrcChangeOnce = true;

                        document.querySelector(TopScopeInterface.queries.playerIframe).src = 'about:blank';
                    }
                });
            });

            return {
                selectedLanguage
            };
        }
    }


    // If context is top scope
    if (!isEmbedded()) {
        if (!TOP_SCOPE_DOMAINS.includes(location.hostname)) return;

        // Recolor episodes links visited before, excluding the current or watched ones
        GM_addStyle(`
      div#stream.hosterSiteDirectNav a[data-episode-id]:visited:not([class]) {
        background: #ffdd00;
      }
    `);

        // Wait for DOM
        await new Promise((resolve) => {
            if (['complete'].includes(document.readyState)) {
                resolve();
            } else {
                document.addEventListener('DOMContentLoaded', resolve, {
                    once: true
                });
            }
        });

        try {
            if (!IS_SAFARI && !GM_getValue('violentmonkeyWarningTextWasShown')) {
                const showWarning = () => {
                    if (document.visibilityState === 'visible') {
                        Notiflixx.report.warning(...VIOLENTMONKEY_WARNING, 'Okay');
                        setTimeout(() => GM_setValue('violentmonkeyWarningTextWasShown', true), 500);
                        document.removeEventListener('visibilitychange', showWarning);
                    }
                };

                if (document.visibilityState === 'visible') {
                    showWarning();
                } else {
                    document.addEventListener('visibilitychange', showWarning);
                }
            }

            const lastAutoplayError = GM_getValue('lastAutoplayError');

            if (lastAutoplayError && ((Date.now() - lastAutoplayError.date) <= (60 * 1000))) {
                GM_deleteValue('lastAutoplayError');

                Notiflixx.notify.warning(
                    `${GM_info.script.name}: Last autoplay end up with an error, but you should be at the next episode page now. Try again, and if the problem persists, report the bug, or you can try switching video player providers if possible`
                );
            }
        } catch (e) {
            console.error(e);
        }

        const topScopeInterface = new TopScopeInterface();
        const iframe = document.querySelector(TopScopeInterface.queries.playerIframe);

        // Not a video page?
        if (!iframe) return;

        // Remove the website logic responsible for marking episodes as watched.
        // since the script would handle it instead. Awaiting is unnecessary
        (async function waitForWatchedFunction(start = Date.now()) {
            if (unsafeWindow.markAsWatched) {
                unsafeWindow.markAsWatched = () => {};
            } else {
                if ((Date.now() - start) > (10 * 1000)) {
                    throw new Error('Watched function didn\'t arrive in time');
                }

                await sleep();

                return waitForWatchedFunction(start);
            }
        }());

        iframe.addEventListener('load', async () => {
            await topScopeInterface.init(iframe);
        }, {
            once: true
        });

        // Wait for the website main code to finish
        await new Promise((resolve) => {
            waitForElement(TopScopeInterface.queries.selectedLanguageBtn, {
                existing: true,
                onceOnly: true,
                callbackOnTimeout: true,
                timeout: 10 * 1000,
            }, resolve);
        });

        await sleep();

        const {
            selectedLanguage
        } = topScopeInterface.updateVideoLanguageProcessing();
        const preferredProvidersButtons = [
            ...document.querySelectorAll(TopScopeInterface.queries.providerChangeBtn)
        ].filter(el => el.parentElement.dataset.langKey === selectedLanguage);

        if (preferredProvidersButtons.length) {
            for (const id of coreSettings[CORE_SETTINGS_MAP.providersPriority]) {
                const preferredProviderName = VIDEO_PROVIDERS_IDS[id];

                for (const btn of preferredProvidersButtons) {
                    const providerName = btn.firstElementChild.querySelector(
                        TopScopeInterface.queries.providerName
                    ).innerText;

                    if (providerName === preferredProviderName) {
                        btn.click();
                        return;
                    }
                }
            }
        }
    }

    // If context is iframe scope
    else {
        const isItDoodstream = document.title.toLowerCase().endsWith('doodstream');
        const isItLoadX = !!(document.querySelector('title')?.textContent === 'LoadX');
        const isItVidoza = !!document.querySelector('meta[content*="Vidoza"]');
        const isItSpeedfiles = !!document.querySelector(
            'meta[content*="https://speedfiles.net"]'
        );
        let isItVOEJWP = false;
        let isItVOEPlyr = false;

        if (
            !isItDoodstream && !isItLoadX && !isItVidoza && !isItSpeedfiles &&
            !!document.querySelector('meta[name="keywords"][content^="VOE"]')
        ) {
            for (const script of document.querySelectorAll('script')) {
                if (script.innerText.indexOf('/jwplayer/') !== -1) {
                    isItVOEJWP = true;
                    break;
                }
            }

            if (!isItVOEJWP) isItVOEPlyr = true;
        }

        if ([
                isItDoodstream, isItLoadX, isItVidoza, isItSpeedfiles, isItVOEJWP, isItVOEPlyr
            ].every(e => !e)) return;

        const iframeMessenger = new IframeMessenger();

        for (const {
                condition,
                interface: Interface
            }
            of [{
                    condition: isItDoodstream,
                    interface: DoodstreamIframeInterface
                },
                {
                    condition: isItLoadX,
                    interface: LoadXIframeInterface
                },
                {
                    condition: isItVidoza,
                    interface: VidozaIframeInterface
                },
                {
                    condition: isItVOEJWP,
                    interface: VOEJWPIframeInterface
                },
                {
                    condition: isItVOEPlyr,
                    interface: VOEPlyrIframeInterface
                },
                {
                    condition: isItSpeedfiles,
                    interface: SpeedfilesIframeInterface
                },
            ]) {
            if (!condition) continue;

            // Call early to get rid of ads and intercept listeners
            const iframeInterface = new Interface(iframeMessenger);

            window.addEventListener('load', async () => {
                // Give a little bit of a time for the TopScopeInterface to prepare
                await sleep(4);
                await iframeMessenger.initCrossFrameConnection();

                waitForElement(Interface.queries.player, {
                    existing: true,
                    onceOnly: true,
                }, async (player) => {
                    // Prevent fullscreen triggering by a playback start, on Safari
                    player.setAttribute('playsinline', '');
                    player.setAttribute('webkit-playsinline', '');
 
                    // Attempt to fix a Safari bug when the video controls get duplicated
                    GM_addStyle(`
            video::-webkit-media-controls-panel, video::-webkit-media-controls-play-button, video::-webkit-media-controls-start-playback-button {
              display: none !important;
              -webkit-appearance: none;
              opacity: 0;
              visibility: hidden;
            }
          `);

                    await iframeInterface.init(player);
                });
            }, {
                once: true
            });

            break;
        }
    }
}());
长期地址
遇到问题?请前往 GitHub 提 Issues,或加Q群1031348184

赞助商

Fishcpy

广告

Rainyun

注册一下就行

Rainyun

一年攒够 12 元