import Config from "./config";
import {
    ActionType,
    Category,
    CategorySkipOption,
    ChannelIDStatus,
    ContentContainer,
    ScheduledTime,
    SegmentUUID,
    SkipToTimeParams,
    SponsorHideType,
    SponsorSourceType,
    SponsorTime,
    ToggleSkippable,
    VideoID,
    VideoInfo,
} from "./types";
import Utils from "./utils";
import PreviewBar, { PreviewBarSegment } from "./js-components/previewBar";
import SkipNotice from "./render/SkipNotice";
import SkipNoticeComponent from "./components/SkipNoticeComponent";
import UpcomingNotice from "./render/UpcomingNotice";
import SubmissionNotice from "./render/SubmissionNotice";
import { Message, MessageResponse, VoteResponse } from "./messageTypes";
import { SkipButtonControlBar } from "./js-components/skipButtonControlBar";
import { getStartTimeFromUrl } from "./utils/urlParser";
import { getControls, getExistingChapters, getHashParams, hasAutogeneratedChapters, isPlayingPlaylist, isVisible } from "./utils/pageUtils";
import { CategoryPill } from "./render/CategoryPill";
import { AnimationUtils } from "../maze-utils/src/animationUtils";
import { GenericUtils } from "./utils/genericUtils";
import { logDebug, logWarn } from "./utils/logger";
import { importTimes } from "./utils/exporter";
import { ChapterVote } from "./render/ChapterVote";
import { openWarningDialog } from "./utils/warnings";
import { extensionUserAgent, isFirefoxOrSafari, waitFor } from "../maze-utils/src";
import { formatJSErrorMessage, getFormattedTime, getLongErrorMessage } from "../maze-utils/src/formating";
import { getChannelIDInfo, getVideo, getIsAdPlaying, getIsLivePremiere, setIsAdPlaying, checkVideoIDChange, getVideoID, getYouTubeVideoID, setupVideoModule, checkIfNewVideoID, isOnInvidious, isOnMobileYouTube, isOnYouTubeMusic, isOnYTTV, getLastNonInlineVideoID, triggerVideoIDChange, triggerVideoElementChange, getIsInline, getCurrentTime, setCurrentTime, getVideoDuration, verifyCurrentTime, waitForVideo } from "../maze-utils/src/video";
import { Keybind, StorageChangesObject, isSafari, keybindEquals, keybindToString } from "../maze-utils/src/config";
import { findValidElement } from "../maze-utils/src/dom"
import { getHash, HashedValue } from "../maze-utils/src/hash";
import { generateUserID } from "../maze-utils/src/setup";
import { updateAll } from "../maze-utils/src/thumbnailManagement";
import { setupThumbnailListener } from "./utils/thumbnails";
import * as documentScript from "../dist/js/document.js";
import { isVorapisInstalled, runCompatibilityChecks } from "./utils/compatibility";
import { cleanPage } from "./utils/pageCleaner";
import { addCleanupListener } from "../maze-utils/src/cleanup";
import { hideDeArrowPromotion, tryShowingDeArrowPromotion } from "./dearrowPromotion";
import { asyncRequestToServer } from "./utils/requests";
import { isMobileControlsOpen } from "./utils/mobileUtils";
import { defaultPreviewTime } from "./utils/constants";
import { onVideoPage } from "../maze-utils/src/pageInfo";
import { getSegmentsForVideo } from "./utils/segmentData";
import { getCategoryDefaultSelection, getCategorySelection } from "./utils/skipRule";
import { getSkipProfileBool, getSkipProfileIDForTab, hideTooShortSegments, setCurrentTabSkipProfile } from "./utils/skipProfiles";
import { FetchResponse, logRequest } from "../maze-utils/src/background-request-proxy";

cleanPage();

const utils = new Utils();

utils.wait(() => Config.isReady(), 5000, 10).then(() => {
    // Hack to get the CSS loaded on permission-based sites (Invidious)
    addCSS();
    setCategoryColorCSSVariables();

    runCompatibilityChecks();
});

const skipBuffer = 0.003;
// If this close to the end, skip to the end
const endTimeSkipBuffer = 0.5;

//was sponsor data found when doing SponsorsLookup
let sponsorDataFound = false;
//the actual sponsorTimes if loaded and UUIDs associated with them
let sponsorTimes: SponsorTime[] = [];
let existingChaptersImported = false;
let importingChaptersWaitingForFocus = false;
let importingChaptersWaiting = false;
let loopedChapter :SponsorTime = null;
// List of open skip notices
const skipNotices: SkipNotice[] = [];
let upcomingNotice: UpcomingNotice | null = null;
let activeSkipKeybindElement: ToggleSkippable = null;
let shownSegmentFailedToFetchWarning = false;
let selectedSegment: SegmentUUID | null = null;
let previewedSegment = false;

// JSON video info
let videoInfo: VideoInfo = null;
// Locked Categories in this tab, like: ["sponsor","intro","outro"]
let lockedCategories: Category[] = [];
// Used to calculate a more precise "virtual" video time
const lastKnownVideoTime: { videoTime: number; preciseTime: number; fromPause: boolean; approximateDelay: number } = {
    videoTime: null,
    preciseTime: null,
    fromPause: false,
    approximateDelay: null,
};
// It resumes with a slightly later time on chromium
let lastTimeFromWaitingEvent: number = null;
const lastNextChapterKeybind = {
    time: 0,
    date: 0
};

// Skips are scheduled to ensure precision.
// Skips are rescheduled every seeking event.
// Skips are canceled every seeking event
let currentSkipSchedule: NodeJS.Timeout = null;
let currentSkipInterval: NodeJS.Timeout = null;
let currentVirtualTimeInterval: NodeJS.Timeout = null;
let currentUpcomingSchedule: NodeJS.Timeout = null;

/** Has the sponsor been skipped */
let sponsorSkipped: boolean[] = [];

let videoMuted = false; // Has it been attempted to be muted
const controlsWithEventListeners: HTMLElement[] = [];

setupVideoModule({
    videoIDChange,
    channelIDChange,
    videoElementChange,
    playerInit: () => {
        previewBar = null; // remove old previewbar
        createPreviewBar();
    },
    updatePlayerBar: () => {
        updatePreviewBar();
        updateVisibilityOfPlayerControlsButton();
    },
    resetValues,
    documentScript: chrome.runtime.getManifest().manifest_version === 2 ? documentScript : undefined
}, () => Config);
setupThumbnailListener();

// Is the video currently being switched
let switchingVideos = null;

// Used by the play and playing listeners to make sure two aren't
// called at the same time
let lastCheckTime = 0;
let lastCheckVideoTime = -1;

// To determine if a video resolution change is happening
let firstPlay = true;

let previewBar: PreviewBar = null;
// Skip to highlight button
let skipButtonControlBar: SkipButtonControlBar = null;
// For full video sponsors/selfpromo
let categoryPill: CategoryPill = null;

/** Element containing the player controls on the YouTube player. */
let controls: HTMLElement | null = null;

/** Contains buttons created by `createButton()`. */
const playerButtons: Record<string, {button: HTMLButtonElement; image: HTMLImageElement; setupListener: boolean}> = {};

addHotkeyListener();

/** Segments created by the user which have not yet been submitted. */
let sponsorTimesSubmitting: SponsorTime[] = [];
let loadedPreloadedSegment = false;

//becomes true when isInfoFound is called
//this is used to close the popup on YouTube when the other popup opens
let popupInitialised = false;

let submissionNotice: SubmissionNotice = null;

let lastResponseStatus: number | Error | string;

// Contains all of the functions and variables needed by the skip notice
const skipNoticeContentContainer: ContentContainer = () => ({
    vote,
    dontShowNoticeAgain,
    unskipSponsorTime,
    sponsorTimes,
    sponsorTimesSubmitting,
    skipNotices,
    sponsorVideoID: getVideoID(),
    reskipSponsorTime,
    updatePreviewBar,
    onMobileYouTube: isOnMobileYouTube(),
    sponsorSubmissionNotice: submissionNotice,
    resetSponsorSubmissionNotice,
    updateEditButtonsOnPlayer,
    previewTime,
    videoInfo,
    getRealCurrentTime: getRealCurrentTime,
    lockedCategories,
    channelIDInfo: getChannelIDInfo()
});

// value determining when to count segment as skipped and send telemetry to server (percent based)
const manualSkipPercentCount = 0.5;

//get messages from the background script and the popup
chrome.runtime.onMessage.addListener(messageListener);

function messageListener(request: Message, sender: unknown, sendResponse: (response: MessageResponse) => void): void | boolean {
    //messages from popup script
    switch(request.message){
        case "update":
            checkVideoIDChange();
            break;
        case "sponsorStart":
            startOrEndTimingNewSegment()

            sendResponse({
                creatingSegment: isSegmentCreationInProgress(),
            });

            break;
        case "isInfoFound":
            //send the sponsor times along with if it's found
            sendResponse({
                found: sponsorDataFound,
                status: lastResponseStatus,
                sponsorTimes: sponsorTimes.filter((segment) => getCategorySelection(segment).option !== CategorySkipOption.Disabled),
                time: getCurrentTime() ?? 0,
                onMobileYouTube: isOnMobileYouTube(),
                videoID: getVideoID(),
                loopedChapter: loopedChapter?.UUID,
                channelID: getChannelIDInfo().id,
                channelAuthor: getChannelIDInfo().author,
                currentTabSkipProfileID: getSkipProfileIDForTab()
            });

            if (!request.updating && popupInitialised && document.getElementById("sponsorBlockPopupContainer") != null) {
                //the popup should be closed now that another is opening
                closeInfoMenu();
            }

            popupInitialised = true;
            break;
        case "getChannelID":
            sendResponse({
                channelID: getChannelIDInfo().id,
                isYTTV: (document.location.host === "tv.youtube.com")
            });

            break;
        case "submitTimes":
            openSubmissionMenu();
            break;
        case "refreshSegments":
            // update video on refresh if videoID invalid
            if (!getVideoID()) {
                checkVideoIDChange();
            }
            // if popup rescieves no response, or the videoID is invalid,
            // it will assume the page is not a video page and stop the refresh animation
            sendResponse({ hasVideo: getVideoID() != null });
            // fetch segments
            if (getVideoID()) {
                sponsorsLookup(false, true);
            }

            break;
        case "unskip":
            unskipSponsorTime(sponsorTimes.find((segment) => segment.UUID === request.UUID), null, true);
            break;
        case "reskip":
            reskipSponsorTime(sponsorTimes.find((segment) => segment.UUID === request.UUID), true);
            break;
        case "selectSegment":
            selectSegment(request.UUID);
            break;
        case "submitVote":
            vote(request.type, request.UUID).then((response) => sendResponse(response));
            return true;
        case "hideSegment":
            utils.getSponsorTimeFromUUID(sponsorTimes, request.UUID).hidden = request.type;
            utils.addHiddenSegment(getVideoID(), request.UUID, request.type);
            updatePreviewBar();

            if (skipButtonControlBar?.isEnabled()
                && sponsorTimesSubmitting.every((s) => s.hidden !== SponsorHideType.Visible || s.actionType !== ActionType.Poi)) {
                skipButtonControlBar.disable();
            }
            break;
        case "closePopup":
            closeInfoMenu();
            break;
        case "copyToClipboard":
            navigator.clipboard.writeText(request.text);
            break;
        case "loopChapter":
            if (!request.UUID){
                loopedChapter = null;
                break;
            }
            loopedChapter = {...utils.getSponsorTimeFromUUID(sponsorTimes, request.UUID)};
            loopedChapter.segment = [loopedChapter.segment[1], loopedChapter.segment[0]];
            break;
        case "importSegments": {
            const importedSegments = importTimes(request.data, getVideoDuration());
            let addedSegments = false;
            for (const segment of importedSegments) {
                if (!sponsorTimesSubmitting.some(
                        (s) => Math.abs(s.segment[0] - segment.segment[0]) < 1
                            && Math.abs(s.segment[1] - segment.segment[1]) < 1
                            && s.description === segment.description)) {
                    const hasChaptersPermission = (Config.config.showCategoryWithoutPermission
                        || Config.config.permissions["chapter"]);
                    if (segment.category === "chapter" && (!getCategoryDefaultSelection("chapter") || !hasChaptersPermission)) {
                        segment.category = "chooseACategory" as Category;
                        segment.actionType = ActionType.Skip;
                        segment.description = "";
                    }

                    sponsorTimesSubmitting.push(segment);
                    addedSegments = true;
                }
            }

            if (addedSegments) {
                Config.local.unsubmittedSegments[getVideoID()] = sponsorTimesSubmitting;
                Config.forceLocalUpdate("unsubmittedSegments");

                updateEditButtonsOnPlayer();
                updateSponsorTimesSubmitting(false);
                openSubmissionMenu();
            }

            sendResponse({
                importedSegments
            });
            break;
        }
        case "keydown":
            (document.body || document).dispatchEvent(new KeyboardEvent('keydown', {
                key: request.key,
                keyCode: request.keyCode,
                code: request.code,
                which: request.which,
                shiftKey: request.shiftKey,
                ctrlKey: request.ctrlKey,
                altKey: request.altKey,
                metaKey: request.metaKey
            }));
            break;
        case "getLogs":
            sendResponse({
                debug: window["SBLogs"].debug,
                warn: window["SBLogs"].warn
            });
            break;
        case "setCurrentTabSkipProfile":
            setCurrentTabSkipProfile(request.configID);
            channelIDChange();
            break;
    }

    sendResponse({});
}

/**
 * Called when the config is updated
 */
function contentConfigUpdateListener(changes: StorageChangesObject) {
    for (const key in changes) {
        switch(key) {
            case "hideVideoPlayerControls":
            case "hideInfoButtonPlayerControls":
            case "hideDeleteButtonPlayerControls":
                updateVisibilityOfPlayerControlsButton()
                break;
            case "categorySelections":
                channelIDChange();
                break;
            case "barTypes":
                setCategoryColorCSSVariables();
                break;
            case "fullVideoSegments":
            case "fullVideoLabelsOnThumbnails":
                updateAll();
                break;
        }
    }
}
function contentLocalConfigUpdateListener(changes: StorageChangesObject) {
    for (const key in changes) {
        switch(key) {
            case "channelSkipProfileIDs":
            case "skipProfiles":
            case "skipProfileTemp":
            case "skipRules":
                channelIDChange();
                break;
        }
    }
}

if (!window.location.href.includes("youtube.com/live_chat")) {
    if (!Config.configSyncListeners.includes(contentConfigUpdateListener)) {
        Config.configSyncListeners.push(contentConfigUpdateListener);
    }

    if (!Config.configLocalListeners.includes(contentLocalConfigUpdateListener)) {
        Config.configLocalListeners.push(contentLocalConfigUpdateListener);
    }
}

function resetValues() {
    lastCheckTime = 0;
    lastCheckVideoTime = -1;
    previewedSegment = false;
    firstPlay = true;

    sponsorTimes = [];
    existingChaptersImported = false;
    sponsorSkipped = [];
    loopedChapter = null;
    lastResponseStatus = 0;
    shownSegmentFailedToFetchWarning = false;

    videoInfo = null;
    lockedCategories = [];

    //empty the preview bar
    if (previewBar !== null) {
        previewBar.clear();
    }

    //reset sponsor data found check
    sponsorDataFound = false;

    // When first loading a video, it is not switching videos
    // Hover play also doesn't need this check
    if (switchingVideos === null || !onVideoPage()) {
        switchingVideos = false;
    } else {
        switchingVideos = true;
        logDebug("Setting switching videos to true (reset data)");
    }

    skipButtonControlBar?.disable();
    categoryPill?.setVisibility(false);

    for (let i = 0; i < skipNotices.length; i++) {
        skipNotices.pop()?.close();
    }

    if (upcomingNotice) {
        upcomingNotice.close();
        upcomingNotice = null;
    }

    hideDeArrowPromotion();
}

function videoIDChange(): void {
    //setup the preview bar
    if (previewBar === null) {
        if (isOnMobileYouTube()) {
            // Mobile YouTube workaround
            const observer = new MutationObserver(handleMobileControlsMutations);
            let controlsContainer = null;

            utils.wait(() => {
                controlsContainer = document.getElementById("player-control-container")
                return controlsContainer !== null
            }).then(() => {
                observer.observe(document.getElementById("player-control-container"), {
                    attributes: true,
                    childList: true,
                    subtree: true
                });
            }).catch();
        } else {
            utils.wait(getControls).then(createPreviewBar);
        }
    }

    // Notify the popup about the video change
    chrome.runtime.sendMessage({
        message: "videoChanged",
        videoID: getVideoID(),
        channelID: getChannelIDInfo().id,
        channelAuthor: getChannelIDInfo().author
    });

    sponsorsLookup();

    // Make sure all player buttons are properly added
    updateVisibilityOfPlayerControlsButton();

    // Clear unsubmitted segments from the previous video
    sponsorTimesSubmitting = [];
    updateSponsorTimesSubmitting();

    tryShowingDeArrowPromotion().catch(logWarn);

    checkPreviewbarState();

    if (getIsInline()) {
        // Hover preview progress bar can take some time to appear
        //   and if the miniplayer is also active then it would 
        //   attach to the wrong one
        setTimeout(checkPreviewbarState, 500);
        setTimeout(checkPreviewbarState, 1000);
        setTimeout(checkPreviewbarState, 3000);
    }
}

function handleMobileControlsMutations(): void {
    // Don't update while scrubbing
    if (!chrome.runtime?.id 
            || document.querySelector(".YtProgressBarProgressBarPlayheadDotInDragging")) return;

    updateVisibilityOfPlayerControlsButton();

    skipButtonControlBar?.updateMobileControls();

    if (previewBar !== null) {
        if (!previewBar.parent.contains(previewBar.container) && isMobileControlsOpen()) {
            previewBar.createElement();
            updatePreviewBar();

            return;
        } else if (!previewBar.parent.isConnected) {
            // The parent does not exist anymore, remove that old preview bar
            previewBar.remove();
            previewBar = null;
        }
    }

    // Create the preview bar if needed (the function hasn't returned yet)
    createPreviewBar();
}

function getPreviewBarAttachElement(): HTMLElement | null {
    const progressElementOptions = [{
            // For newer mobile YouTube (Sept 2024)
            selector: ".ytChapteredProgressBarHost, .ytProgressBarLineHost, .YtProgressBarLineHost, .YtChapteredProgressBarHost",
            isVisibleCheck: true
        }, {
            // For newer mobile YouTube (May 2024)
            selector: ".YtmProgressBarProgressBarLine",
            isVisibleCheck: true
        }, {
            // For desktop YouTube hover play
            // Priority is given to the hover play progress bar over the main progress bar
            //   for miniplayer + hover preview case
            // Second is new hover play selector
            selector: "#video-preview .ytp-progress-bar, #video-preview .YtProgressBarLineHost",
            isVisibleCheck: true
        }, {
            // For desktop YouTube
            selector: ".ytp-progress-bar",
            isVisibleCheck: true
        }, {
            // For desktop YouTube
            selector: ".no-model.cue-range-marker",
            isVisibleCheck: true
        }, {
            // For Invidious/VideoJS
            selector: ".vjs-progress-holder",
            isVisibleCheck: false
        }, {
            // For Youtube Music and YTKids
            // there are two sliders, one for volume and one for progress - both called #progressContainer
            selector: "#progress-bar>#sliderContainer>div>#sliderBar>#progressContainer",
        }, {
            // For piped
            selector: ".shaka-ad-markers",
            isVisibleCheck: false
        }, {
            // For Vorapis v3
            selector: ".ytp-progress-bar-container > .html5-progress-bar > .ytp-progress-list"
        }, {
            // For YTTV
            selector: ".yssi-slider > div.ytu-ss-timeline-container",
            isVisibleCheck: false
        }
    ];

    for (const option of progressElementOptions) {
        const allElements = document.querySelectorAll(option.selector) as NodeListOf<HTMLElement>;
        const el = option.isVisibleCheck ? findValidElement(allElements) : allElements[0];

        if (el) {
            return el;
        }
    }

    return null;
}

/**
 * Creates a preview bar on the video
 */
function createPreviewBar(): void {
    if (previewBar !== null) return;

    const el = getPreviewBarAttachElement();

    if (el) {
        const chapterVote = new ChapterVote(voteAsync);
        previewBar = new PreviewBar(el, isOnMobileYouTube(), isOnInvidious(), isOnYTTV(), chapterVote, () => importExistingChapters(true));

        updatePreviewBar();
    }
}

/**
 * Triggered every time the video duration changes.
 * This happens when the resolution changes or at random time to clear memory.
 */
function durationChangeListener(): void {
    updateAdFlag();
    updatePreviewBar();
}

/**
 * Triggered once the video is ready.
 * This is mainly to attach to embedded players who don't have a video element visible.
 */
function videoOnReadyListener(): void {
    createPreviewBar();
    updatePreviewBar();
    updateVisibilityOfPlayerControlsButton()
}

function cancelSponsorSchedule(): void {
    logDebug("Pausing skipping");

    if (currentSkipSchedule !== null) {
        clearTimeout(currentSkipSchedule);
        currentSkipSchedule = null;
    }

    if (currentSkipInterval !== null) {
        clearInterval(currentSkipInterval);
        currentSkipInterval = null;
    }

    if (currentUpcomingSchedule !== null) {
        clearTimeout(currentUpcomingSchedule);
        currentUpcomingSchedule = null;
    }
}

/**
 * @param currentTime Optional if you don't want to use the actual current time
 */
async function startSponsorSchedule(includeIntersectingSegments = false, currentTime?: number, includeNonIntersectingSegments = true): Promise<void> {
    cancelSponsorSchedule();

    // Don't skip if advert playing and reset last checked time
    if (getIsAdPlaying()) {
        // Reset lastCheckVideoTime
        lastCheckVideoTime = -1;
        lastCheckTime = 0;
        logDebug("[SB] Ad playing, pausing skipping");

        return;
    }

    // Give up if video changed, and trigger a videoID change if so
    if (await checkIfNewVideoID()) {
        return;
    }

    logDebug(`Considering to start skipping: ${!getVideo()}, ${getVideo()?.paused}`);
    if (!getVideo()) return;
    if (currentTime === undefined || currentTime === null) {
        currentTime = getVirtualTime();
    }
    clearWaitingTime();

    updateActiveSegment(currentTime);

    if ((getVideo().paused && getCurrentTime() !== 0) // Allow autoplay disabled videos to skip before playing
        || (getCurrentTime() >= getVideoDuration() - 0.01 && getVideoDuration() > 1)) return;
    const skipInfo = getNextSkipIndex(currentTime, includeIntersectingSegments, includeNonIntersectingSegments);

    const currentSkip = skipInfo.array[skipInfo.index];
    const skipTime: number[] = [currentSkip?.scheduledTime, skipInfo.array[skipInfo.endIndex]?.segment[1]];
    const timeUntilSponsor = skipTime?.[0] - currentTime;
    const videoID = getVideoID();

    if (videoMuted && !inMuteSegment(currentTime, skipInfo.index !== -1
            && timeUntilSponsor < skipBuffer && shouldAutoSkip(currentSkip))) {
        getVideo().muted = false;
        videoMuted = false;

        for (const notice of skipNotices) {
            // So that the notice can hide buttons
            notice.unmutedListener(currentTime);
        }
    }

    logDebug(`Ready to start skipping: ${skipInfo.index} at ${currentTime}`);
    if (skipInfo.index === -1) return;

    if (Config.config.disableSkipping || (getChannelIDInfo().status === ChannelIDStatus.Fetching && Config.config.forceChannelCheck)){
        return;
    }

    if (incorrectVideoCheck()) return;

    // Find all indexes in between the start and end
    let skippingSegments = [skipInfo.array[skipInfo.index]];
    if (skipInfo.index !== skipInfo.endIndex) {
        skippingSegments = [];

        for (const segment of skipInfo.array) {
            if (shouldAutoSkip(segment) &&
                    segment.segment[0] >= skipTime[0] && segment.segment[1] <= skipTime[1]
                    && segment.segment[0] === segment.scheduledTime) { // Don't include artificial scheduled segments (end times for mutes)
                skippingSegments.push(segment);
            }
        }
    }

    logDebug(`Next step in starting skipping: ${!shouldSkip(currentSkip)}, ${!sponsorTimesSubmitting?.some((segment) => segment.segment === currentSkip.segment)}`);

    const skippingFunction = (forceVideoTime?: number) => {
        let forcedSkipTime: number = null;
        let forcedIncludeIntersectingSegments = false;
        let forcedIncludeNonIntersectingSegments = true;

        if (incorrectVideoCheck(videoID, currentSkip)) return;
        forceVideoTime ||= Math.max(getCurrentTime(), getVirtualTime());

        if ((shouldSkip(currentSkip)
                || sponsorTimesSubmitting?.some((segment) => segment.segment === currentSkip.segment
                    && segment.actionType !== ActionType.Chapter
                    && segment.hidden === SponsorHideType.Visible))) {
            if (forceVideoTime >= skipTime[0] - skipBuffer && (forceVideoTime < skipTime[1] || skipTime[1] < skipTime[0])) {
                skipToTime({
                    v: getVideo(),
                    skipTime,
                    skippingSegments,
                    openNotice: skipInfo.openNotice
                });

                // These are segments that start at the exact same time but need separate notices
                for (const extra of skipInfo.extraIndexes) {
                    const extraSkip = skipInfo.array[extra];
                    if (shouldSkip(extraSkip)) {
                        skipToTime({
                            v: getVideo(),
                            skipTime: [extraSkip.scheduledTime, extraSkip.segment[1]],
                            skippingSegments: [extraSkip],
                            openNotice: skipInfo.openNotice
                        });
                    }
                }

                if (getCategorySelection(currentSkip)?.option === CategorySkipOption.ManualSkip
                        || currentSkip.actionType === ActionType.Mute) {
                    forcedSkipTime = skipTime[0] + 0.001;
                } else {
                    forcedSkipTime = skipTime[1];
                    forcedIncludeNonIntersectingSegments = false;

                    // Only if not at the end of the video
                    if (Math.abs(skipTime[1] - getVideoDuration()) > endTimeSkipBuffer) {
                        forcedIncludeIntersectingSegments = true;
                    }
                }
            } else {
                forcedSkipTime = forceVideoTime + 0.001;
            }
        } else {
            forcedSkipTime = forceVideoTime + 0.001;
        }

        // Don't pretend to be earlier than we are, could result in loops
        if (forcedSkipTime !== null && forceVideoTime > forcedSkipTime && skipTime[1] > skipTime[0]) {
            forcedSkipTime = forceVideoTime;
        }

        startSponsorSchedule(forcedIncludeIntersectingSegments, forcedSkipTime, forcedIncludeNonIntersectingSegments);
    };

    if (timeUntilSponsor < skipBuffer) {
        skippingFunction(currentTime);
    } else {
        let delayTime = timeUntilSponsor * 1000 * (1 / getVideo().playbackRate);
        if (delayTime < (isFirefoxOrSafari() && !isSafari() ? 750 : 300)
                && shouldAutoSkip(skippingSegments[0])) {
            let forceStartIntervalTime: number | null = null;
            if (isFirefoxOrSafari() && !isSafari() && delayTime > 300) {
                forceStartIntervalTime = await waitForNextTimeChange();
            }

            // Use interval instead of timeout near the end to combat imprecise video time
            const startIntervalTime = forceStartIntervalTime || performance.now();
            const startVideoTime = Math.max(currentTime, getVirtualTime());
            delayTime = (skipTime?.[0] - startVideoTime) * 1000 * (1 / getVideo().playbackRate);

            let startWaitingForReportedTimeToChange = true;
            const reportedVideoTimeAtStart = getCurrentTime();
            logDebug(`Starting setInterval skipping ${getCurrentTime()} to skip at ${skipTime[0]}`);

            if (currentSkipInterval !== null) clearInterval(currentSkipInterval);
            currentSkipInterval = setInterval(() => {
                // Estimate delay, but only take the current time right after a change
                // Current time remains the same for many "frames" on Firefox
                if (isFirefoxOrSafari() && !lastKnownVideoTime.fromPause && startWaitingForReportedTimeToChange
                        && reportedVideoTimeAtStart !== getCurrentTime()) {
                    startWaitingForReportedTimeToChange = false;
                    const delay = getVirtualTime() - getCurrentTime();
                    if (delay > 0) lastKnownVideoTime.approximateDelay = delay;
                }

                const intervalDuration = performance.now() - startIntervalTime;
                if (intervalDuration + skipBuffer * 1000 >= delayTime || getVirtualTime() + skipBuffer >= skipTime[0]) {
                    clearInterval(currentSkipInterval);
                    if (!isFirefoxOrSafari() && !getVideo().muted && !inMuteSegment(getCurrentTime(), true)) {
                        // Workaround for more accurate skipping on Chromium
                        getVideo().muted = true;
                        getVideo().muted = false;
                    }

                    skippingFunction(Math.max(getVirtualTime(), startVideoTime + getVideo().playbackRate * Math.max(delayTime, intervalDuration) / 1000));
                }
            }, 0);
        } else {
            logDebug(`Starting timeout to skip ${getCurrentTime()} to skip at ${skipTime[0]}`);

            const offset = (isFirefoxOrSafari() && !isSafari() ? 600 : 150);
            // Schedule for right before to be more precise than normal timeout
            const offsetDelayTime = Math.max(0, delayTime - offset);
            currentSkipSchedule = setTimeout(skippingFunction, offsetDelayTime);

            // Show the notice only if the segment hasn't already started
            if (Config.config.showUpcomingNotice && getCurrentTime() < skippingSegments[0].segment[0] 
                    && !sponsorTimesSubmitting?.some((segment) => segment.segment === currentSkip.segment)
                    && [ActionType.Skip, ActionType.Mute].includes(skippingSegments[0].actionType)
                    && getCategorySelection(skippingSegments[0])?.option > CategorySkipOption.ShowOverlay
                    && !getVideo()?.paused) {
                const maxPopupTime = 3000;
                const timeUntilPopup = Math.max(0, offsetDelayTime - maxPopupTime);
                const popupTime = offsetDelayTime - timeUntilPopup;
                const autoSkip = shouldAutoSkip(skippingSegments[0]);

                if (currentUpcomingSchedule) clearTimeout(currentUpcomingSchedule);
                currentUpcomingSchedule = setTimeout(createUpcomingNotice, timeUntilPopup, [skippingSegments[0]], popupTime, autoSkip);
            }
        }
    }
}

/**
 * Used on Firefox only, waits for the next animation frame until
 * the video time has changed
 */
function waitForNextTimeChange(): Promise<DOMHighResTimeStamp | null> {
    return new Promise((resolve) => {
        getVideo().addEventListener("timeupdate", () => resolve(performance.now()), { once: true });
    });
}

function getVirtualTime(): number {
    const virtualTime = lastTimeFromWaitingEvent ?? (lastKnownVideoTime.videoTime !== null ?
        (performance.now() - lastKnownVideoTime.preciseTime) * (getVideo()?.playbackRate || 1) / 1000 + lastKnownVideoTime.videoTime : null);

    if (Config.config.useVirtualTime && !isSafari() && virtualTime
            && virtualTime > getCurrentTime() && virtualTime - getCurrentTime() < 0.8 && getCurrentTime() !== 0) {
        return Math.max(virtualTime, getCurrentTime());
    } else {
        return getCurrentTime();
    }
}

function inMuteSegment(currentTime: number, includeOverlap: boolean): boolean {
    const checkFunction = (segment) => segment.actionType === ActionType.Mute
        && segment.hidden === SponsorHideType.Visible
        && segment.segment[0] <= currentTime
        && (segment.segment[1] > currentTime || (includeOverlap && segment.segment[1] + 0.02 > currentTime));
    return sponsorTimes?.some(checkFunction) || sponsorTimesSubmitting.some(checkFunction);
}

/**
 * This makes sure the videoID is still correct and if the sponsorTime is included
 */
function incorrectVideoCheck(videoID?: string, sponsorTime?: SponsorTime): boolean {
    if (!onVideoPage()) return false;

    const currentVideoID = getYouTubeVideoID();
    const recordedVideoID = videoID || getVideoID();
    if (currentVideoID !== recordedVideoID || (sponsorTime
            && (!sponsorTimes || !sponsorTimes?.some((time) => time.segment[0] === sponsorTime.segment[0] && time.segment[1] === sponsorTime.segment[1]))
            && !sponsorTimesSubmitting.some((time) => time.segment[0] === sponsorTime.segment[0] && time.segment[1] === sponsorTime.segment[1])
            && (!isLoopedChapter(sponsorTime)))) {
        // Something has really gone wrong
        console.error("[SponsorBlock] The videoID recorded when trying to skip is different than what it should be.");
        console.error("[SponsorBlock] VideoID recorded: " + recordedVideoID + ". Actual VideoID: " + currentVideoID);
        console.error("[SponsorBlock] SponsorTime", sponsorTime, "sponsorTimes", sponsorTimes, "sponsorTimesSubmitting", sponsorTimesSubmitting);

        // Video ID change occured
        checkVideoIDChange();

        return true;
    } else {
        return false;
    }
}

let playbackRateCheckInterval: NodeJS.Timeout | null = null;
let lastPlaybackSpeed = 1;
let setupVideoListenersFirstTime = true;
function setupVideoListeners(video: HTMLVideoElement) {
    if (!video) return; // Maybe video became invisible

    //wait until it is loaded
    video.addEventListener('loadstart', videoOnReadyListener)
    video.addEventListener('durationchange', durationChangeListener);

    if (setupVideoListenersFirstTime) {
        addCleanupListener(() => {
            video.removeEventListener('loadstart', videoOnReadyListener);
            video.removeEventListener('durationchange', durationChangeListener);
        });
    }

    if (!Config.config.disableSkipping) {
        switchingVideos = false;

        let startedWaiting = false;
        let lastPausedAtZero = true;
        let lastVideoDataChange = 0;

        const rateChangeListener = () => {
            updateVirtualTime();
            clearWaitingTime();

            startSponsorSchedule();
        };
        video.addEventListener('ratechange', rateChangeListener);
        // Used by videospeed extension (https://github.com/igrigorik/videospeed/pull/740)
        video.addEventListener('videoSpeed_ratechange', rateChangeListener);

        const playListener = () => {
            // Prevent video resolution changes from causing skips
            if (!firstPlay && Date.now() - lastVideoDataChange < 200 && video.currentTime === 0) return;

            firstPlay = false;
            updateVirtualTime();
            checkForMiniplayerPlaying();

            if (switchingVideos || lastPausedAtZero) {
                switchingVideos = false;
                logDebug("Setting switching videos to false");

                // If already segments loaded before video, retry to skip starting segments
                if (sponsorTimes) startSkipScheduleCheckingForStartSponsors();
            }

            lastPausedAtZero = false;

            // Check if an ad is playing
            updateAdFlag();

            // Make sure it doesn't get double called with the playing event
            if (Math.abs(lastCheckVideoTime - video.currentTime) > 0.3
                    || (lastCheckVideoTime !== video.currentTime && Date.now() - lastCheckTime > 2000)) {
                lastCheckTime = Date.now();
                lastCheckVideoTime = video.currentTime;

                startSponsorSchedule();
            }
        };
        video.addEventListener('play', playListener);

        const playingListener = () => {
            updateVirtualTime();
            lastPausedAtZero = false;

            if (startedWaiting) {
                startedWaiting = false;
                logDebug(`[SB] Playing event after buffering: ${Math.abs(lastCheckVideoTime - video.currentTime) > 0.3
                    || (lastCheckVideoTime !== video.currentTime && Date.now() - lastCheckTime > 2000)}`);
            }

            if (switchingVideos) {
                switchingVideos = false;
                logDebug("Setting switching videos to false");

                // If already segments loaded before video, retry to skip starting segments
                if (sponsorTimes) startSkipScheduleCheckingForStartSponsors();
            }

            // Make sure it doesn't get double called with the play event
            if (Math.abs(lastCheckVideoTime - video.currentTime) > 0.3
                    || (lastCheckVideoTime !== video.currentTime && Date.now() - lastCheckTime > 2000)) {
                lastCheckTime = Date.now();
                lastCheckVideoTime = video.currentTime;

                startSponsorSchedule();
            }

            if (playbackRateCheckInterval) clearInterval(playbackRateCheckInterval);
            lastPlaybackSpeed = video.playbackRate;

            // Video speed controller compatibility
            // That extension makes rate change events not propagate
            if (document.body.classList.contains("vsc-initialized")) {
                playbackRateCheckInterval = setInterval(() => {
                    if ((!getVideoID() || video.paused) && playbackRateCheckInterval) {
                        // Video is gone, stop checking
                        clearInterval(playbackRateCheckInterval);
                        return;
                    }
    
                    if (video.playbackRate !== lastPlaybackSpeed) {
                        lastPlaybackSpeed = video.playbackRate;
    
                        rateChangeListener();
                    }
                }, 2000);
            }
        };
        video.addEventListener('playing', playingListener);
        
        const seekingListener = () => {
            lastKnownVideoTime.fromPause = false;

            if (!video.paused){
                // Reset lastCheckVideoTime
                lastCheckTime = Date.now();
                lastCheckVideoTime = video.currentTime;

                updateVirtualTime();
                clearWaitingTime();

                // Sometimes looped videos loop back to almost zero, but not quite
                if (video.loop && video.currentTime < 0.2 && getCurrentTime() < 0.2) {
                    startSponsorSchedule(false, 0);
                } else {
                    startSponsorSchedule();
                }
            } else {
                updateActiveSegment(getCurrentTime());

                if (getCurrentTime() === 0) {
                    lastPausedAtZero = true;
                }
            }
        };
        video.addEventListener('seeking', seekingListener);
        
        const stoppedPlayback = () => {
            // Reset lastCheckVideoTime
            lastCheckVideoTime = -1;
            lastCheckTime = 0;

            if (playbackRateCheckInterval) clearInterval(playbackRateCheckInterval);

            lastKnownVideoTime.videoTime = null;
            lastKnownVideoTime.preciseTime = null;
            updateWaitingTime(video);

            cancelSponsorSchedule();
        };
        const pauseListener = () => {
            lastKnownVideoTime.fromPause = true;

            stoppedPlayback();
        };
        video.addEventListener('pause', pauseListener);
        const waitingListener = () => {
            logDebug("[SB] Not skipping due to buffering");
            startedWaiting = true;

            stoppedPlayback();
        };
        video.addEventListener('waiting', waitingListener);

        // When video data is changed
        const emptyListener = () => {
            lastVideoDataChange = Date.now();

            if (firstPlay && video.currentTime === 0) {
                playListener();
            }
        }
        video.addEventListener('emptied', emptyListener);

        // For when autoplay is off to skip before starting playback
        const metadataLoadedListener = () => {
            if (firstPlay && getCurrentTime() === 0) {
                playListener();
            }
        }
        video.addEventListener('loadedmetadata', metadataLoadedListener)

        startSponsorSchedule();

        if (setupVideoListenersFirstTime) {
            addCleanupListener(() => {
                video.removeEventListener('play', playListener);
                video.removeEventListener('playing', playingListener);
                video.removeEventListener('seeking', seekingListener);
                video.removeEventListener('ratechange', rateChangeListener);
                video.removeEventListener('videoSpeed_ratechange', rateChangeListener);
                video.removeEventListener('pause', pauseListener);
                video.removeEventListener('waiting', waitingListener);
                video.removeEventListener('empty', emptyListener);
                video.removeEventListener('loadedmetadata', metadataLoadedListener);

                if (playbackRateCheckInterval) clearInterval(playbackRateCheckInterval);
            });
        }
    }

    setupVideoListenersFirstTime = false;
}

function updateVirtualTime() {
    if (currentVirtualTimeInterval) clearInterval(currentVirtualTimeInterval);

    lastKnownVideoTime.videoTime = getCurrentTime();
    lastKnownVideoTime.preciseTime = performance.now();

    // If on Firefox, wait for the second time change (time remains fixed for many "frames" for privacy reasons)
    if (isFirefoxOrSafari()) {
        let count = 0;
        let rawCount = 0;
        let lastTime = lastKnownVideoTime.videoTime;
        let lastPerformanceTime = performance.now();

        currentVirtualTimeInterval = setInterval(() => {
            const frameTime = performance.now() - lastPerformanceTime;
            if (lastTime !== getCurrentTime()) {
                rawCount++;

                // If there is lag, give it another shot at finding a good change time
                if (frameTime < 20 || rawCount > 30) {
                    count++;
                }
                lastTime = getCurrentTime();
            }

            if (count > 1) {
                const delay = lastKnownVideoTime.fromPause && lastKnownVideoTime.approximateDelay ?
                    lastKnownVideoTime.approximateDelay : 0;

                lastKnownVideoTime.videoTime = getCurrentTime() + delay;
                lastKnownVideoTime.preciseTime = performance.now();

                clearInterval(currentVirtualTimeInterval);
                currentVirtualTimeInterval = null;
            }

            lastPerformanceTime = performance.now();
        }, 1);
    }
}

function updateWaitingTime(video: HTMLVideoElement): void {
    lastTimeFromWaitingEvent = video.currentTime;
}

function clearWaitingTime(): void {
    lastTimeFromWaitingEvent = null;
}

function setupSkipButtonControlBar() {
    if (!skipButtonControlBar) {
        skipButtonControlBar = new SkipButtonControlBar({
            skip: (segment) => skipToTime({
                v: getVideo(),
                skipTime: segment.segment,
                skippingSegments: [segment],
                openNotice: true,
                forceAutoSkip: true
            }),
            selectSegment,
            onMobileYouTube: isOnMobileYouTube(),
            onYTTV: isOnYTTV(),
        });
    }

    skipButtonControlBar.attachToPage();
}

function setupCategoryPill() {
    if (!categoryPill) {
        categoryPill = new CategoryPill();
    }

    categoryPill.attachToPage(isOnMobileYouTube(), isOnInvidious(), voteAsync);
}

async function sponsorsLookup(keepOldSubmissions = true, ignoreCache = false) {
    const videoID = getVideoID();
    if (!videoID) {
        console.error("[SponsorBlock] Attempted to fetch segments with a null/undefined videoID.");
        return;
    }

    const segmentData = await getSegmentsForVideo(videoID, ignoreCache);

    // Make sure an old pending request doesn't get used.
    if (videoID !== getVideoID()) return;

    // store last response status
    lastResponseStatus = segmentData.status;
    if (segmentData.status === 200) {
        const receivedSegments = segmentData.segments;

        if (receivedSegments && receivedSegments.length) {
            sponsorDataFound = receivedSegments.findIndex((segment) => getCategorySelection(segment).option !== CategorySkipOption.Disabled) !== -1;

            // Check if any old submissions should be kept
            if (sponsorTimes !== null && keepOldSubmissions) {
                for (let i = 0; i < sponsorTimes.length; i++) {
                    if (sponsorTimes[i].source === SponsorSourceType.Local)  {
                        // This is a user submission, keep it
                        receivedSegments.push(sponsorTimes[i]);
                    }
                }
            }

            const oldSegments = sponsorTimes || [];
            sponsorTimes = receivedSegments;
            existingChaptersImported = false;

            if (keepOldSubmissions) {
                for (const segment of oldSegments) {
                    const otherSegment = sponsorTimes.find((other) => segment.UUID === other.UUID);
                    if (otherSegment) {
                        // If they downvoted it, or changed the category, keep it
                        otherSegment.hidden = segment.hidden;
                        otherSegment.category = segment.category;
                    }
                }
            }

            // See if some segments should be hidden
            const hashPrefix = (await getHash(videoID, 1)).slice(0, 4) as VideoID & HashedValue;
            const downvotedData = Config.local.downvotedSegments[hashPrefix];
            if (downvotedData) {
                for (const segment of sponsorTimes) {
                    const hashedUUID = await getHash(segment.UUID, 1);
                    const segmentDownvoteData = downvotedData.segments.find((downvote) => downvote.uuid === hashedUUID);
                    if (segmentDownvoteData) {
                        segment.hidden = segmentDownvoteData.hidden;
                    }
                }
            }

            hideTooShortSegments(sponsorTimes);

            if (!getVideo()) {
                //there is still no video here
                await waitForVideo();
            }

            startSkipScheduleCheckingForStartSponsors();

            if (!isNaN(getVideoDuration())) {
                updatePreviewBar();
            }
        }
    }

    notifyPopupOfSegments();
    importExistingChapters(true);

    if (Config.config.isVip) {
        lockedCategoriesLookup();
    }
}

function notifyPopupOfSegments(): void {
    // notify popup of segment changes
    chrome.runtime.sendMessage({
        message: "infoUpdated",
        found: sponsorDataFound,
        status: lastResponseStatus,
        sponsorTimes: sponsorTimes.filter((segment) => getCategorySelection(segment).option !== CategorySkipOption.Disabled),
        time: getCurrentTime() ?? 0,
        onMobileYouTube: isOnMobileYouTube(),
        videoID: getVideoID(),
        loopedChapter: loopedChapter?.UUID,
        channelID: getChannelIDInfo().id,
        channelAuthor: getChannelIDInfo().author,
        currentTabSkipProfileID: getSkipProfileIDForTab()
    });
}

function importExistingChapters(wait: boolean) {
    if (!existingChaptersImported && !importingChaptersWaiting && onVideoPage() && !isOnMobileYouTube()) {
        const waitCondition = () => getVideoDuration() && getExistingChapters(getVideoID(), getVideoDuration());

        if (wait && !document.hasFocus() && !importingChaptersWaitingForFocus && !waitCondition()) {
            importingChaptersWaitingForFocus = true;
            const listener = () => {
                importExistingChapters(wait);
                window.removeEventListener("focus", listener);
            };
            window.addEventListener("focus", listener);
        } else {
            importingChaptersWaiting = true;
            waitFor(waitCondition,
                wait ? 15000 : 0, 400, (c) => c?.length > 0).then((chapters) => {
                    importingChaptersWaiting = false;

                    if (!existingChaptersImported && chapters?.length > 0) {
                        sponsorTimes = (sponsorTimes ?? []).concat(...chapters).sort((a, b) => a.segment[0] - b.segment[0]);
                        existingChaptersImported = true;
                        updatePreviewBar();
                    }
                }).catch(() => { importingChaptersWaiting = false; });

            if (!getSkipProfileBool("showAutogeneratedChapters")) {
                waitFor(() => hasAutogeneratedChapters(), wait ? 15000 : 0, 400).then(() => {
                    updateActiveSegment(getCurrentTime());
                }).catch(() => { }); // eslint-disable-line @typescript-eslint/no-empty-function
            }
        }
    }
}

function handleExistingChaptersChannelChange() {
    if (existingChaptersImported && hasAutogeneratedChapters() && !getSkipProfileBool("showAutogeneratedChapters")) {
        sponsorTimes = sponsorTimes.filter((segment) => segment.source !== SponsorSourceType.Autogenerated);
    }
}

async function lockedCategoriesLookup(): Promise<void> {
    const hashPrefix = (await getHash(getVideoID(), 1)).slice(0, 4);
    try {
        const response = await asyncRequestToServer("GET", "/api/lockCategories/" + hashPrefix);

        if (response.ok) {
            const categoriesResponse = JSON.parse(response.responseText).filter((lockInfo) => lockInfo.videoID === getVideoID())[0]?.categories;
            if (Array.isArray(categoriesResponse)) {
                lockedCategories = categoriesResponse;
            }
        } else if (response.status !== 404) {
            logRequest(response, "SB", "locked categories")
        }
    } catch (e) {
        console.warn(`[SB] Caught error while looking up category locks for hashprefix ${hashPrefix}`, e)
    }
}

/**
 * Only should be used when it is okay to skip a sponsor when in the middle of it
 *
 * Ex. When segments are first loaded
 */
function startSkipScheduleCheckingForStartSponsors() {
	// switchingVideos is ignored in Safari due to event fire order. See #1142
    if ((!switchingVideos || isSafari()) && sponsorTimes) {
        // See if there are any starting sponsors
        let startingSegmentTime = getStartTimeFromUrl(document.URL) || -1;
        let found = false;
        for (const time of sponsorTimes) {
            if (time.segment[0] <= getCurrentTime() && time.segment[0] > startingSegmentTime && time.segment[1] > getCurrentTime()
                    && time.actionType !== ActionType.Poi) {
                        startingSegmentTime = time.segment[0];
                        found = true;
                break;
            }
        }
        if (!found) {
            for (const time of sponsorTimesSubmitting) {
                if (time.segment[0] <= getCurrentTime() && time.segment[0] > startingSegmentTime && time.segment[1] > getCurrentTime()
                        && time.actionType !== ActionType.Poi) {
                            startingSegmentTime = time.segment[0];
                            found = true;
                    break;
                }
            }
        }

        // For highlight category
        const poiSegments = sponsorTimes
            .filter((time) => time.segment[1] > getCurrentTime()
                && time.actionType === ActionType.Poi
                && time.hidden === SponsorHideType.Visible
                && getCategorySelection(time).option !== CategorySkipOption.Disabled)
            .sort((a, b) => b.segment[0] - a.segment[0]);
        for (const time of poiSegments) {
            const skipOption = getCategorySelection(time)?.option;
            if (skipOption !== CategorySkipOption.ShowOverlay) {
                skipToTime({
                    v: getVideo(),
                    skipTime: time.segment,
                    skippingSegments: [time],
                    openNotice: true,
                    unskipTime: getCurrentTime()
                });
                if (skipOption === CategorySkipOption.AutoSkip) break;
            }
        }

        if (startingSegmentTime !== -1) {
            startSponsorSchedule(undefined, startingSegmentTime);
        } else {
            startSponsorSchedule();
        }
    }
}

function selectSegment(UUID: SegmentUUID): void {
    selectedSegment = UUID;
    updatePreviewBar();
}

function updatePreviewBar(): void {
    if (previewBar === null) return;

    if (getIsAdPlaying()) {
        previewBar.clear();
        return;
    }

    if (getVideo() === null) return;

    const hashParams = getHashParams();
    const requiredSegment = hashParams?.requiredSegment as SegmentUUID || undefined;
    const previewBarSegments: PreviewBarSegment[] = [];
    if (sponsorTimes) {
        sponsorTimes.forEach((segment) => {
            if (segment.hidden !== SponsorHideType.Visible || getCategorySelection(segment).option === CategorySkipOption.Disabled) return;

            previewBarSegments.push({
                segment: segment.segment as [number, number],
                category: segment.category,
                actionType: segment.actionType,
                unsubmitted: false,
                showLarger: segment.actionType === ActionType.Poi,
                description: segment.description,
                source: segment.source,
                requiredSegment: requiredSegment && (segment.UUID === requiredSegment || segment.UUID?.startsWith(requiredSegment) || requiredSegment.startsWith(segment.UUID)),
                selectedSegment: selectedSegment && segment.UUID === selectedSegment
            });
        });
    }

    sponsorTimesSubmitting.forEach((segment) => {
        if (segment.hidden === SponsorHideType.Visible
                && (segment.actionType !== ActionType.Chapter || segment.segment.length > 1)) {
            previewBarSegments.push({
                segment: segment.segment as [number, number],
                category: segment.category,
                actionType: segment.actionType,
                unsubmitted: true,
                showLarger: segment.actionType === ActionType.Poi,
                description: segment.description,
                source: segment.source
            });
        }
    });

    previewBar.set(previewBarSegments.filter((segment) => segment.actionType !== ActionType.Full), getVideoDuration())
    if (getVideo()) updateActiveSegment(getCurrentTime());

    if (Config.config.showTimeWithSkips) {
        const skippedDuration = utils.getTimestampsDuration(previewBarSegments
            .filter(({actionType}) => ![ActionType.Mute, ActionType.Chapter].includes(actionType))
            .map(({segment}) => segment));

        showTimeWithoutSkips(skippedDuration);
    }
}

function updateCategoryPill() {
    const fullVideoSegment = sponsorTimes.filter((time) => time.actionType === ActionType.Full)[0];
    if (fullVideoSegment && getSkipProfileBool("fullVideoSegments")) {
        categoryPill?.setSegment(fullVideoSegment);
    } else {
        categoryPill?.setVisibility(false);
    }
}

//checks if this channel is whitelisted, should be done only after the channelID has been loaded
async function channelIDChange() {
    // check if the start of segments were missed
    if (Config.config.forceChannelCheck && sponsorTimes?.length > 0) startSkipScheduleCheckingForStartSponsors();

    hideTooShortSegments(sponsorTimes);
    handleExistingChaptersChannelChange();
    updatePreviewBar();
    updateCategoryPill();
    notifyPopupOfSegments();
}

function videoElementChange(newVideo: boolean, video: HTMLVideoElement): void {
    waitFor(() => Config.isReady()).then(() => {
        if (newVideo) {
            setupVideoListeners(video);
            setupSkipButtonControlBar();
            setupCategoryPill();
        }
        
        updatePreviewBar();
        checkPreviewbarState();
    
        // Incase the page is still transitioning, check again in a few seconds
        setTimeout(checkPreviewbarState, 100);
        setTimeout(checkPreviewbarState, 1000);
        setTimeout(checkPreviewbarState, 5000);
    })
}

let checkingPreviewbarAgain = false;
function checkPreviewbarState(): void {
    if (!getPreviewBarAttachElement() && !checkingPreviewbarAgain && getVideo() && getVideoID()) {
        checkingPreviewbarAgain = true;
        setTimeout(() => {
            checkingPreviewbarAgain = false;
            checkPreviewbarState();
        }, 500);

        return;
    }

    if (previewBar && !getPreviewBarAttachElement()?.contains(previewBar.container)) {
        previewBar.remove();
        previewBar = null;
    }

    createPreviewBar();
}

/**
 * Returns info about the next upcoming sponsor skip
 */
function getNextSkipIndex(currentTime: number, includeIntersectingSegments: boolean, includeNonIntersectingSegments: boolean):
        {array: ScheduledTime[]; index: number; endIndex: number; extraIndexes: number[]; openNotice: boolean} {

    const autoSkipSorter = (segment: ScheduledTime) => {
        const skipOption = getCategorySelection(segment)?.option;
        if (segment.hidden !== SponsorHideType.Visible) {
            // Hidden segments sometimes end up here if another segment is at the same time, use them last
            return 3;
        } else if ((skipOption === CategorySkipOption.AutoSkip || shouldAutoSkip(segment))
                && (segment.actionType === ActionType.Skip || segment.actionType === ActionType.Chapter)) {
            return 0;
        } else if (skipOption !== CategorySkipOption.ShowOverlay) {
            return 1;
        } else {
            return 2;
        }
    }

    const { includedTimes: submittedArray, scheduledTimes: sponsorStartTimes } =
        getStartTimes(sponsorTimes, includeIntersectingSegments, includeNonIntersectingSegments);
    const { scheduledTimes: sponsorStartTimesAfterCurrentTime } = getStartTimes(sponsorTimes, includeIntersectingSegments, includeNonIntersectingSegments, currentTime, true);

    // This is an array in-case multiple segments have the exact same start time
    const minSponsorTimeIndexes = GenericUtils.indexesOf(sponsorStartTimes, Math.min(...sponsorStartTimesAfterCurrentTime));
    // Find auto skipping segments if possible, sort by duration otherwise
    const minSponsorTimeIndex = minSponsorTimeIndexes.sort(
        (a, b) => ((autoSkipSorter(submittedArray[a]) - autoSkipSorter(submittedArray[b]))
        || (submittedArray[a].segment[1] - submittedArray[a].segment[0]) - (submittedArray[b].segment[1] - submittedArray[b].segment[0])))[0] ?? -1;
    // Store extra indexes for the non-auto skipping segments if others occur at the exact same start time
    const extraIndexes = minSponsorTimeIndexes.filter((i) => i !== minSponsorTimeIndex && autoSkipSorter(submittedArray[i]) !== 0);

    const endTimeIndex = getLatestEndTimeIndex(submittedArray, minSponsorTimeIndex);

    const { includedTimes: unsubmittedArray, scheduledTimes: unsubmittedSponsorStartTimes } =
        getStartTimes(sponsorTimesSubmitting, includeIntersectingSegments, includeNonIntersectingSegments);
    const { scheduledTimes: unsubmittedSponsorStartTimesAfterCurrentTime } = getStartTimes(sponsorTimesSubmitting, includeIntersectingSegments, includeNonIntersectingSegments, currentTime, false);

    const minUnsubmittedSponsorTimeIndex = unsubmittedSponsorStartTimes.indexOf(Math.min(...unsubmittedSponsorStartTimesAfterCurrentTime));
    const previewEndTimeIndex = getLatestEndTimeIndex(unsubmittedArray, minUnsubmittedSponsorTimeIndex);

    if ((minUnsubmittedSponsorTimeIndex === -1 && minSponsorTimeIndex !== -1) ||
            sponsorStartTimes[minSponsorTimeIndex] < unsubmittedSponsorStartTimes[minUnsubmittedSponsorTimeIndex]) {
        return {
            array: submittedArray,
            index: minSponsorTimeIndex,
            endIndex: endTimeIndex,
            extraIndexes, // Segments at same time that need separate notices
            openNotice: true
        };
    } else {
        return {
            array: unsubmittedArray,
            index: minUnsubmittedSponsorTimeIndex,
            endIndex: previewEndTimeIndex,
            extraIndexes: [], // No manual things for unsubmitted
            openNotice: false
        };
    }
}

/**
 * This returns index if the skip option is not AutoSkip
 *
 * Finds the last endTime that occurs in a segment that the given
 * segment skips into that is part of an AutoSkip category.
 *
 * Used to find where a segment should truely skip to if there are intersecting submissions due to
 * them having different categories.
 *
 * @param sponsorTimes
 * @param index Index of the given sponsor
 * @param hideHiddenSponsors
 */
function getLatestEndTimeIndex(sponsorTimes: SponsorTime[], index: number, hideHiddenSponsors = true): number {
    // Only combine segments for AutoSkip
    if (index == -1 ||
            !shouldAutoSkip(sponsorTimes[index])
            || sponsorTimes[index].actionType !== ActionType.Skip) {
        return index;
    }

    let latestEndTimeIndex = -1;
    // Default to looped chapter if its end would have been skipped
    if (loopedChapter
        && (loopedChapter.segment[0] > sponsorTimes[index].segment[0]
                && loopedChapter.segment[0] <= sponsorTimes[index]?.segment[1])){
        latestEndTimeIndex = sponsorTimes.length - 1;
    } else {
        // or the normal end time otherwise 
        latestEndTimeIndex = index;
    }

    for (let i = 0; i < sponsorTimes?.length; i++) {
        const currentSegment = sponsorTimes[i].segment;
        const latestEndTime = sponsorTimes[latestEndTimeIndex].segment[1];

        if (currentSegment[0] - skipBuffer <= latestEndTime && currentSegment[1] > latestEndTime
            && (!hideHiddenSponsors || sponsorTimes[i].hidden === SponsorHideType.Visible)
            && shouldAutoSkip(sponsorTimes[i])
            && sponsorTimes[i].actionType === ActionType.Skip) {
                // Overlapping segment
                latestEndTimeIndex = i;
        }
    }

    // Keep going if required
    if (latestEndTimeIndex !== index) {
        latestEndTimeIndex = getLatestEndTimeIndex(sponsorTimes, latestEndTimeIndex, hideHiddenSponsors);
    }

    return latestEndTimeIndex;
}

/**
 * Gets just the start times from a sponsor times array.
 * Optionally specify a minimum
 *
 * @param sponsorTimes
 * @param minimum
 * @param hideHiddenSponsors
 * @param includeIntersectingSegments If true, it will include segments that start before
 *  the current time, but end after
 */
function getStartTimes(sponsorTimes: SponsorTime[], includeIntersectingSegments: boolean, includeNonIntersectingSegments: boolean,
    minimum?: number, hideHiddenSponsors = false): {includedTimes: ScheduledTime[]; scheduledTimes: number[]} {
    if (!sponsorTimes) return {includedTimes: [], scheduledTimes: []};

    const includedTimes: ScheduledTime[] = [];
    const scheduledTimes: number[] = [];

    const shouldIncludeTime = (segment: ScheduledTime ) => (minimum === undefined
        || ((includeNonIntersectingSegments && segment.scheduledTime >= minimum)
            || (includeIntersectingSegments && segment.scheduledTime < minimum
                    && ((segment.segment[1] > minimum && shouldSkip(segment)) // Only include intersecting skippable segments
                        || isLoopedChapter(segment)))))
        && (!hideHiddenSponsors || segment.hidden === SponsorHideType.Visible)
        && segment.segment.length === 2
        && segment.actionType !== ActionType.Poi
        && segment.actionType !== ActionType.Full;

    const possibleTimes = sponsorTimes.map((sponsorTime) => ({
        ...sponsorTime,
        scheduledTime: sponsorTime.segment[0]
    }));

    // Schedule at the end time to know when to unmute and remove title from seek bar
    sponsorTimes.forEach(sponsorTime => {
        if (!possibleTimes.some((time) => sponsorTime.segment[1] === time.scheduledTime && shouldIncludeTime(time))
            && (minimum === undefined || sponsorTime.segment[1] > minimum)) {
            possibleTimes.push({
                ...sponsorTime,
                scheduledTime: sponsorTime.segment[1]
            });
        }
    });

    if (loopedChapter){
        possibleTimes.push({
            ...loopedChapter,
            scheduledTime: loopedChapter.segment[0]})
    }

    for (let i = 0; i < possibleTimes.length; i++) {
        if (shouldIncludeTime(possibleTimes[i])) {
            scheduledTimes.push(possibleTimes[i].scheduledTime);
            includedTimes.push(possibleTimes[i]);
        }
    }

    return { includedTimes, scheduledTimes };
}

/**
 * Skip to exact time in a video and autoskips
 *
 * @param time
 */
function previewTime(time: number, unpause = true) {
    previewedSegment = true;
    setCurrentTime(time);

    // Unpause the video if needed
    if (unpause && getVideo().paused){
        getVideo().play();
    }
}

//send telemetry and count skip
function sendTelemetryAndCount(skippingSegments: SponsorTime[], secondsSkipped: number, fullSkip: boolean) {
    for (const segment of skippingSegments) {
        if (!previewedSegment && sponsorTimesSubmitting.some((s) => s.segment === segment.segment)) {
            // Count that as a previewed segment
            previewedSegment = true;
        }
    }

    if (!Config.config.trackViewCount || (!Config.config.trackViewCountInPrivate && chrome.extension.inIncognitoContext)) return;

    let counted = false;
    for (const segment of skippingSegments) {
        const index = sponsorTimes?.findIndex((s) => s.segment === segment.segment);
        if (index !== -1 && !sponsorSkipped[index]) {
            sponsorSkipped[index] = true;
            if (!counted) {
                Config.config.minutesSaved = Config.config.minutesSaved + secondsSkipped / 60;
                if (segment.actionType !== ActionType.Chapter) {
                    Config.config.skipCount = Config.config.skipCount + 1;
                }
                counted = true;
            }

            if (fullSkip) asyncRequestToServer("POST", "/api/viewedVideoSponsorTime?UUID=" + segment.UUID + "&videoID=" + getVideoID())
                .then(r => {
                    if (!r.ok) logRequest(r, "SB", "segment skip log");
                })
                .catch(e => console.warn("[SB] Caught error while attempting to log segment skip", e));
        }
    }
}

//skip from the start time to the end time for a certain index sponsor time
function skipToTime({v, skipTime, skippingSegments, openNotice, forceAutoSkip, unskipTime}: SkipToTimeParams): void {
    if (Config.config.disableSkipping) return;

    // There will only be one submission if it is manual skip
    const autoSkip: boolean = forceAutoSkip || shouldAutoSkip(skippingSegments[0]);
    const isSubmittingSegment = sponsorTimesSubmitting.some((time) => time.segment === skippingSegments[0].segment);

    if ((autoSkip || isSubmittingSegment)
            && getCurrentTime() !== skipTime[1]) {
        switch(skippingSegments[0].actionType) {
            case ActionType.Poi:
            case ActionType.Chapter:
            case ActionType.Skip: {
                // Fix for looped videos not working when skipping to the end #426
                // for some reason you also can't skip to 1 second before the end
                if (v.loop && getVideoDuration() > 1 && skipTime[1] >= getVideoDuration() - 1) {
                    setCurrentTime(0);
                } else if (getVideoDuration() > 1 && skipTime[1] >= getVideoDuration()
                        && (navigator.vendor === "Apple Computer, Inc." || isPlayingPlaylist())) {
                    // MacOS will loop otherwise #1027
                    // Sometimes playlists loop too #1804
                    setCurrentTime(getVideoDuration() - 0.001);
                } else if (getVideoDuration() > 1 && Math.abs(skipTime[1] - getVideoDuration()) < endTimeSkipBuffer
                    && isFirefoxOrSafari() && !isSafari()) {
                    setCurrentTime(getVideoDuration());
                } else {
                    if (inMuteSegment(skipTime[1], true)) {
                        // Make sure not to mute if skipping into a mute segment
                        v.muted = true;
                        videoMuted = true;
                    }

                    setCurrentTime(skipTime[1]);
                }

                break;
            }
            case ActionType.Mute: {
                if (!v.muted) {
                    v.muted = true;
                    videoMuted = true;
                }
                break;
            }
        }
    }

    if (autoSkip && Config.config.audioNotificationOnSkip
            && !isSubmittingSegment && !getVideo()?.muted) {
        const beep = new Audio(chrome.runtime.getURL("icons/beep.oga"));
        beep.volume = getVideo().volume * 0.1;
        const oldMetadata = navigator.mediaSession.metadata
        beep.play();
        beep.addEventListener("ended", () => {
            navigator.mediaSession.metadata = null;
            setTimeout(() => {
                navigator.mediaSession.metadata = oldMetadata;
                beep.remove();
            });
        })
    }

    if (!autoSkip
            && skippingSegments.length === 1
            && skippingSegments[0].actionType === ActionType.Poi) {
        waitFor(() => skipButtonControlBar).then(() => {
            skipButtonControlBar.enable(skippingSegments[0]);
            if (isOnMobileYouTube() || Config.config.skipKeybind == null) skipButtonControlBar.setShowKeybindHint(false);
    
            activeSkipKeybindElement?.setShowKeybindHint(false);
            activeSkipKeybindElement = skipButtonControlBar;
        })
    } else {
        if (openNotice) {
            //send out the message saying that a sponsor message was skipped
            if (!Config.config.dontShowNotice || !autoSkip) {
                createSkipNotice(skippingSegments, autoSkip, unskipTime, false);
            } else if (autoSkip) {
                activeSkipKeybindElement?.setShowKeybindHint(false);
                activeSkipKeybindElement = {
                    setShowKeybindHint: () => {}, //eslint-disable-line @typescript-eslint/no-empty-function
                    toggleSkip: () => {
                        unskipSponsorTime(skippingSegments[0], unskipTime);

                        createSkipNotice(skippingSegments, autoSkip, unskipTime, true);
                    }
                };
            }
        }
    }

    //send telemetry that a this sponsor was skipped
    if (autoSkip || isSubmittingSegment) sendTelemetryAndCount(skippingSegments, skipTime[1] - skipTime[0], true);
}

function createSkipNotice(skippingSegments: SponsorTime[], autoSkip: boolean, unskipTime: number, startReskip: boolean, voteNotice = false) {
    for (const skipNotice of skipNotices) {
        if (skippingSegments.length === skipNotice.segments.length
                && skippingSegments.every((segment) => skipNotice.segments.some((s) => s.UUID === segment.UUID))) {
            // Skip notice already exists
            return;
        }
    }

    const upcomingNoticeShown = !!upcomingNotice && !upcomingNotice.closed;

    const newSkipNotice = new SkipNotice(skippingSegments, autoSkip, skipNoticeContentContainer, () => {
        upcomingNotice?.close();
        upcomingNotice = null;
    }, unskipTime, startReskip, upcomingNoticeShown, voteNotice);
    if (isOnMobileYouTube() || Config.config.skipKeybind == null) newSkipNotice.setShowKeybindHint(false);
    skipNotices.push(newSkipNotice);

    activeSkipKeybindElement?.setShowKeybindHint(false);
    activeSkipKeybindElement = newSkipNotice;
}

function createUpcomingNotice(skippingSegments: SponsorTime[], timeLeft: number, autoSkip: boolean): void {
    if (upcomingNotice 
            && !upcomingNotice.closed
            && upcomingNotice.sameNotice(skippingSegments)) {
        return;
    }

    upcomingNotice?.close();
    upcomingNotice = new UpcomingNotice(skippingSegments, skipNoticeContentContainer, timeLeft / 1000, autoSkip);
}

function unskipSponsorTime(segment: SponsorTime, unskipTime: number = null, forceSeek = false, voteNotice = false) {
    if (segment.actionType === ActionType.Mute) {
        getVideo().muted = false;
        videoMuted = false;
    }

    if (forceSeek || segment.actionType === ActionType.Skip || segment.actionType === ActionType.Chapter || voteNotice) {
        //add a tiny bit of time to make sure it is not skipped again
        setCurrentTime(unskipTime ?? segment.segment[0] + 0.001);
    }

}

function reskipSponsorTime(segment: SponsorTime, forceSeek = false) {
    if (segment.actionType === ActionType.Mute && !forceSeek) {
        getVideo().muted = true;
        videoMuted = true;
    } else {
        const skippedTime = Math.max(segment.segment[1] - getCurrentTime(), 0);
        const segmentDuration = segment.segment[1] - segment.segment[0];
        const fullSkip = skippedTime / segmentDuration > manualSkipPercentCount;

        setCurrentTime(segment.segment[1]);
        sendTelemetryAndCount([segment], segment.actionType !== ActionType.Chapter ? skippedTime : 0, fullSkip);
        startSponsorSchedule(true, segment.segment[1], false);
    }
}

function createButton(baseID: string, title: string, callback: () => void, imageName: string, isDraggable = false): HTMLElement {
    const existingElement = document.getElementById(baseID + "Button");
    if (existingElement !== null) return existingElement;

    // Button HTML
    const newButton = document.createElement("button");
    newButton.draggable = isDraggable;
    newButton.id = baseID + "Button";
    newButton.classList.add("playerButton");
    newButton.classList.add("ytp-button");
    if (Config.config.prideTheme) newButton.classList.add("prideTheme");
    if (isOnYTTV()) {
        // Some style needs to be set here, but the numbers don't matter 
        newButton.setAttribute("style", "width: 40px; height: 40px");
    }
    newButton.setAttribute("title", chrome.i18n.getMessage(title));
    newButton.addEventListener("click", () => {
        callback();
    });

    // Image HTML
    const newButtonImage = document.createElement("img");
    newButton.draggable = isDraggable;
    newButtonImage.id = baseID + "Image";
    newButtonImage.className = "playerButtonImage";
    newButtonImage.src = chrome.runtime.getURL("icons/" + imageName);

    // Append image to button
    newButton.appendChild(newButtonImage);

    // Add the button to player
    if (controls) controls.prepend(newButton);

    // Store the elements to prevent unnecessary querying
    playerButtons[baseID] = {
        button: newButton,
        image: newButtonImage,
        setupListener: false
    };

    return newButton;
}

function shouldAutoSkip(segment: SponsorTime): boolean {
    const canSkipNonMusic = !Config.config.skipNonMusicOnlyOnYoutubeMusic || isOnYouTubeMusic();
    if (segment.category === "music_offtopic" && !canSkipNonMusic) {
        return false;
    }

    return (!getSkipProfileBool("manualSkipOnFullVideo") || !sponsorTimes?.some((s) => s.category === segment.category && s.actionType === ActionType.Full))
        && (getCategorySelection(segment)?.option === CategorySkipOption.AutoSkip ||
            (getSkipProfileBool("autoSkipOnMusicVideos") && canSkipNonMusic
                && sponsorTimes?.some((s) => s.category === "music_offtopic" && getCategorySelection(segment)?.option === CategorySkipOption.AutoSkip)
                && segment.actionType === ActionType.Skip)
            || sponsorTimesSubmitting.some((s) => s.segment === segment.segment))
        || isLoopedChapter(segment);
}

function shouldSkip(segment: SponsorTime): boolean {
    return segment.hidden === SponsorHideType.Visible && (segment.actionType !== ActionType.Full
            && getCategorySelection(segment)?.option > CategorySkipOption.ShowOverlay)
            || (getSkipProfileBool("autoSkipOnMusicVideos")
                && sponsorTimes?.some((s) => s.category === "music_offtopic" && getCategorySelection(segment)?.option === CategorySkipOption.AutoSkip)
                && segment.actionType === ActionType.Skip)
            || isLoopedChapter(segment);
}

function isLoopedChapter(segment: SponsorTime): boolean{
    return !!segment && !!loopedChapter && segment.segment[1] != undefined
        && segment.segment[0] === loopedChapter.segment[0] && segment.segment[1] === loopedChapter.segment[1];
}

/** Creates any missing buttons on the YouTube player if possible. */
async function createButtons(): Promise<void> {
    controls = await utils.wait(getControls).catch();

    // Add button if does not already exist in html
    createButton("startSegment", "sponsorStart", () => startOrEndTimingNewSegment(), "PlayerStartIconSponsorBlocker.svg");
    createButton("cancelSegment", "sponsorCancel", () => cancelCreatingSegment(), "PlayerCancelSegmentIconSponsorBlocker.svg");
    createButton("delete", "clearTimes", () => clearSponsorTimes(), "PlayerDeleteIconSponsorBlocker.svg");
    createButton("submit", "OpenSubmissionMenu", () => openSubmissionMenu(), "PlayerUploadIconSponsorBlocker.svg");
    createButton("info", "openPopup", () => openInfoMenu(), "PlayerInfoIconSponsorBlocker.svg");

    const controlsContainer = getControls();
    if (Config.config.autoHideInfoButton && !isOnInvidious() && controlsContainer
            && playerButtons["info"]?.button && !controlsWithEventListeners.includes(controlsContainer)) {
        controlsWithEventListeners.push(controlsContainer);

        AnimationUtils.setupAutoHideAnimation(playerButtons["info"].button, controlsContainer);
    }
}

/** Creates any missing buttons on the player and updates their visiblity. */
async function updateVisibilityOfPlayerControlsButton(): Promise<void> {
    // Not on a proper video yet
    if (!getVideoID() || isOnMobileYouTube()) return;

    await createButtons();

    updateEditButtonsOnPlayer();

    // Don't show the info button on embeds
    if (Config.config.hideInfoButtonPlayerControls || document.URL.includes("/embed/") || isOnInvidious() || isOnYTTV()
        || document.getElementById("sponsorBlockPopupContainer") != null) {
        playerButtons.info.button.style.display = "none";
    } else {
        playerButtons.info.button.style.removeProperty("display");
    }
}

/** Updates the visibility of buttons on the player related to creating segments. */
function updateEditButtonsOnPlayer(): void {
    // Don't try to update the buttons if we aren't on a YouTube video page
    if (!getVideoID() || isOnMobileYouTube()) return;

    const buttonsEnabled = !(Config.config.hideVideoPlayerControls || isOnInvidious());

    let creatingSegment = false;
    let submitButtonVisible = false;
    let deleteButtonVisible = false;

    // Only check if buttons should be visible if they're enabled
    if (buttonsEnabled) {
        creatingSegment = isSegmentCreationInProgress();

        // Show only if there are any segments to submit
        submitButtonVisible = sponsorTimesSubmitting.length > 0;

        // Show only if there are any segments to delete
        deleteButtonVisible = sponsorTimesSubmitting.length > 1 || (sponsorTimesSubmitting.length > 0 && !creatingSegment);
    }

    // Update the elements
    playerButtons.startSegment.button.style.display = buttonsEnabled ? "unset" : "none";
    playerButtons.cancelSegment.button.style.display = buttonsEnabled && creatingSegment ? "unset" : "none";

    if (buttonsEnabled) {
        if (creatingSegment) {
            playerButtons.startSegment.image.src = chrome.runtime.getURL("icons/PlayerStopIconSponsorBlocker.svg");
            playerButtons.startSegment.button.setAttribute("title", chrome.i18n.getMessage("sponsorEnd"));
        } else {
            playerButtons.startSegment.image.src = chrome.runtime.getURL("icons/PlayerStartIconSponsorBlocker.svg");
            playerButtons.startSegment.button.setAttribute("title", chrome.i18n.getMessage("sponsorStart"));
        }
    }

    playerButtons.submit.button.style.display = submitButtonVisible && !Config.config.hideUploadButtonPlayerControls ? "unset" : "none";
    playerButtons.delete.button.style.display = deleteButtonVisible && !Config.config.hideDeleteButtonPlayerControls ? "unset" : "none";
}

/**
 * Used for submitting. This will use the HTML displayed number when required as the video's
 * current time is out of date while scrubbing or at the end of the getVideo(). This is not needed
 * for sponsor skipping as the video is not playing during these times.
 */
function getRealCurrentTime(): number {
    // Used to check if replay button
    const playButtonSVGData = document.querySelector(".ytp-play-button")?.querySelector(".ytp-svg-fill")?.getAttribute("d");
    const playButtonSVGDataNew = document.querySelector(".ytp-play-button")?.querySelector("path")?.getAttribute("d");
    const replaceSVGData = "M 18,11 V 7 l -5,5 5,5 v -4 c 3.3,0 6,2.7 6,6 0,3.3 -2.7,6 -6,6 -3.3,0 -6,-2.7 -6,-6 h -2 c 0,4.4 3.6,8 8,8 4.4,0 8,-3.6 8,-8 0,-4.4 -3.6,-8 -8,-8 z";
    const replaceSVGDataNew = "M11.29 2.92C14.85 1.33 18.87 1.06 22";

    if (playButtonSVGData === replaceSVGData || playButtonSVGDataNew.startsWith(replaceSVGDataNew)) {
        // At the end of the video
        return getVideoDuration();
    } else {
        return getCurrentTime();
    }
}

function startOrEndTimingNewSegment() {
    if (isOnYTTV() && getIsLivePremiere()) {
        alert(chrome.i18n.getMessage("yttvLiveContentWarning"));
        return;
    }

    verifyCurrentTime(getRealCurrentTime());
    const roundedTime = Math.round((getRealCurrentTime() + Number.EPSILON) * 1000) / 1000;
    if (!isSegmentCreationInProgress()) {
        sponsorTimesSubmitting.push({
            segment: [roundedTime],
            UUID: generateUserID() as SegmentUUID,
            category: Config.config.defaultCategory,
            actionType: ActionType.Skip,
            source: SponsorSourceType.Local
        });
    } else {
        // Finish creating the new segment
        const existingSegment = getIncompleteSegment();
        const existingTime = existingSegment.segment[0];
        const currentTime = roundedTime;

        // Swap timestamps if the user put the segment end before the start
        existingSegment.segment = [Math.min(existingTime, currentTime), Math.max(existingTime, currentTime)];
    }

    // Save the newly created segment
    Config.local.unsubmittedSegments[getVideoID()] = sponsorTimesSubmitting;
    Config.forceLocalUpdate("unsubmittedSegments");

    // Make sure they know if someone has already submitted something it while they were watching
    sponsorsLookup(true, true);

    updateEditButtonsOnPlayer();
    updateSponsorTimesSubmitting(false);

    importExistingChapters(false);

    if (lastResponseStatus !== 200 && lastResponseStatus !== 404
            && !shownSegmentFailedToFetchWarning && Config.config.showSegmentFailedToFetchWarning) {
        alert(chrome.i18n.getMessage("segmentFetchFailureWarning"));

        shownSegmentFailedToFetchWarning = true;
    }
}

function getIncompleteSegment(): SponsorTime {
    return sponsorTimesSubmitting[sponsorTimesSubmitting.length - 1];
}

/** Is the latest submitting segment incomplete */
function isSegmentCreationInProgress(): boolean {
    const segment = getIncompleteSegment();
    return segment && segment?.segment?.length !== 2;
}

function cancelCreatingSegment() {
    if (isSegmentCreationInProgress()) {
        if (sponsorTimesSubmitting.length > 1) {  // If there's more than one segment: remove last
            sponsorTimesSubmitting.pop();
            Config.local.unsubmittedSegments[getVideoID()] = sponsorTimesSubmitting;
        } else {  // Otherwise delete the video entry & close submission menu
            resetSponsorSubmissionNotice();
            sponsorTimesSubmitting = [];
            delete Config.local.unsubmittedSegments[getVideoID()];
        }
        Config.forceLocalUpdate("unsubmittedSegments");
    }

    updateEditButtonsOnPlayer();
    updateSponsorTimesSubmitting(false);
}

function updateSponsorTimesSubmitting(getFromConfig = true) {
    const segmentTimes = Config.local.unsubmittedSegments[getVideoID()];

    //see if this data should be saved in the sponsorTimesSubmitting variable
    if (getFromConfig && segmentTimes != undefined) {
        sponsorTimesSubmitting = [];

        for (const segmentTime of segmentTimes) {
            sponsorTimesSubmitting.push({
                segment: segmentTime.segment,
                UUID: segmentTime.UUID,
                category: segmentTime.category,
                actionType: segmentTime.actionType,
                description: segmentTime.description,
                hidden: segmentTime.hidden,
                source: segmentTime.source
            });
        }

        if (sponsorTimesSubmitting.length > 0) {
            // Assume they already previewed a segment
            previewedSegment = true;

            importExistingChapters(true);
        }
    }

    updatePreviewBar();

    // Restart skipping schedule
    if (getVideo() !== null) startSponsorSchedule();

    if (submissionNotice !== null) {
        submissionNotice.update();
    }

    checkForPreloadedSegment();
}

function openInfoMenu() {
    if (document.getElementById("sponsorBlockPopupContainer") != null) {
        //it's already added
        return;
    }

    popupInitialised = false;

    //hide info button
    if (playerButtons.info) playerButtons.info.button.style.display = "none";

    const popup = document.createElement("div");
    popup.id = "sponsorBlockPopupContainer";

    const frame = document.createElement("iframe");
    frame.allow = "clipboard-write";
    frame.width = "374";
    frame.height = "500";
    frame.style.borderRadius = "12px";
    frame.addEventListener("load", async () => {
        frame.contentWindow.postMessage("", "*");

        // To support userstyles applying to the popup
        const stylusStyle = document.querySelector(".stylus");
        if (stylusStyle) {
            frame.contentWindow.postMessage({
                type: "style",
                css: stylusStyle.textContent
            }, "*");
        }

        const enhancerStyle = document.getElementById("efyt-theme");
        if (enhancerStyle) {
            const enhancerStyleVariables = document.getElementById("efyt-theme-variables");
            if (enhancerStyleVariables) {
                const enhancerCss = await fetch(enhancerStyle.getAttribute("href")).then((response) => response.text());
                const enhancerVariablesCss = await fetch(enhancerStyleVariables.getAttribute("href")).then((response) => response.text());

                if (enhancerCss && enhancerVariablesCss) {
                    frame.contentWindow.postMessage({
                        type: "style",
                        // Image needs needs to reference the full url now
                        css: enhancerCss.replace("./images/youtube-deep-dark/IconSponsorBlocker256px.png",
                            "https://raw.githubusercontent.com/RaitaroH/YouTube-DeepDark/master/YT_Images/IconSponsorBlocker256px.png")
                            + enhancerVariablesCss
                    }, "*");
                }
            }
        }
    });
    frame.src = chrome.runtime.getURL("popup.html");
    popup.appendChild(frame);

    const elemHasChild = (elements: NodeListOf<HTMLElement>): Element => {
        let parentNode: Element;
        for (const node of elements) {
            if (node.firstElementChild !== null) {
                parentNode = node;
            }
        }
        return parentNode
    }

    const parentNodeOptions = [{
        // YouTube
        selector: "#secondary-inner",
        hasChildCheck: true
    }, {
        // old youtube theme
        selector: "#watch7-sidebar-contents",
    }];
    for (const option of parentNodeOptions) {
        const allElements = document.querySelectorAll(option.selector) as NodeListOf<HTMLElement>;
        const el = option.hasChildCheck ? elemHasChild(allElements) : allElements[0];

        if (el) {
            if (option.hasChildCheck) el.insertBefore(popup, el.firstChild);
            break;
        }
    }
}

function closeInfoMenu() {
    const popup = document.getElementById("sponsorBlockPopupContainer");
    if (popup === null) return;

    popup.remove();

    // Show info button if it's not an embed
    if (!document.URL.includes("/embed/") && playerButtons.info) {
        playerButtons.info.button.style.display = "unset";
    }
}

function clearSponsorTimes() {
    const currentVideoID = getVideoID();

    const sponsorTimes = Config.local.unsubmittedSegments[currentVideoID];

    if (sponsorTimes != undefined && sponsorTimes.length > 0) {
        const confirmMessage = chrome.i18n.getMessage("clearThis") + getSegmentsMessage(sponsorTimes)
                                + "\n" + chrome.i18n.getMessage("confirmMSG")
        if(!confirm(confirmMessage)) return;

        resetSponsorSubmissionNotice();

        //clear the sponsor times
        delete Config.local.unsubmittedSegments[currentVideoID];
        Config.forceLocalUpdate("unsubmittedSegments");

        //clear sponsor times submitting
        sponsorTimesSubmitting = [];

        updatePreviewBar();
        updateEditButtonsOnPlayer();
    }
}

//if skipNotice is null, it will not affect the UI
async function vote(type: number, UUID: SegmentUUID, category?: Category, skipNotice?: SkipNoticeComponent): Promise<VoteResponse> {
    if (skipNotice !== null && skipNotice !== undefined) {
        //add loading info
        skipNotice.addVoteButtonInfo(chrome.i18n.getMessage("Loading"))
        skipNotice.setNoticeInfoMessage();
    }

    const response = await voteAsync(type, UUID, category);
    if (response != undefined) {
        //see if it was a success or failure
        if (skipNotice != null) {
            if ("error" in response) {
                skipNotice.setNoticeInfoMessage(formatJSErrorMessage(response.error))
                skipNotice.resetVoteButtonInfo();
            } else if (response.ok || response.status === 429) {
                //success (treat rate limits as a success)
                skipNotice.afterVote(utils.getSponsorTimeFromUUID(sponsorTimes, UUID), type, category);
            } else {
                logRequest({headers: null, ...response}, "SB", "vote on segment");
                if (response.status === 403 && response.responseText.startsWith("Vote rejected due to a tip from a moderator.")) {
                    openWarningDialog(skipNoticeContentContainer);
                } else {
                    skipNotice.setNoticeInfoMessage(getLongErrorMessage(response.status, response.responseText))
                }

                skipNotice.resetVoteButtonInfo();
            }
        }
    }

    return response;
}

async function voteAsync(type: number, UUID: SegmentUUID, category?: Category): Promise<VoteResponse | undefined> {
    const sponsorIndex = utils.getSponsorIndexFromUUID(sponsorTimes, UUID);

    // Don't vote for preview sponsors
    if (sponsorIndex == -1 || sponsorTimes[sponsorIndex].source !== SponsorSourceType.Server) return Promise.resolve(undefined);

    // See if the local time saved count and skip count should be saved
    if (type === 0 && sponsorSkipped[sponsorIndex] || type === 1 && !sponsorSkipped[sponsorIndex]) {
        let factor = 1;
        if (type == 0) {
            factor = -1;

            sponsorSkipped[sponsorIndex] = false;
        }

        // Count this as a skip
        Config.config.minutesSaved = Config.config.minutesSaved + factor * (sponsorTimes[sponsorIndex].segment[1] - sponsorTimes[sponsorIndex].segment[0]) / 60;

        Config.config.skipCount = Config.config.skipCount + factor;
    }

    return new Promise((resolve) => {
        chrome.runtime.sendMessage({
            message: "submitVote",
            type: type,
            UUID: UUID,
            category: category,
            videoID: getVideoID()
        }, (response) => {
            if (response.ok === true) {
                // Change the sponsor locally
                const segment = utils.getSponsorTimeFromUUID(sponsorTimes, UUID);
                if (segment) {
                    if (type === 0) {
                        segment.hidden = SponsorHideType.Downvoted;
                    } else if (category) {
                        segment.category = category;
                    } else if (type === 1) {
                        segment.hidden = SponsorHideType.Visible;
                    }

                    if (!category && !Config.config.isVip) {
                        utils.addHiddenSegment(getVideoID(), segment.UUID, segment.hidden);
                    }

                    updatePreviewBar();
                }
            }

            resolve(response);
        });
    });
}

//Closes all notices that tell the user that a sponsor was just skipped
function closeAllSkipNotices(){
    const notices = document.getElementsByClassName("sponsorSkipNotice");
    for (let i = 0; i < notices.length; i++) {
        notices[i].remove();
    }
}

function dontShowNoticeAgain() {
    Config.config.dontShowNotice = true;
    closeAllSkipNotices();
}

/**
 * Helper method for the submission notice to clear itself when it closes
 */
function resetSponsorSubmissionNotice(callRef = true) {
    submissionNotice?.close(callRef);
    submissionNotice = null;
}

function closeSubmissionMenu() {
    submissionNotice?.close();
    submissionNotice = null;
}

function openSubmissionMenu() {
    if (submissionNotice !== null){
        closeSubmissionMenu();
        return;
    }

    if (sponsorTimesSubmitting !== undefined && sponsorTimesSubmitting.length > 0) {
        submissionNotice = new SubmissionNotice(skipNoticeContentContainer, sendSubmitMessage);
    }
}

function previewRecentSegment() {
    if (sponsorTimesSubmitting !== undefined && sponsorTimesSubmitting.length > 0) {
        previewTime(sponsorTimesSubmitting[sponsorTimesSubmitting.length - 1].segment[0] - defaultPreviewTime);
        
        if (submissionNotice) {
            submissionNotice.scrollToBottom();
        }
    }
}

function submitSegments() {
    if (sponsorTimesSubmitting !== undefined
            && sponsorTimesSubmitting.length > 0
            && submissionNotice !== null) {
        submissionNotice.submit();
    }

}

//send the message to the background js
//called after all the checks have been made that it's okay to do so
async function sendSubmitMessage(): Promise<boolean> {
    // check if all segments are full video
    const onlyFullVideo = sponsorTimesSubmitting.every((segment) => segment.actionType === ActionType.Full);
    // Block if submitting on a running livestream or premiere
    if (!onlyFullVideo && (getIsLivePremiere() || isVisible(document.querySelector(".ytp-live-badge")))) {
        alert(chrome.i18n.getMessage("liveOrPremiere"));
        return false;
    }

    if (!previewedSegment 
            && !sponsorTimesSubmitting.every((segment) => 
                [ActionType.Full, ActionType.Chapter, ActionType.Poi].includes(segment.actionType) 
                    || segment.segment[1] >= getVideoDuration()
                    || segment.segment[0] === 0)) {
        alert(`${chrome.i18n.getMessage("previewSegmentRequired")} ${keybindToString(Config.config.previewKeybind)}`);
        return false;
    }

    // Add loading animation
    playerButtons.submit.image.src = chrome.runtime.getURL("icons/PlayerUploadIconSponsorBlocker.svg");
    const stopAnimation = AnimationUtils.applyLoadingAnimation(playerButtons.submit.button, 1, () => updateEditButtonsOnPlayer());

    //check if a sponsor exceeds the duration of the video
    for (let i = 0; i < sponsorTimesSubmitting.length; i++) {
        if (sponsorTimesSubmitting[i].segment[1] > getVideoDuration()) {
            sponsorTimesSubmitting[i].segment[1] = getVideoDuration();
        }
    }

    //update sponsorTimes
    Config.local.unsubmittedSegments[getVideoID()] = sponsorTimesSubmitting;
    Config.forceLocalUpdate("unsubmittedSegments");

    // Check to see if any of the submissions are below the minimum duration set
    if (Config.config.minDuration > 0) {
        for (let i = 0; i < sponsorTimesSubmitting.length; i++) {
            const duration = sponsorTimesSubmitting[i].segment[1] - sponsorTimesSubmitting[i].segment[0];
            if (duration > 0 && duration < Config.config.minDuration) {
                const confirmShort = chrome.i18n.getMessage("shortCheck") + "\n\n" +
                    getSegmentsMessage(sponsorTimesSubmitting);

                if(!confirm(confirmShort)) return false;
            }
        }
    }

    let response: FetchResponse;
    try {
        response = await asyncRequestToServer("POST", "/api/skipSegments", {
            videoID: getVideoID(),
            userID: Config.config.userID,
            segments: sponsorTimesSubmitting,
            videoDuration: getVideoDuration(),
            userAgent: extensionUserAgent(),
        });
    } catch (e) {
        console.error("[SB] Caught error while attempting to submit segments", e);
        // Show that the upload failed
        playerButtons.submit.button.style.animation = "unset";
        playerButtons.submit.image.src = chrome.runtime.getURL("icons/PlayerUploadFailedIconSponsorBlocker.svg");
        alert(formatJSErrorMessage(e));
        return false;
    }

    if (response.status === 200) {
        stopAnimation();

        // Remove segments from storage since they've already been submitted
        delete Config.local.unsubmittedSegments[getVideoID()];
        Config.forceLocalUpdate("unsubmittedSegments");

        const newSegments = sponsorTimesSubmitting;
        try {
            const receivedNewSegments = JSON.parse(response.responseText);
            if (receivedNewSegments?.length === newSegments.length) {
                for (let i = 0; i < receivedNewSegments.length; i++) {
                    newSegments[i].UUID = receivedNewSegments[i].UUID;
                    newSegments[i].source = SponsorSourceType.Server;
                }
            }
        } catch(e) {} // eslint-disable-line no-empty

        // Add submissions to current sponsors list
        sponsorTimes = (sponsorTimes || []).concat(newSegments).sort((a, b) => a.segment[0] - b.segment[0]);

        // Increase contribution count
        Config.config.sponsorTimesContributed = Config.config.sponsorTimesContributed + sponsorTimesSubmitting.length;

        // New count just used to see if a warning "Read The Guidelines!!" message needs to be shown
        // One per time submitting
        Config.config.submissionCountSinceCategories = Config.config.submissionCountSinceCategories + 1;

        // Empty the submitting times
        sponsorTimesSubmitting = [];

        updatePreviewBar();
        updateCategoryPill();

        return true;
    } else {
        // Show that the upload failed
        playerButtons.submit.button.style.animation = "unset";
        playerButtons.submit.image.src = chrome.runtime.getURL("icons/PlayerUploadFailedIconSponsorBlocker.svg");

        if (response.status === 403 && response.responseText.startsWith("Submission rejected due to a tip from a moderator.")) {
            openWarningDialog(skipNoticeContentContainer);
        } else {
            logRequest(response, "SB", "segment submission");
            alert(getLongErrorMessage(response.status, response.responseText));
        }
    }

    return false;
}

//get the message that visually displays the video times
function getSegmentsMessage(sponsorTimes: SponsorTime[]): string {
    let sponsorTimesMessage = "";

    for (let i = 0; i < sponsorTimes.length; i++) {
        for (let s = 0; s < sponsorTimes[i].segment.length; s++) {
            let timeMessage = getFormattedTime(sponsorTimes[i].segment[s]);
            //if this is an end time
            if (s == 1) {
                timeMessage = " " + chrome.i18n.getMessage("to") + " " + timeMessage;
            } else if (i > 0) {
                //add commas if necessary
                timeMessage = ", " + timeMessage;
            }

            sponsorTimesMessage += timeMessage;
        }
    }

    return sponsorTimesMessage;
}

function updateActiveSegment(currentTime: number): void {
    if (!chrome.runtime?.id) return;

    previewBar?.updateChapterText(sponsorTimes, sponsorTimesSubmitting, currentTime);

    chrome.runtime.sendMessage({
        message: "time",
        time: currentTime
    });
}

function nextChapter(): void {
    const chapters = previewBar.unfilteredChapterGroups?.filter((time) => [ActionType.Chapter, null].includes(time.actionType));
    if (!chapters || chapters.length <= 0) return;

    lastNextChapterKeybind.time = getCurrentTime();
    lastNextChapterKeybind.date = Date.now();

    const nextChapter = chapters.findIndex((time) => time.segment[0] > getCurrentTime());
    if (nextChapter !== -1) {
        setCurrentTime(chapters[nextChapter].segment[0]);
    } else {
        setCurrentTime(getVideoDuration());
    }
}

function previousChapter(): void {
    if (Date.now() - lastNextChapterKeybind.date < 3000) {
        setCurrentTime(lastNextChapterKeybind.time);
        lastNextChapterKeybind.date = 0;
        return;
    }

    const chapters = previewBar.unfilteredChapterGroups?.filter((time) => [ActionType.Chapter, null].includes(time.actionType));
    if (!chapters || chapters.length <= 0) {
        setCurrentTime(0);
        return;
    }

    // subtract 5 seconds to allow skipping back to the previous chapter if close to start of
    // the current one
    const nextChapter = chapters.findIndex((time) => time.segment[0] > getCurrentTime() - Math.min(5, time.segment[1] - time.segment[0]));
    const previousChapter = nextChapter !== -1 ? (nextChapter - 1) : (chapters.length - 1);
    if (previousChapter !== -1) {
        setCurrentTime(chapters[previousChapter].segment[0]);
    } else {
        setCurrentTime(0);
    }
}

async function handleKeybindVote(type: number): Promise<void>{
    let lastSkipNotice = skipNotices[0]?.skipNoticeRef.current;
    lastSkipNotice?.onMouseEnter();

    if (!lastSkipNotice) {
        const lastSegment = [...sponsorTimes].reverse()?.find((s) => s.source == SponsorSourceType.Server && (s.segment[0] <= getCurrentTime() && getCurrentTime() - (s.segment[1] || s.segment[0]) <= Config.config.skipNoticeDuration));
        if (!lastSegment) return;

        createSkipNotice([lastSegment], shouldAutoSkip(lastSegment), lastSegment?.segment[0] + 0.001,false, true);
        lastSkipNotice = await skipNotices[0].waitForSkipNoticeRef();
        lastSkipNotice?.reskippedMode(0);
    }

    vote(type,lastSkipNotice?.segments[0]?.UUID, undefined, lastSkipNotice);
    return;
}

function addHotkeyListener(): void {
    document.addEventListener("keydown", hotkeyListener, true);
    document.addEventListener("keyup", hotkeyPropagationListener, true);

    addCleanupListener(() => {
        document.body.removeEventListener("keydown", hotkeyListener, true);
        document.body.removeEventListener("keyup", hotkeyPropagationListener, true);
    });
}

function hotkeyListener(e: KeyboardEvent): void {
    if ((["textarea", "input"].includes(document.activeElement?.tagName?.toLowerCase())
        || (document.activeElement as HTMLElement)?.isContentEditable
        || document.activeElement?.id?.toLowerCase()?.match(/editable|input/))
            && document.hasFocus()) return;

    const key: Keybind = {
        key: e.key,
        code: e.code,
        alt: e.altKey,
        ctrl: e.ctrlKey,
        shift: e.shiftKey
    };

    const skipKey = Config.config.skipKeybind;
    const skipToHighlightKey = Config.config.skipToHighlightKeybind;
    const closeSkipNoticeKey = Config.config.closeSkipNoticeKeybind;
    const startSponsorKey = Config.config.startSponsorKeybind;
    const submitKey = Config.config.actuallySubmitKeybind;
    const previewKey = Config.config.previewKeybind;
    const openSubmissionMenuKey = Config.config.submitKeybind;
    const nextChapterKey = Config.config.nextChapterKeybind;
    const previousChapterKey = Config.config.previousChapterKeybind;
    const upvoteKey = Config.config.upvoteKeybind;
    const downvoteKey = Config.config.downvoteKeybind;

    if (keybindEquals(key, skipKey)) {
        if (activeSkipKeybindElement && !(activeSkipKeybindElement instanceof SkipButtonControlBar)) {
            activeSkipKeybindElement.toggleSkip.call(activeSkipKeybindElement);
        }

        return;
    } else if (keybindEquals(key, skipToHighlightKey)) {
        if (skipButtonControlBar) {
            skipButtonControlBar.toggleSkip.call(skipButtonControlBar);
        }

        return;
    } else if (keybindEquals(key, closeSkipNoticeKey)) {
        for (let i = 0; i < skipNotices.length; i++) {
            skipNotices.pop().close();
        }
        
        upcomingNotice?.close();
        upcomingNotice = null;
        return;
    } else if (keybindEquals(key, startSponsorKey)) {
        startOrEndTimingNewSegment();
        return;
    } else if (keybindEquals(key, submitKey)) {
        submitSegments();
        return;
    } else if (keybindEquals(key, openSubmissionMenuKey)) {
        e.preventDefault();

        openSubmissionMenu();
        return;
    } else if (keybindEquals(key, previewKey)) {
        previewRecentSegment();
        return;
    } else if (keybindEquals(key, nextChapterKey)) {
        if (sponsorTimes.length > 0) e.stopPropagation();
        nextChapter();
        return;
    } else if (keybindEquals(key, previousChapterKey)) {
        if (sponsorTimes.length > 0) e.stopPropagation();
        previousChapter();
        return;
    } else if (keybindEquals(key, upvoteKey)) {
        handleKeybindVote(1);
        return;
    } else if (keybindEquals(key, downvoteKey)) {
        handleKeybindVote(0);
        return;
    }
}

function hotkeyPropagationListener(e: KeyboardEvent): void {
    if ((["textarea", "input"].includes(document.activeElement?.tagName?.toLowerCase())
        || (document.activeElement as HTMLElement)?.isContentEditable
        || document.activeElement?.id?.toLowerCase()?.match(/editable|input/))
            && document.hasFocus()) return;

    const key: Keybind = {
        key: e.key,
        code: e.code,
        alt: e.altKey,
        ctrl: e.ctrlKey,
        shift: e.shiftKey
    };

    const nextChapterKey = Config.config.nextChapterKeybind;
    const previousChapterKey = Config.config.previousChapterKeybind;

    if (keybindEquals(key, nextChapterKey)) {
        if (sponsorTimes.length > 0) e.stopPropagation();
        return;
    } else if (keybindEquals(key, previousChapterKey)) {
        if (sponsorTimes.length > 0) e.stopPropagation();
        return;
    }
}

/**
 * Adds the CSS to the page if needed. Required on optional sites with Chrome.
 */
function addCSS() {
    if (!isFirefoxOrSafari() && Config.config.invidiousInstances.includes(new URL(document.URL).hostname)) {
        const onLoad = () => {
            const head = document.getElementsByTagName("head")[0];

            for (const file of utils.css) {
                const fileref = document.createElement("link");

                fileref.rel = "stylesheet";
                fileref.type = "text/css";
                fileref.href = chrome.runtime.getURL(file);

                head.appendChild(fileref);
            }
        };

        if (document.readyState === "complete") {
            onLoad();
        } else {
            document.addEventListener("DOMContentLoaded", onLoad);
        }
    }
}

/**
 * Update the isAdPlaying flag and hide preview bar/controls if ad is playing
 */
function updateAdFlag(): void {
    const wasAdPlaying = getIsAdPlaying();
    setIsAdPlaying(document.getElementsByClassName('ad-showing').length > 0);
    if(wasAdPlaying != getIsAdPlaying()) {
        updatePreviewBar();
        updateVisibilityOfPlayerControlsButton();
    }
}

function showTimeWithoutSkips(skippedDuration: number): void {
    if (isNaN(skippedDuration) || skippedDuration < 0) {
        skippedDuration = 0;
    }

    // YouTube player time display
    const selector =
        isOnInvidious()     ? ".vjs-duration" :
        isOnYTTV()          ? ".ypl-full-controls .ypmcs-control .time-info-bar" :
        isOnMobileYouTube() ? ".ytwPlayerTimeDisplayContent" :
                              ".ytp-time-display.notranslate .ytp-time-wrapper .ytp-time-contents";
    const display = document.querySelector(selector);
    if (!display) return;

    const durationID = "sponsorBlockDurationAfterSkips";
    let duration = document.getElementById(durationID);

    // Create span if needed
    if (duration === null) {
        duration = document.createElement('span');
        duration.id = durationID;

        if (isOnMobileYouTube()) {
            duration.style.paddingLeft = "4px";
            display.insertBefore(duration, display.lastChild);
        } else {
            display.appendChild(duration);
        }
    }

    const durationAfterSkips = getFormattedTime(getVideoDuration() - skippedDuration);

    duration.innerText = (durationAfterSkips == null || skippedDuration <= 0) ? "" : " (" + durationAfterSkips + ")";
}

function checkForPreloadedSegment() {
    if (loadedPreloadedSegment) return;

    loadedPreloadedSegment = true;
    const hashParams = getHashParams();

    let pushed = false;
    const segments = hashParams.segments;
    if (Array.isArray(segments)) {
        for (const segment of segments) {
            if (Array.isArray(segment.segment)) {
                if (!sponsorTimesSubmitting.some((s) => s.segment[0] === segment.segment[0] && s.segment[1] === s.segment[1])) {
                    sponsorTimesSubmitting.push({
                        segment: segment.segment,
                        UUID: generateUserID() as SegmentUUID,
                        category: segment.category ? segment.category : Config.config.defaultCategory,
                        actionType: segment.actionType ? segment.actionType : ActionType.Skip,
                        description: segment.description ?? "",
                        source: SponsorSourceType.Local
                    });

                    pushed = true;
                }
            }
        }
    }

    if (pushed) {
        Config.local.unsubmittedSegments[getVideoID()] = sponsorTimesSubmitting;
        Config.forceLocalUpdate("unsubmittedSegments");
    }
}

// Generate and inject a stylesheet that creates CSS variables with configured category colors
function setCategoryColorCSSVariables() {
    let styleContainer = document.getElementById("sbCategoryColorStyle");
    if (!styleContainer) {
        styleContainer = document.createElement("style");
        styleContainer.id = "sbCategoryColorStyle";
        if (isVorapisInstalled()) {
            // Vorapi deletes styles
            styleContainer.className = 'stylus';
        }

        const head = (document.head || document.documentElement);
        head.appendChild(styleContainer)
    }

    let css = ":root {"
    for (const [category, config] of Object.entries(Config.config.barTypes)) {
        css += `--sb-category-${category}: ${config.color};`;
        css += `--darkreader-bg--sb-category-${category}: ${config.color};`;

        const luminance = GenericUtils.getLuminance(config.color);
        css += `--sb-category-text-${category}: ${luminance > 128 ? "black" : "white"};`;
        css += `--darkreader-text--sb-category-text-${category}: ${luminance > 128 ? "black" : "white"};`;
    }
    css += "}";

    styleContainer.innerText = css;
}

/**
 * If mini player starts playing, then videoID change might have to be called
 */
function checkForMiniplayerPlaying() {
    const miniPlayerUI = document.querySelector(".miniplayer") as HTMLElement;
    if (!onVideoPage() && isVisible(miniPlayerUI)) {
        const videoID = getLastNonInlineVideoID();
        if (videoID) {
            triggerVideoIDChange(videoID);

            // treat as if video element has changed
            const video = miniPlayerUI.querySelector("video") as HTMLVideoElement;
            if (video && getVideo() !== video) {
                triggerVideoElementChange(video);
            }
        }
    }
}
