diff --git a/manifest/chrome-manifest-extra.json b/manifest/chrome-manifest-extra.json index ffe96d15ae..7e03158988 100644 --- a/manifest/chrome-manifest-extra.json +++ b/manifest/chrome-manifest-extra.json @@ -1,7 +1,10 @@ { "host_permissions": [ "https://*.youtube.com/*", - "https://sponsor.ajay.app/*" + "https://sponsor.ajay.app/*", + "https://rutube.ru/*", + "https://*.rutube.ru/*", + "https://sponsorblock.futuba.ru/*" ], "optional_host_permissions": [ "*://*/*" @@ -96,7 +99,9 @@ ], "matches": [ "https://*.youtube.com/*", - "https://www.youtube-nocookie.com/embed/*" + "https://www.youtube-nocookie.com/embed/*", + "https://rutube.ru/*", + "https://*.rutube.ru/*" ], "exclude_matches": [ "https://accounts.youtube.com/RotateCookiesPage*" diff --git a/manifest/manifest-v2-extra.json b/manifest/manifest-v2-extra.json index f149e1c30b..52f14dd82e 100644 --- a/manifest/manifest-v2-extra.json +++ b/manifest/manifest-v2-extra.json @@ -59,7 +59,10 @@ "libs/6xKydSBYKcSV-LCoeQqfX1RYOo3ig4vwlBduz8A.woff2" ], "permissions": [ - "https://sponsor.ajay.app/*" + "https://sponsor.ajay.app/*", + "https://rutube.ru/*", + "https://*.rutube.ru/*", + "https://sponsorblock.futuba.ru/*" ], "optional_permissions": [ "*://*/*" @@ -120,7 +123,9 @@ "run_at": "document_start", "matches": [ "https://*.youtube.com/*", - "https://www.youtube-nocookie.com/embed/*" + "https://www.youtube-nocookie.com/embed/*", + "https://rutube.ru/*", + "https://*.rutube.ru/*" ], "exclude_matches": [ "https://accounts.youtube.com/RotateCookiesPage*" diff --git a/src/content.ts b/src/content.ts index d02beae292..4fb3879f4b 100644 --- a/src/content.ts +++ b/src/content.ts @@ -54,6 +54,7 @@ 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"; +import { getActiveVideoService } from "./videoServices"; cleanPage(); @@ -118,6 +119,9 @@ let sponsorSkipped: boolean[] = []; let videoMuted = false; // Has it been attempted to be muted const controlsWithEventListeners: HTMLElement[] = []; +const activeVideoService = getActiveVideoService(); +let currentVideoServiceVideoID: VideoID | null = null; +let currentVideoServiceVideoElement: HTMLVideoElement | null = null; setupVideoModule({ videoIDChange, @@ -132,9 +136,55 @@ setupVideoModule({ updateVisibilityOfPlayerControlsButton(); }, resetValues, - documentScript: chrome.runtime.getManifest().manifest_version === 2 ? documentScript : undefined + documentScript: chrome.runtime.getManifest().manifest_version === 2 + && (activeVideoService?.capabilities.documentScript ?? true) ? documentScript : undefined }, () => Config); setupThumbnailListener(); +setupVideoServiceTracking(); + +function getCurrentVideoID(): VideoID | null { + return activeVideoService ? currentVideoServiceVideoID ?? activeVideoService.getVideoID() : getVideoID(); +} + +function getCurrentPageVideoID(): VideoID | null { + return activeVideoService ? activeVideoService.getVideoID() : getYouTubeVideoID(); +} + +function checkCurrentVideoIDChange(): void { + if (!activeVideoService) { + void checkVideoIDChange(); + return; + } + + checkVideoServiceVideoIDChange(); +} + +function checkVideoServiceVideoIDChange(): void { + const videoID = activeVideoService?.getVideoID(); + if (!videoID || videoID === currentVideoServiceVideoID) return; + + resetValues(); + currentVideoServiceVideoID = videoID; + videoIDChange(); +} + +function checkVideoServiceVideoElementChange(video: HTMLVideoElement): void { + if (video === currentVideoServiceVideoElement) return; + + currentVideoServiceVideoElement = video; + triggerVideoElementChange(video); +} + +function setupVideoServiceTracking(): void { + if (!activeVideoService?.setupTracking) return; + + void waitFor(() => Config.isReady(), 5000, 1).then(() => { + activeVideoService.setupTracking({ + onVideoIDChange: checkVideoServiceVideoIDChange, + onVideoElementChange: checkVideoServiceVideoElementChange + }); + }); +} // Is the video currently being switched let switchingVideos = null; @@ -181,7 +231,7 @@ const skipNoticeContentContainer: ContentContainer = () => ({ sponsorTimes, sponsorTimesSubmitting, skipNotices, - sponsorVideoID: getVideoID(), + sponsorVideoID: getCurrentVideoID(), reskipSponsorTime, updatePreviewBar, onMobileYouTube: isOnMobileYouTube(), @@ -205,7 +255,7 @@ function messageListener(request: Message, sender: unknown, sendResponse: (respo //messages from popup script switch(request.message){ case "update": - checkVideoIDChange(); + checkCurrentVideoIDChange(); break; case "sponsorStart": startOrEndTimingNewSegment() @@ -223,7 +273,7 @@ function messageListener(request: Message, sender: unknown, sendResponse: (respo sponsorTimes: sponsorTimes.filter((segment) => getCategorySelection(segment).option !== CategorySkipOption.Disabled), time: getCurrentTime() ?? 0, onMobileYouTube: isOnMobileYouTube(), - videoID: getVideoID(), + videoID: getCurrentVideoID(), loopedChapter: loopedChapter?.UUID, channelID: getChannelIDInfo().id, channelAuthor: getChannelIDInfo().author, @@ -249,14 +299,14 @@ function messageListener(request: Message, sender: unknown, sendResponse: (respo break; case "refreshSegments": // update video on refresh if videoID invalid - if (!getVideoID()) { - checkVideoIDChange(); + if (!getCurrentVideoID()) { + checkCurrentVideoIDChange(); } // 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 }); + sendResponse({ hasVideo: getCurrentVideoID() != null }); // fetch segments - if (getVideoID()) { + if (getCurrentVideoID()) { sponsorsLookup(false, true); } @@ -275,7 +325,7 @@ function messageListener(request: Message, sender: unknown, sendResponse: (respo return true; case "hideSegment": utils.getSponsorTimeFromUUID(sponsorTimes, request.UUID).hidden = request.type; - utils.addHiddenSegment(getVideoID(), request.UUID, request.type); + utils.addHiddenSegment(getCurrentVideoID(), request.UUID, request.type); updatePreviewBar(); if (skipButtonControlBar?.isEnabled() @@ -319,7 +369,7 @@ function messageListener(request: Message, sender: unknown, sendResponse: (respo } if (addedSegments) { - Config.local.unsubmittedSegments[getVideoID()] = sponsorTimesSubmitting; + Config.local.unsubmittedSegments[getCurrentVideoID()] = sponsorTimesSubmitting; Config.forceLocalUpdate("unsubmittedSegments"); updateEditButtonsOnPlayer(); @@ -407,6 +457,7 @@ if (!window.location.href.includes("youtube.com/live_chat")) { } function resetValues() { + currentVideoServiceVideoID = null; lastCheckTime = 0; lastCheckVideoTime = -1; previewedSegment = false; @@ -473,14 +524,14 @@ function videoIDChange(): void { }); }).catch(); } else { - utils.wait(getControls).then(createPreviewBar); + utils.wait(activeVideoService ? getPreviewBarAttachElement : getControls).then(createPreviewBar).catch(); } } // Notify the popup about the video change chrome.runtime.sendMessage({ message: "videoChanged", - videoID: getVideoID(), + videoID: getCurrentVideoID(), channelID: getChannelIDInfo().id, channelAuthor: getChannelIDInfo().author }); @@ -494,7 +545,9 @@ function videoIDChange(): void { sponsorTimesSubmitting = []; updateSponsorTimesSubmitting(); - tryShowingDeArrowPromotion().catch(logWarn); + if (activeVideoService?.capabilities.deArrow ?? true) { + tryShowingDeArrowPromotion().catch(logWarn); + } checkPreviewbarState(); @@ -579,6 +632,12 @@ function getPreviewBarAttachElement(): HTMLElement | null { isVisibleCheck: false } ]; + if (activeVideoService?.selectors?.previewBar) { + progressElementOptions.push({ + selector: activeVideoService.selectors.previewBar, + isVisibleCheck: true + }); + } for (const option of progressElementOptions) { const allElements = document.querySelectorAll(option.selector) as NodeListOf; @@ -601,7 +660,7 @@ function createPreviewBar(): void { const el = getPreviewBarAttachElement(); if (el) { - const chapterVote = new ChapterVote(voteAsync); + const chapterVote = activeVideoService?.capabilities.chapterVote === false ? null : new ChapterVote(voteAsync); previewBar = new PreviewBar(el, isOnMobileYouTube(), isOnInvidious(), isOnYTTV(), chapterVote, () => importExistingChapters(true)); updatePreviewBar(); @@ -683,7 +742,7 @@ async function startSponsorSchedule(includeIntersectingSegments = false, current 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(); + const videoID = getCurrentVideoID(); if (videoMuted && !inMuteSegment(currentTime, skipInfo.index !== -1 && timeUntilSponsor < skipBuffer && shouldAutoSkip(currentSkip))) { @@ -887,8 +946,8 @@ function inMuteSegment(currentTime: number, includeOverlap: boolean): boolean { function incorrectVideoCheck(videoID?: string, sponsorTime?: SponsorTime): boolean { if (!onVideoPage()) return false; - const currentVideoID = getYouTubeVideoID(); - const recordedVideoID = videoID || getVideoID(); + const currentVideoID = getCurrentPageVideoID(); + const recordedVideoID = videoID || getCurrentVideoID(); 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]) @@ -899,7 +958,7 @@ function incorrectVideoCheck(videoID?: string, sponsorTime?: SponsorTime): boole console.error("[SponsorBlock] SponsorTime", sponsorTime, "sponsorTimes", sponsorTimes, "sponsorTimesSubmitting", sponsorTimesSubmitting); // Video ID change occured - checkVideoIDChange(); + checkCurrentVideoIDChange(); return true; } else { @@ -1007,7 +1066,7 @@ function setupVideoListeners(video: HTMLVideoElement) { // That extension makes rate change events not propagate if (document.body.classList.contains("vsc-initialized")) { playbackRateCheckInterval = setInterval(() => { - if ((!getVideoID() || video.paused) && playbackRateCheckInterval) { + if ((!getCurrentVideoID() || video.paused) && playbackRateCheckInterval) { // Video is gone, stop checking clearInterval(playbackRateCheckInterval); return; @@ -1186,6 +1245,8 @@ function setupSkipButtonControlBar() { } function setupCategoryPill() { + if (activeVideoService?.capabilities.categoryPill === false) return; + if (!categoryPill) { categoryPill = new CategoryPill(); } @@ -1194,7 +1255,7 @@ function setupCategoryPill() { } async function sponsorsLookup(keepOldSubmissions = true, ignoreCache = false) { - const videoID = getVideoID(); + const videoID = getCurrentVideoID(); if (!videoID) { console.error("[SponsorBlock] Attempted to fetch segments with a null/undefined videoID."); return; @@ -1203,7 +1264,7 @@ async function sponsorsLookup(keepOldSubmissions = true, ignoreCache = false) { const segmentData = await getSegmentsForVideo(videoID, ignoreCache); // Make sure an old pending request doesn't get used. - if (videoID !== getVideoID()) return; + if (videoID !== getCurrentVideoID()) return; // store last response status lastResponseStatus = segmentData.status; @@ -1267,7 +1328,9 @@ async function sponsorsLookup(keepOldSubmissions = true, ignoreCache = false) { } notifyPopupOfSegments(); - importExistingChapters(true); + if (activeVideoService?.capabilities.chapters ?? true) { + importExistingChapters(true); + } if (Config.config.isVip) { lockedCategoriesLookup(); @@ -1283,7 +1346,7 @@ function notifyPopupOfSegments(): void { sponsorTimes: sponsorTimes.filter((segment) => getCategorySelection(segment).option !== CategorySkipOption.Disabled), time: getCurrentTime() ?? 0, onMobileYouTube: isOnMobileYouTube(), - videoID: getVideoID(), + videoID: getCurrentVideoID(), loopedChapter: loopedChapter?.UUID, channelID: getChannelIDInfo().id, channelAuthor: getChannelIDInfo().author, @@ -1292,8 +1355,10 @@ function notifyPopupOfSegments(): void { } function importExistingChapters(wait: boolean) { + if (activeVideoService?.capabilities.chapters === false) return; + if (!existingChaptersImported && !importingChaptersWaiting && onVideoPage() && !isOnMobileYouTube()) { - const waitCondition = () => getVideoDuration() && getExistingChapters(getVideoID(), getVideoDuration()); + const waitCondition = () => getVideoDuration() && getExistingChapters(getCurrentVideoID(), getVideoDuration()); if (wait && !document.hasFocus() && !importingChaptersWaitingForFocus && !waitCondition()) { importingChaptersWaitingForFocus = true; @@ -1331,12 +1396,12 @@ function handleExistingChaptersChannelChange() { } async function lockedCategoriesLookup(): Promise { - const hashPrefix = (await getHash(getVideoID(), 1)).slice(0, 4); + const hashPrefix = (await getHash(getCurrentVideoID(), 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; + const categoriesResponse = JSON.parse(response.responseText).filter((lockInfo) => lockInfo.videoID === getCurrentVideoID())[0]?.categories; if (Array.isArray(categoriesResponse)) { lockedCategories = categoriesResponse; } @@ -1511,7 +1576,7 @@ function videoElementChange(newVideo: boolean, video: HTMLVideoElement): void { let checkingPreviewbarAgain = false; function checkPreviewbarState(): void { - if (!getPreviewBarAttachElement() && !checkingPreviewbarAgain && getVideo() && getVideoID()) { + if (!getPreviewBarAttachElement() && !checkingPreviewbarAgain && getVideo() && getCurrentVideoID()) { checkingPreviewbarAgain = true; setTimeout(() => { checkingPreviewbarAgain = false; @@ -1743,7 +1808,7 @@ function sendTelemetryAndCount(skippingSegments: SponsorTime[], secondsSkipped: counted = true; } - if (fullSkip) asyncRequestToServer("POST", "/api/viewedVideoSponsorTime?UUID=" + segment.UUID + "&videoID=" + getVideoID()) + if (fullSkip) asyncRequestToServer("POST", "/api/viewedVideoSponsorTime?UUID=" + segment.UUID + "&videoID=" + getCurrentVideoID()) .then(r => { if (!r.ok) logRequest(r, "SB", "segment skip log"); }) @@ -1919,6 +1984,7 @@ function createButton(baseID: string, title: string, callback: () => void, image newButton.id = baseID + "Button"; newButton.classList.add("playerButton"); newButton.classList.add("ytp-button"); + if (activeVideoService) newButton.classList.add("sb-video-service-button"); if (Config.config.prideTheme) newButton.classList.add("prideTheme"); if (isOnYTTV()) { // Some style needs to be set here, but the numbers don't matter @@ -2004,7 +2070,7 @@ async function createButtons(): Promise { /** 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; + if (!getCurrentVideoID() || isOnMobileYouTube()) return; await createButtons(); @@ -2022,7 +2088,7 @@ async function updateVisibilityOfPlayerControlsButton(): Promise { /** 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; + if (!getCurrentVideoID() || isOnMobileYouTube()) return; const buttonsEnabled = !(Config.config.hideVideoPlayerControls || isOnInvidious()); @@ -2065,17 +2131,21 @@ function updateEditButtonsOnPlayer(): void { * for sponsor skipping as the video is not playing during these times. */ function getRealCurrentTime(): number { + if (activeVideoService) { + return getCurrentTime() ?? 0; + } + // 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)) { + if (playButtonSVGData === replaceSVGData || playButtonSVGDataNew?.startsWith(replaceSVGDataNew)) { // At the end of the video return getVideoDuration(); } else { - return getCurrentTime(); + return getCurrentTime() ?? 0; } } @@ -2106,7 +2176,7 @@ function startOrEndTimingNewSegment() { } // Save the newly created segment - Config.local.unsubmittedSegments[getVideoID()] = sponsorTimesSubmitting; + Config.local.unsubmittedSegments[getCurrentVideoID()] = sponsorTimesSubmitting; Config.forceLocalUpdate("unsubmittedSegments"); // Make sure they know if someone has already submitted something it while they were watching @@ -2115,7 +2185,9 @@ function startOrEndTimingNewSegment() { updateEditButtonsOnPlayer(); updateSponsorTimesSubmitting(false); - importExistingChapters(false); + if (activeVideoService?.capabilities.chapters ?? true) { + importExistingChapters(false); + } if (lastResponseStatus !== 200 && lastResponseStatus !== 404 && !shownSegmentFailedToFetchWarning && Config.config.showSegmentFailedToFetchWarning) { @@ -2139,11 +2211,11 @@ function cancelCreatingSegment() { if (isSegmentCreationInProgress()) { if (sponsorTimesSubmitting.length > 1) { // If there's more than one segment: remove last sponsorTimesSubmitting.pop(); - Config.local.unsubmittedSegments[getVideoID()] = sponsorTimesSubmitting; + Config.local.unsubmittedSegments[getCurrentVideoID()] = sponsorTimesSubmitting; } else { // Otherwise delete the video entry & close submission menu resetSponsorSubmissionNotice(); sponsorTimesSubmitting = []; - delete Config.local.unsubmittedSegments[getVideoID()]; + delete Config.local.unsubmittedSegments[getCurrentVideoID()]; } Config.forceLocalUpdate("unsubmittedSegments"); } @@ -2153,7 +2225,7 @@ function cancelCreatingSegment() { } function updateSponsorTimesSubmitting(getFromConfig = true) { - const segmentTimes = Config.local.unsubmittedSegments[getVideoID()]; + const segmentTimes = Config.local.unsubmittedSegments[getCurrentVideoID()]; //see if this data should be saved in the sponsorTimesSubmitting variable if (getFromConfig && segmentTimes != undefined) { @@ -2175,7 +2247,9 @@ function updateSponsorTimesSubmitting(getFromConfig = true) { // Assume they already previewed a segment previewedSegment = true; - importExistingChapters(true); + if (activeVideoService?.capabilities.chapters ?? true) { + importExistingChapters(true); + } } } @@ -2204,6 +2278,7 @@ function openInfoMenu() { const popup = document.createElement("div"); popup.id = "sponsorBlockPopupContainer"; + if (activeVideoService) popup.classList.add("sb-video-service-popup"); const frame = document.createElement("iframe"); frame.allow = "clipboard-write"; @@ -2255,6 +2330,9 @@ function openInfoMenu() { } const parentNodeOptions = [{ + // Other video services + selector: activeVideoService?.selectors?.popupParent?.join(", "), + }, { // YouTube selector: "#secondary-inner", hasChildCheck: true @@ -2263,14 +2341,21 @@ function openInfoMenu() { selector: "#watch7-sidebar-contents", }]; for (const option of parentNodeOptions) { + if (!option.selector) continue; + 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); + else el.prepend(popup); break; } } + + if (!popup.isConnected && activeVideoService) { + document.body.prepend(popup); + } } function closeInfoMenu() { @@ -2286,7 +2371,7 @@ function closeInfoMenu() { } function clearSponsorTimes() { - const currentVideoID = getVideoID(); + const currentVideoID = getCurrentVideoID(); const sponsorTimes = Config.local.unsubmittedSegments[currentVideoID]; @@ -2370,7 +2455,7 @@ async function voteAsync(type: number, UUID: SegmentUUID, category?: Category): type: type, UUID: UUID, category: category, - videoID: getVideoID() + videoID: getCurrentVideoID() }, (response) => { if (response.ok === true) { // Change the sponsor locally @@ -2385,7 +2470,7 @@ async function voteAsync(type: number, UUID: SegmentUUID, category?: Category): } if (!category && !Config.config.isVip) { - utils.addHiddenSegment(getVideoID(), segment.UUID, segment.hidden); + utils.addHiddenSegment(getCurrentVideoID(), segment.UUID, segment.hidden); } updatePreviewBar(); @@ -2485,7 +2570,7 @@ async function sendSubmitMessage(): Promise { } //update sponsorTimes - Config.local.unsubmittedSegments[getVideoID()] = sponsorTimesSubmitting; + Config.local.unsubmittedSegments[getCurrentVideoID()] = sponsorTimesSubmitting; Config.forceLocalUpdate("unsubmittedSegments"); // Check to see if any of the submissions are below the minimum duration set @@ -2503,13 +2588,20 @@ async function sendSubmitMessage(): Promise { let response: FetchResponse; try { - response = await asyncRequestToServer("POST", "/api/skipSegments", { - videoID: getVideoID(), - userID: Config.config.userID, - segments: sponsorTimesSubmitting, - videoDuration: getVideoDuration(), - userAgent: extensionUserAgent(), - }); + if (activeVideoService?.submitSegment) { + const responses = await Promise.all(sponsorTimesSubmitting.map((segment) => + activeVideoService.submitSegment(getCurrentVideoID(), segment) + )); + response = responses.find((response) => !response.ok) ?? responses[responses.length - 1]; + } else { + response = await asyncRequestToServer("POST", "/api/skipSegments", { + videoID: getCurrentVideoID(), + 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 @@ -2519,11 +2611,11 @@ async function sendSubmitMessage(): Promise { return false; } - if (response.status === 200) { + if (response.status === 200 || (activeVideoService && response.ok)) { stopAnimation(); // Remove segments from storage since they've already been submitted - delete Config.local.unsubmittedSegments[getVideoID()]; + delete Config.local.unsubmittedSegments[getCurrentVideoID()]; Config.forceLocalUpdate("unsubmittedSegments"); const newSegments = sponsorTimesSubmitting; @@ -2874,7 +2966,7 @@ function checkForPreloadedSegment() { } if (pushed) { - Config.local.unsubmittedSegments[getVideoID()] = sponsorTimesSubmitting; + Config.local.unsubmittedSegments[getCurrentVideoID()] = sponsorTimesSubmitting; Config.forceLocalUpdate("unsubmittedSegments"); } } @@ -2904,6 +2996,9 @@ function setCategoryColorCSSVariables() { css += `--darkreader-text--sb-category-text-${category}: ${luminance > 128 ? "black" : "white"};`; } css += "}"; + if (activeVideoService?.contentStyle) { + css += activeVideoService.contentStyle; + } styleContainer.innerText = css; } @@ -2925,4 +3020,4 @@ function checkForMiniplayerPlaying() { } } } -} +} \ No newline at end of file diff --git a/src/js-components/previewBar.ts b/src/js-components/previewBar.ts index a90cc73dc4..069e68d051 100644 --- a/src/js-components/previewBar.ts +++ b/src/js-components/previewBar.ts @@ -16,6 +16,7 @@ import { isVorapisInstalled } from "../utils/compatibility"; import { isOnYTTV } from "../../maze-utils/src/video"; import { getCategorySelection } from "../utils/skipRule"; import { getSkipProfileBool } from "../utils/skipProfiles"; +import { getActiveVideoService } from "../videoServices"; const TOOLTIP_VISIBLE_CLASS = 'sponsorCategoryTooltipVisible'; const MIN_CHAPTER_SIZE = 0.003; @@ -70,7 +71,7 @@ class PreviewBar { hoveredSection: HTMLElement; customChaptersBar: HTMLElement; chaptersBarSegments: PreviewBarSegment[]; - chapterVote: ChapterVote; + chapterVote: ChapterVote | null; originalChapterBar: HTMLElement; originalChapterBarBlocks: NodeListOf; chapterMargin: number; @@ -78,7 +79,7 @@ class PreviewBar { unfilteredChapterGroups: ChapterGroup[]; chapterGroups: ChapterGroup[]; - constructor(parent: HTMLElement, onMobileYouTube: boolean, onInvidious: boolean, onYTTV: boolean, chapterVote: ChapterVote, updateExistingChapters: () => void, test=false) { + constructor(parent: HTMLElement, onMobileYouTube: boolean, onInvidious: boolean, onYTTV: boolean, chapterVote: ChapterVote | null, updateExistingChapters: () => void, test=false) { if (test) return; this.container = document.createElement('ul'); this.container.id = 'previewbar'; @@ -288,6 +289,8 @@ class PreviewBar { if (this.onMobileYouTube) { this.container.style.transform = "none"; + } else if (getActiveVideoService()?.previewBarClass) { + this.container.classList.add(getActiveVideoService()!.previewBarClass); } else if (!this.onInvidious) { this.container.classList.add("sbNotInvidious"); } @@ -350,7 +353,13 @@ class PreviewBar { private updatePageElements(): void { // YT, Vorapis v3 - const allProgressBars = document.querySelectorAll(".ytp-progress-bar, .ytp-progress-bar-container > .html5-progress-bar > .ytp-progress-list") as NodeListOf; + const servicePreviewBarSelector = getActiveVideoService()?.selectors?.previewBar; + const progressBarSelector = [ + ".ytp-progress-bar", + ".ytp-progress-bar-container > .html5-progress-bar > .ytp-progress-list", + servicePreviewBarSelector + ].filter(Boolean).join(", "); + const allProgressBars = document.querySelectorAll(progressBarSelector) as NodeListOf; this.progressBar = findValidElement(allProgressBars) ?? allProgressBars?.[0]; if (this.progressBar) { @@ -971,7 +980,7 @@ class PreviewBar { chapterTitle.classList.remove("sponsorBlock-segment-title"); } - if (chosenSegment.source === SponsorSourceType.Server) { + if (chosenSegment.source === SponsorSourceType.Server && this.chapterVote) { const chapterVoteContainer = this.chapterVote.getContainer(); if (document.location.host === "tv.youtube.com") { if (!chaptersContainer.contains(chapterVoteContainer)) { @@ -993,7 +1002,7 @@ class PreviewBar { this.chapterVote.setVisibility(true); this.chapterVote.setSegment(chosenSegment); } else { - this.chapterVote.setVisibility(false); + this.chapterVote?.setVisibility(false); } } else if (!getSkipProfileBool("showAutogeneratedChapters") && hasAutogeneratedChapters()) { // Keep original hidden @@ -1006,7 +1015,7 @@ class PreviewBar { const titlePrefixDot = chaptersContainer.querySelector(".ytp-chapter-title-prefix") as HTMLElement; if (titlePrefixDot) titlePrefixDot.style.display = "none"; - this.chapterVote.setVisibility(false); + this.chapterVote?.setVisibility(false); } else { chaptersContainer.querySelector(".sponsorChapterText")?.remove(); const chapterTitle = chaptersContainer.querySelector(".ytp-chapter-title-content") as HTMLDivElement; @@ -1014,7 +1023,7 @@ class PreviewBar { chapterTitle.style.removeProperty("display"); chaptersContainer.classList.remove("sponsorblock-chapter-visible"); - this.chapterVote.setVisibility(false); + this.chapterVote?.setVisibility(false); } } } diff --git a/src/utils.ts b/src/utils.ts index 2340ab3e81..4889d9a819 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -8,6 +8,7 @@ import { isSafari } from "../maze-utils/src/config"; import { asyncRequestToServer } from "./utils/requests"; import { FetchResponse, logRequest } from "../maze-utils/src/background-request-proxy"; import { formatJSErrorMessage, getLongErrorMessage } from "../maze-utils/src/formating"; +import { getActiveVideoService } from "./videoServices"; export default class Utils { @@ -242,7 +243,8 @@ export default class Utils { ".main-video-section > .video-container", // Cloudtube ".shaka-video-container", // Piped "#player-container.ytk-player", // YT Kids - "#id-tv-container" // YTTV + "#id-tv-container", // YTTV + ...(getActiveVideoService()?.selectors?.referenceNode ?? []) ]; let referenceNode = findValidElementFromSelector(selectors) diff --git a/src/utils/pageUtils.ts b/src/utils/pageUtils.ts index 034ff0f01f..fa4d943d49 100644 --- a/src/utils/pageUtils.ts +++ b/src/utils/pageUtils.ts @@ -1,6 +1,7 @@ import { ActionType, Category, SponsorSourceType, SponsorTime, VideoID } from "../types"; import { getFormattedTimeToSeconds } from "../../maze-utils/src/formating"; import { getSkipProfileBool } from "./skipProfiles"; +import { getActiveVideoService } from "../videoServices"; export function getControls(): HTMLElement { const controlsSelectors = [ @@ -13,7 +14,8 @@ export function getControls(): HTMLElement { // Vorapis v3 ".html5-player-chrome", // tv.youtube.com - ".ypcs-control-buttons-right" + ".ypcs-control-buttons-right", + ...(getActiveVideoService()?.selectors?.controls ?? []) ]; for (const controlsSelector of controlsSelectors) { diff --git a/src/utils/segmentData.ts b/src/utils/segmentData.ts index 7ff6a13490..20672f038e 100644 --- a/src/utils/segmentData.ts +++ b/src/utils/segmentData.ts @@ -7,6 +7,7 @@ import { getHashParams } from "./pageUtils"; import { asyncRequestToServer } from "./requests"; import { extensionUserAgent } from "../../maze-utils/src"; import { logRequest, serializeOrStringify } from "../../maze-utils/src/background-request-proxy"; +import { getActiveVideoService } from "../videoServices"; const segmentDataCache = new DataCache(() => { return { @@ -55,6 +56,31 @@ export async function getSegmentsForVideo(videoID: VideoID, ignoreCache: boolean } async function fetchSegmentsForVideo(videoID: VideoID): Promise { + const activeVideoService = getActiveVideoService(); + if (activeVideoService?.getSegments) { + const response = await activeVideoService.getSegments(videoID); + if (response.ok && response.segments?.length) { + const result = { + segments: response.segments, + status: response.status + }; + + segmentDataCache.setupCache(videoID).segments = result.segments; + return result; + } + + if (response.ok || response.status === 404) { + segmentDataCache.setupCache(videoID); + } else { + logRequest(response, "SB", `${activeVideoService.id} skip segments`); + } + + return { + segments: null, + status: response.status + }; + } + const extraRequestData: Record = {}; const hashParams = getHashParams(); if (hashParams.requiredSegment) extraRequestData.requiredSegment = hashParams.requiredSegment; diff --git a/src/videoServices/index.ts b/src/videoServices/index.ts new file mode 100644 index 0000000000..17580b881a --- /dev/null +++ b/src/videoServices/index.ts @@ -0,0 +1,12 @@ +import { rutubeVideoService } from "./rutube"; +import type { VideoService } from "./types"; + +export type { VideoService } from "./types"; + +const videoServices = [ + rutubeVideoService +]; + +export function getActiveVideoService(): VideoService | null { + return videoServices.find((service) => service.isCurrentHost()) ?? null; +} \ No newline at end of file diff --git a/src/videoServices/rutube/index.ts b/src/videoServices/rutube/index.ts new file mode 100644 index 0000000000..d54a49fded --- /dev/null +++ b/src/videoServices/rutube/index.ts @@ -0,0 +1,181 @@ +import { addCleanupListener } from "../../../maze-utils/src/cleanup"; +import { ActionType, Category, SegmentUUID, SponsorSourceType, SponsorTime, VideoID } from "../../types"; +import { FetchResponse, sendRequestToCustomServer } from "../../../maze-utils/src/background-request-proxy"; +import type { VideoService } from "../types"; + +const RUTUBE_API = "https://sponsorblock.futuba.ru/api"; +const RUTUBE_PREVIEW_BAR_SELECTOR = '[data-testid="ui-progress-progressBar"], [data-testid="video-ui"] [role="slider"][aria-valuemax]'; +const RUTUBE_CONTROLS_SELECTORS = [ + '[data-testid="video-ui"] [class*="desktop-controls-layout-module__column"][class*="_justify-flex-end"][class*="_align-center"]', + '[data-testid="video-ui"] [class*="_justify-flex-end"][class*="_align-center"]' +]; +const RUTUBE_REFERENCE_NODE_SELECTORS = [ + "#raichuContainerWithPlayer", + "[data-testid='layout-loader']", + ".video-player" +]; +const RUTUBE_POPUP_PARENT_SELECTORS = [ + "[data-testid='layout-loader']", + "#raichuContainerWithPlayer" +]; +const RUTUBE_CONTENT_STYLE = ` +.sb-video-service-button { + width: 40px; + height: 40px; + min-width: 40px; + padding: 0; + border: 0; + background: transparent; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; +} + +.sb-video-service-button .playerButtonImage { + width: 24px; + height: 24px; + object-fit: contain; +} + +#sponsorBlockPopupContainer.sb-video-service-popup { + position: fixed; + top: 84px; + right: 12px; + width: 374px; + max-width: calc(100vw - 24px); + margin: 0; + z-index: 10000; +} + +#previewbar.sb-video-service-previewbar { + height: 100%; + inset: 0; + transform: none; + overflow: hidden; + border-radius: inherit; + z-index: 1; +} + +#previewbar.sb-video-service-previewbar .previewbar { + height: 100%; + vertical-align: top; +} +`; + +interface RutubeTimestamp { + start: number; + end: number; + type?: string; +} + +interface RutubeInfo { + title?: string; + url?: string; + type?: string; +} + +export function isRutubeHost(host = location.host): boolean { + return host === "rutube.ru" || host.endsWith(".rutube.ru"); +} + +function getRutubeVideoID(url = document.URL): VideoID | null { + try { + const urlObject = new URL(url); + if (!isRutubeHost(urlObject.host)) return null; + + const id = urlObject.pathname.match(/\/video\/([^/]+)/)?.[1]; + return id ? id as VideoID : null; + } catch { + return null; + } +} + +function getRutubeVideoElement(): HTMLVideoElement | null { + return document.querySelector("video") as HTMLVideoElement | null; +} + +async function getRutubeInfo(videoID: VideoID): Promise { + const response = await sendRequestToCustomServer("POST", `${RUTUBE_API}/rutube/info`, { + url: `https://rutube.ru/video/${videoID}` + }); + + if (!response.ok) return null; + + return JSON.parse(response.responseText) as RutubeInfo; +} + +async function getRutubeSegments(videoID: VideoID): Promise { + const response = await sendRequestToCustomServer("GET", `${RUTUBE_API}/timestamps/${videoID}`); + if (!response.ok) return response; + + const timestamps = (JSON.parse(response.responseText)?.timestamps ?? []) as RutubeTimestamp[]; + return { + ...response, + segments: timestamps + .filter((timestamp) => timestamp.type === "skip" && timestamp.end > timestamp.start) + .map((timestamp) => ({ + segment: [timestamp.start, timestamp.end] as [number, number], + UUID: `rutube-${timestamp.start}-${timestamp.end}` as SegmentUUID, + category: "sponsor" as Category, + actionType: ActionType.Skip, + source: SponsorSourceType.Server + })) + .sort((a, b) => a.segment[0] - b.segment[0]) + }; +} + +async function submitRutubeSegment(videoID: VideoID, segment: SponsorTime): Promise { + const info = await getRutubeInfo(videoID); + + return await sendRequestToCustomServer("POST", `${RUTUBE_API}/timestamps/${videoID}`, { + start: segment.segment[0], + end: segment.segment[1], + type: "skip", + videoTitle: info?.title, + videoUrl: info?.url ?? `https://rutube.ru/video/${videoID}`, + videoType: info?.type + }); +} + +export const rutubeVideoService: VideoService = { + id: "rutube", + isCurrentHost: isRutubeHost, + getVideoID: getRutubeVideoID, + getVideoElement: getRutubeVideoElement, + getSegments: getRutubeSegments, + submitSegment: submitRutubeSegment, + setupTracking: ({ onVideoIDChange, onVideoElementChange }) => { + onVideoIDChange(); + + const video = getRutubeVideoElement(); + if (video) onVideoElementChange(video); + + let lastUrl = location.href; + const interval = setInterval(() => { + const video = getRutubeVideoElement(); + if (video) onVideoElementChange(video); + if (location.href === lastUrl) return; + + lastUrl = location.href; + onVideoIDChange(); + }, 500); + + addCleanupListener(() => clearInterval(interval)); + }, + selectors: { + controls: RUTUBE_CONTROLS_SELECTORS, + popupParent: RUTUBE_POPUP_PARENT_SELECTORS, + previewBar: RUTUBE_PREVIEW_BAR_SELECTOR, + referenceNode: RUTUBE_REFERENCE_NODE_SELECTORS + }, + previewBarClass: "sb-video-service-previewbar", + contentStyle: RUTUBE_CONTENT_STYLE, + capabilities: { + categoryPill: false, + chapters: false, + chapterVote: false, + deArrow: false, + documentScript: false + } +}; \ No newline at end of file diff --git a/src/videoServices/types.ts b/src/videoServices/types.ts new file mode 100644 index 0000000000..a01243e05d --- /dev/null +++ b/src/videoServices/types.ts @@ -0,0 +1,30 @@ +import { FetchResponse } from "../../maze-utils/src/background-request-proxy"; +import { SponsorTime, VideoID } from "../types"; + +export interface VideoService { + id: string; + isCurrentHost(host?: string): boolean; + getVideoID(url?: string): VideoID | null; + getVideoElement?(): HTMLVideoElement | null; + getSegments?(videoID: VideoID): Promise; + submitSegment?(videoID: VideoID, segment: SponsorTime): Promise; + setupTracking?(handlers: { + onVideoIDChange: () => void; + onVideoElementChange: (video: HTMLVideoElement) => void; + }): void; + selectors?: { + controls?: string[]; + popupParent?: string[]; + previewBar?: string; + referenceNode?: string[]; + }; + previewBarClass?: string; + contentStyle?: string; + capabilities: { + categoryPill: boolean; + chapters: boolean; + chapterVote: boolean; + deArrow: boolean; + documentScript: boolean; + }; +} \ No newline at end of file