import Config from "./config"; import { ActionType, Category, CategorySkipOption, ChannelIDInfo, 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 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, 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 { isFirefoxOrSafari, waitFor } from "../maze-utils/src"; import { getErrorMessage, getFormattedTime } from "../maze-utils/src/formating"; import { getChannelIDInfo, getVideo, getIsAdPlaying, getIsLivePremiere, setIsAdPlaying, checkVideoIDChange, getVideoID, getYouTubeVideoID, setupVideoModule, checkIfNewVideoID, isOnInvidious, isOnMobileYouTube, getLastNonInlineVideoID, triggerVideoIDChange, triggerVideoElementChange, getIsInline, getCurrentTime, setCurrentTime, getVideoDuration, verifyCurrentTime } 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 { 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"; 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; // List of open skip notices const skipNotices: SkipNotice[] = []; let activeSkipKeybindElement: ToggleSkippable = null; let retryFetchTimeout: NodeJS.Timeout = 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; /** 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; //is this channel whitelised from getting sponsors skipped let channelWhitelisted = false; 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 = {}; 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; let retryCount = 0; // 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, time: getCurrentTime() ?? 0, onMobileYouTube: isOnMobileYouTube() }); if (!request.updating && popupInitialised && document.getElementById("sponsorBlockPopupContainer") != null) { //the popup should be closed now that another is opening closeInfoMenu(); } popupInitialised = true; break; case "getVideoID": sendResponse({ videoID: getVideoID(), }); break; case "getChannelID": sendResponse({ channelID: getChannelIDInfo().id }); break; case "isChannelWhitelisted": sendResponse({ value: channelWhitelisted }); break; case "whitelistChange": channelWhitelisted = request.value; sponsorsLookup(); 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 "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)) { const hasChaptersPermission = (Config.config.showCategoryWithoutPermission || Config.config.permissions["chapter"]); if (segment.category === "chapter" && (!utils.getCategorySelection("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; } 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": sponsorsLookup(true, true); break; case "barTypes": setCategoryColorCSSVariables(); break; case "fullVideoSegments": case "fullVideoLabelsOnThumbnails": updateAll(); break; } } } if (!Config.configSyncListeners.includes(contentConfigUpdateListener)) { Config.configSyncListeners.push(contentConfigUpdateListener); } function resetValues() { lastCheckTime = 0; lastCheckVideoTime = -1; retryCount = 0; previewedSegment = false; firstPlay = true; sponsorTimes = []; existingChaptersImported = false; sponsorSkipped = []; lastResponseStatus = 0; shownSegmentFailedToFetchWarning = false; videoInfo = null; channelWhitelisted = false; 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(); } 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(), whitelisted: channelWhitelisted }); 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: ".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 (const option of progressElementOptions) { const allElements = document.querySelectorAll(option.selector) as NodeListOf; 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(), 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; } } /** * @param currentTime Optional if you don't want to use the actual current time */ async function startSponsorSchedule(includeIntersectingSegments = false, currentTime?: number, includeNonIntersectingSegments = true): Promise { 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 || channelWhitelisted || (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 artifical 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))) { if (forceVideoTime >= skipTime[0] - skipBuffer && forceVideoTime < skipTime[1]) { skipToTime({ v: getVideo(), skipTime, skippingSegments, openNotice: skipInfo.openNotice }); // These are segments that start at the exact same time but need seperate 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 (utils.getCategorySelection(currentSkip.category)?.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) { 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)) { 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, getCurrentTime()); 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 || getCurrentTime() >= 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(getCurrentTime(), 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 currentSkipSchedule = setTimeout(skippingFunction, Math.max(0, delayTime - offset)); } } } /** * Used on Firefox only, waits for the next animation frame until * the video time has changed */ function waitForNextTimeChange(): Promise { 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 / 1000 + lastKnownVideoTime.videoTime : null); if (Config.config.useVirtualTime && !isSafari() && virtualTime && Math.abs(virtualTime - getCurrentTime()) < 0.2 && 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]))) { // 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() { const video = getVideo(); 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() }); } 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 = true; // 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; // Hide all submissions smaller than the minimum duration if (Config.config.minDuration !== 0) { for (const segment of sponsorTimes) { const duration = segment.segment[1] - segment.segment[0]; if (duration > 0 && duration < Config.config.minDuration) { segment.hidden = SponsorHideType.MinimumDuration; } } } 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; } } } if (!getVideo()) { //there is still no video here await waitFor(() => getVideo(), 5000, 10); } startSkipScheduleCheckingForStartSponsors(); if (!isNaN(getVideoDuration())) { updatePreviewBar(); } } else { retryFetch(404); } } else { retryFetch(lastResponseStatus); } importExistingChapters(true); // notify popup of segment changes chrome.runtime.sendMessage({ message: "infoUpdated", found: sponsorDataFound, status: lastResponseStatus, sponsorTimes: sponsorTimes, time: getCurrentTime() ?? 0, onMobileYouTube: isOnMobileYouTube() }); if (Config.config.isVip) { lockedCategoriesLookup(); } } 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; }); // eslint-disable-line @typescript-eslint/no-empty-function } } } async function lockedCategoriesLookup(): Promise { const hashPrefix = (await getHash(getVideoID(), 1)).slice(0, 4); const response = await asyncRequestToServer("GET", "/api/lockCategories/" + hashPrefix); if (response.ok) { try { const categoriesResponse = JSON.parse(response.responseText).filter((lockInfo) => lockInfo.videoID === getVideoID())[0]?.categories; if (Array.isArray(categoriesResponse)) { lockedCategories = categoriesResponse; } } catch (e) { } //eslint-disable-line no-empty } } function retryFetch(errorCode: number): void { sponsorDataFound = false; if (!Config.config.refetchWhenNotFound) return; if (retryFetchTimeout) clearTimeout(retryFetchTimeout); if ((errorCode !== 404 && retryCount > 1) || (errorCode !== 404 && retryCount > 10)) { // Too many errors (50x), give up return; } retryCount++; const delay = errorCode === 404 ? (30000 + Math.random() * 30000) : (2000 + Math.random() * 10000); retryFetchTimeout = setTimeout(() => { if (getVideoID() && sponsorTimes?.length === 0 || sponsorTimes.every((segment) => segment.source !== SponsorSourceType.Server)) { // sponsorsLookup(); } }, delay); } /** * 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) .sort((a, b) => b.segment[0] - a.segment[0]); for (const time of poiSegments) { const skipOption = utils.getCategorySelection(time.category)?.option; if (skipOption !== CategorySkipOption.ShowOverlay) { skipToTime({ v: getVideo(), skipTime: time.segment, skippingSegments: [time], openNotice: true, unskipTime: getCurrentTime() }); if (skipOption === CategorySkipOption.AutoSkip) break; } } const fullVideoSegment = sponsorTimes.filter((time) => time.actionType === ActionType.Full)[0]; if (fullVideoSegment) { categoryPill?.setSegment(fullVideoSegment); } 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) 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)), selectedSegment: selectedSegment && segment.UUID === selectedSegment }); }); } sponsorTimesSubmitting.forEach((segment) => { 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); } } //checks if this channel is whitelisted, should be done only after the channelID has been loaded async function channelIDChange(channelIDInfo: ChannelIDInfo) { const whitelistedChannels = Config.config.whitelistedChannels; //see if this is a whitelisted channel if (whitelistedChannels != undefined && channelIDInfo.status === ChannelIDStatus.Found && whitelistedChannels.includes(channelIDInfo.id)) { channelWhitelisted = true; } // check if the start of segments were missed if (Config.config.forceChannelCheck && sponsorTimes?.length > 0) startSkipScheduleCheckingForStartSponsors(); } function videoElementChange(newVideo: boolean): void { waitFor(() => Config.isReady()).then(() => { if (newVideo) { setupVideoListeners(); 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 = utils.getCategorySelection(segment.category)?.option; if ((skipOption === CategorySkipOption.AutoSkip || shouldAutoSkip(segment)) && segment.actionType === ActionType.Skip) { 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 seperate 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; } // Default to the normal endTime let 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 && (!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] }); } }); 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); } } } //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.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.ogg")); 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) { 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) { 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 newSkipNotice = new SkipNotice(skippingSegments, autoSkip, skipNoticeContentContainer, unskipTime, startReskip); if (isOnMobileYouTube() || Config.config.skipKeybind == null) newSkipNotice.setShowKeybindHint(false); skipNotices.push(newSkipNotice); activeSkipKeybindElement?.setShowKeybindHint(false); activeSkipKeybindElement = newSkipNotice; } function unskipSponsorTime(segment: SponsorTime, unskipTime: number = null, forceSeek = false) { if (segment.actionType === ActionType.Mute) { getVideo().muted = false; videoMuted = false; } if (forceSeek || segment.actionType === ActionType.Skip) { //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"); 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 { return (!Config.config.manualSkipOnFullVideo || !sponsorTimes?.some((s) => s.category === segment.category && s.actionType === ActionType.Full)) && (utils.getCategorySelection(segment.category)?.option === CategorySkipOption.AutoSkip || (Config.config.autoSkipOnMusicVideos && sponsorTimes?.some((s) => s.category === "music_offtopic") && segment.actionType === ActionType.Skip) || sponsorTimesSubmitting.some((s) => s.segment === segment.segment)); } function shouldSkip(segment: SponsorTime): boolean { return (segment.actionType !== ActionType.Full && segment.source !== SponsorSourceType.YouTube && utils.getCategorySelection(segment.category)?.option !== CategorySkipOption.ShowOverlay) || (Config.config.autoSkipOnMusicVideos && sponsorTimes?.some((s) => s.category === "music_offtopic") && segment.actionType === ActionType.Skip); } /** Creates any missing buttons on the YouTube player if possible. */ async function createButtons(): Promise { 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 { // 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() || 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 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"; if (playButtonSVGData === replaceSVGData) { // At the end of the video return getVideoDuration(); } else { return getCurrentTime(); } } function startOrEndTimingNewSegment() { verifyCurrentTime(); 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, 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.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): 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; 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 { if (skipNotice !== null && skipNotice !== undefined) { //add loading info skipNotice.addVoteButtonInfo.bind(skipNotice)(chrome.i18n.getMessage("Loading")) skipNotice.setNoticeInfoMessage.bind(skipNotice)(); } const response = await voteAsync(type, UUID, category); if (response != undefined) { //see if it was a success or failure if (skipNotice != null) { if (response.successType == 1 || (response.successType == -1 && response.statusCode == 429)) { //success (treat rate limits as a success) skipNotice.afterVote.bind(skipNotice)(utils.getSponsorTimeFromUUID(sponsorTimes, UUID), type, category); } else if (response.successType == -1) { if (response.statusCode === 403 && response.responseText.startsWith("Vote rejected due to a tip from a moderator.")) { openWarningDialog(skipNoticeContentContainer); } else { skipNotice.setNoticeInfoMessage.bind(skipNotice)(getErrorMessage(response.statusCode, response.responseText)) } skipNotice.resetVoteButtonInfo.bind(skipNotice)(); } } } return response; } async function voteAsync(type: number, UUID: SegmentUUID, category?: Category): Promise { 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 }, (response) => { if (response.successType === 1) { // 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 { // 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; } } } const response = await asyncRequestToServer("POST", "/api/skipSegments", { videoID: getVideoID(), userID: Config.config.userID, segments: sponsorTimesSubmitting, videoDuration: getVideoDuration(), userAgent: `${chrome.runtime.id}/v${chrome.runtime.getManifest().version}` }); 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(); const fullVideoSegment = sponsorTimes.filter((time) => time.actionType === ActionType.Full)[0]; if (fullVideoSegment) { categoryPill?.setSegment(fullVideoSegment); } 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 { alert(getErrorMessage(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 { 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); } } function addHotkeyListener(): void { document.addEventListener("keydown", hotkeyListener); const onLoad = () => { // Allow us to stop propagation to YouTube by being deeper document.removeEventListener("keydown", hotkeyListener); document.body.addEventListener("keydown", hotkeyListener); addCleanupListener(() => { document.body.removeEventListener("keydown", hotkeyListener); }); }; if (document.readyState === "complete") { onLoad(); } else { document.addEventListener("DOMContentLoaded", onLoad); } } 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; if (keybindEquals(key, skipKey)) { if (activeSkipKeybindElement) { 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(); } 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; } } /** * 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" : isOnMobileYouTube() ? ".YtwPlayerTimeDisplayContent" : ".ytp-time-display.notranslate .ytp-time-wrapper"; 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"; 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); } } } }