diff --git a/config.json.example b/config.json.example index 7ff22bb7..0a587e3c 100644 --- a/config.json.example +++ b/config.json.example @@ -4,8 +4,8 @@ "serverAddressComment": "This specifies the default SponsorBlock server to connect to", "categoryList": ["sponsor", "selfpromo", "interaction", "poi_highlight", "intro", "outro", "preview", "filler", "music_offtopic"], "categorySupport": { - "sponsor": ["skip", "mute"], - "selfpromo": ["skip", "mute"], + "sponsor": ["skip", "mute", "full"], + "selfpromo": ["skip", "mute", "full"], "interaction": ["skip", "mute"], "intro": ["skip", "mute"], "outro": ["skip", "mute"], diff --git a/manifest/manifest.json b/manifest/manifest.json index fcb22641..181118c6 100644 --- a/manifest/manifest.json +++ b/manifest/manifest.json @@ -1,7 +1,7 @@ { "name": "__MSG_fullName__", "short_name": "SponsorBlock", - "version": "3.7.1", + "version": "3.7.2", "default_locale": "en", "description": "__MSG_Description__", "homepage_url": "https://sponsor.ajay.app", diff --git a/public/_locales/en/messages.json b/public/_locales/en/messages.json index c56b3275..ac7cbe09 100644 --- a/public/_locales/en/messages.json +++ b/public/_locales/en/messages.json @@ -302,6 +302,10 @@ "mute": { "message": "Mute" }, + "full": { + "message": "Full Video", + "description": "Used for the name of the option to label an entire video as sponsor or self promotion." + }, "skip_category": { "message": "Skip {0}?" }, @@ -620,6 +624,10 @@ "muteSegments": { "message": "Allow segments that mute audio instead of skip" }, + "fullVideoSegments": { + "message": "Show an icon when a video is entirely an advertisement", + "description": "Referring to the category pill that is now shown on videos that are entirely sponsor or entirely selfpromo" + }, "colorFormatIncorrect": { "message": "Your color is formatted incorrectly. It should be a 3 or 6 digit hex code with a number sign at the beginning." }, @@ -737,6 +745,12 @@ "message": "Got it", "description": "Used as the button to dismiss a tooltip" }, + "fullVideoTooltipWarning": { + "message": "This segment is large. If the whole video is about one topic, then change from \"Skip\" to \"Full Video\". See the guidelines for more information." + }, + "categoryPillTitleText": { + "message": "This entire video is labeled as this category and is too tightly integrated to be able to separate" + }, "experiementOptOut": { "message": "Opt-out of all future experiments", "description": "This is used in a popup about a new experiment to get a list of unlisted videos to back up since all unlisted videos uploaded before 2017 will be set to private." diff --git a/public/content.css b/public/content.css index b9f19080..bfcd4f30 100644 --- a/public/content.css +++ b/public/content.css @@ -613,3 +613,18 @@ input::-webkit-inner-spin-button { line-height: 1.5em; } +.sponsorBlockCategoryPill { + border-radius: 25px; + padding-left: 8px; + padding-right: 8px; + margin-right: 3px; + cursor: pointer; + font-size: 75%; + height: 100%; + align-items: center; +} + +.sponsorBlockCategoryPillTitleSection { + display: flex; + align-items: center; +} \ No newline at end of file diff --git a/public/options/options.html b/public/options/options.html index 1fbcf1b1..657b2d4d 100644 --- a/public/options/options.html +++ b/public/options/options.html @@ -66,6 +66,22 @@
+
+ + +
+
+
+
+

diff --git a/src/components/CategoryPillComponent.tsx b/src/components/CategoryPillComponent.tsx new file mode 100644 index 00000000..6c7e0437 --- /dev/null +++ b/src/components/CategoryPillComponent.tsx @@ -0,0 +1,107 @@ +import * as React from "react"; +import Config from "../config"; +import { Category, SegmentUUID, SponsorTime } from "../types"; + +import ThumbsUpSvg from "../svg-icons/thumbs_up_svg"; +import ThumbsDownSvg from "../svg-icons/thumbs_down_svg"; +import { downvoteButtonColor, SkipNoticeAction } from "../utils/noticeUtils"; +import { VoteResponse } from "../messageTypes"; +import { AnimationUtils } from "../utils/animationUtils"; +import { GenericUtils } from "../utils/genericUtils"; + +export interface CategoryPillProps { + vote: (type: number, UUID: SegmentUUID, category?: Category) => Promise; +} + +export interface CategoryPillState { + segment?: SponsorTime; + show: boolean; + open?: boolean; +} + +class CategoryPillComponent extends React.Component { + + constructor(props: CategoryPillProps) { + super(props); + + this.state = { + segment: null, + show: false, + open: false + }; + } + + render(): React.ReactElement { + const style: React.CSSProperties = { + backgroundColor: Config.config.barTypes["preview-" + this.state.segment?.category]?.color, + display: this.state.show ? "flex" : "none", + color: this.state.segment?.category === "sponsor" ? "white" : "black", + } + + return ( + this.toggleOpen(e)}> + + + + + {chrome.i18n.getMessage("category_" + this.state.segment?.category)} + + + + {this.state.open && ( + <> + {/* Upvote Button */} +
this.vote(e, 1)}> + +
+ + {/* Downvote Button */} +
this.vote(event, 0)}> + +
+ + )} +
+ ); + } + + private toggleOpen(event: React.MouseEvent): void { + event.stopPropagation(); + + if (this.state.show) { + this.setState({ open: !this.state.open }); + } + } + + private async vote(event: React.MouseEvent, type: number): Promise { + event.stopPropagation(); + if (this.state.segment) { + const stopAnimation = AnimationUtils.applyLoadingAnimation(event.currentTarget as HTMLElement, 0.3); + + const response = await this.props.vote(type, this.state.segment.UUID); + await stopAnimation(); + + if (response.successType == 1 || (response.successType == -1 && response.statusCode == 429)) { + this.setState({ + open: false, + show: type === 1 + }); + } else if (response.statusCode !== 403) { + alert(GenericUtils.getErrorMessage(response.statusCode, response.responseText)); + } + } + } +} + +export default CategoryPillComponent; diff --git a/src/components/SkipNoticeComponent.tsx b/src/components/SkipNoticeComponent.tsx index da771853..49dac9ab 100644 --- a/src/components/SkipNoticeComponent.tsx +++ b/src/components/SkipNoticeComponent.tsx @@ -4,7 +4,6 @@ import Config from "../config" import { Category, ContentContainer, CategoryActionType, SponsorHideType, SponsorTime, NoticeVisbilityMode, ActionType, SponsorSourceType, SegmentUUID } from "../types"; import NoticeComponent from "./NoticeComponent"; import NoticeTextSelectionComponent from "./NoticeTextSectionComponent"; -import SubmissionNotice from "../render/SubmissionNotice"; import Utils from "../utils"; const utils = new Utils(); @@ -13,15 +12,7 @@ import { getCategoryActionType, getSkippingText } from "../utils/categoryUtils"; import ThumbsUpSvg from "../svg-icons/thumbs_up_svg"; import ThumbsDownSvg from "../svg-icons/thumbs_down_svg"; import PencilSvg from "../svg-icons/pencil_svg"; - -export enum SkipNoticeAction { - None, - Upvote, - Downvote, - CategoryVote, - CopyDownvote, - Unskip -} +import { downvoteButtonColor, SkipNoticeAction } from "../utils/noticeUtils"; export interface SkipNoticeProps { segments: SponsorTime[]; @@ -216,7 +207,7 @@ class SkipNoticeComponent extends React.Component this.prepAction(SkipNoticeAction.Downvote)}> - + {/* Copy and Downvote Button */} @@ -279,7 +270,7 @@ class SkipNoticeComponent extends React.Component this.prepAction(SkipNoticeAction.CopyDownvote)}> {chrome.i18n.getMessage("CopyAndDownvote")} @@ -727,16 +718,6 @@ class SkipNoticeComponent extends React.Component 1) { - return (this.state.actionState === downvoteType) ? this.selectedColor : this.unselectedColor; - } else { - // You dont have segment selectors so the lockbutton needs to be colored and cannot be selected. - return Config.config.isVip && this.segments[0].locked === 1 ? this.lockedColor : this.unselectedColor; - } - } - private getUnskipText(): string { switch (this.props.segments[0].actionType) { case ActionType.Mute: { diff --git a/src/components/SponsorTimeEditComponent.tsx b/src/components/SponsorTimeEditComponent.tsx index 144f3c82..eaf83a91 100644 --- a/src/components/SponsorTimeEditComponent.tsx +++ b/src/components/SponsorTimeEditComponent.tsx @@ -1,7 +1,7 @@ import * as React from "react"; import * as CompileConfig from "../../config.json"; import Config from "../config"; -import { ActionType, ActionTypes, Category, CategoryActionType, ContentContainer, SponsorTime } from "../types"; +import { ActionType, Category, CategoryActionType, ContentContainer, SponsorTime } from "../types"; import Utils from "../utils"; import { getCategoryActionType } from "../utils/categoryUtils"; import SubmissionNoticeComponent from "./SubmissionNoticeComponent"; @@ -40,6 +40,7 @@ class SponsorTimeEditComponent extends React.Component this.configUpdate(); Config.configListeners.push(this.configUpdate.bind(this)); } + + this.checkToShowFullVideoWarning(); } componentWillUnmount(): void { @@ -82,6 +85,8 @@ class SponsorTimeEditComponent extends React.Component {utils.getFormattedTime(segment[0], true) + @@ -246,7 +255,7 @@ class SponsorTimeEditComponent extends React.Component { Config.config.scrollToEditTimeUpdate = true }); + } + } + + showToolTip(text: string, buttonFunction?: () => void): boolean { + const element = document.getElementById("sponsorTimesContainer" + this.idSuffix); + if (element) { new RectangleTooltip({ - text: chrome.i18n.getMessage("SponsorTimeEditScrollNewFeature"), + text, referenceNode: element.parentElement, prependElement: element, timeout: 15, @@ -296,10 +312,27 @@ class SponsorTimeEditComponent extends React.Component { Config.config.scrollToEditTimeUpdate = true }, + buttonFunction, fontSize: "14px", maxHeight: "200px" }); + + return true; + } else { + return false; + } + } + + checkToShowFullVideoWarning(): void { + const sponsorTime = this.props.contentContainer().sponsorTimesSubmitting[this.props.index]; + const segmentDuration = sponsorTime.segment[1] - sponsorTime.segment[0]; + const videoPercentage = segmentDuration / this.props.contentContainer().v.duration; + + if (videoPercentage > 0.6 && !this.fullVideoWarningShown + && (sponsorTime.category === "sponsor" || sponsorTime.category === "selfpromo" || sponsorTime.category === "chooseACategory")) { + if (this.showToolTip(chrome.i18n.getMessage("fullVideoTooltipWarning"))) { + this.fullVideoWarningShown = true; + } } } @@ -444,6 +477,12 @@ class SponsorTimeEditComponent extends React.Component Config.config !== null, 5000, 10).then(addCSS); @@ -75,9 +78,11 @@ let lastCheckVideoTime = -1; //is this channel whitelised from getting sponsors skipped let channelWhitelisted = false; -// create preview bar 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; @@ -264,6 +269,7 @@ function resetValues() { } skipButtonControlBar?.disable(); + categoryPill?.setVisibility(false); } async function videoIDChange(id) { @@ -560,6 +566,7 @@ function refreshVideoAttachments() { setupVideoListeners(); setupSkipButtonControlBar(); + setupCategoryPill(); } // Create a new bar in the new video element @@ -657,6 +664,14 @@ function setupSkipButtonControlBar() { skipButtonControlBar.attachToPage(); } +function setupCategoryPill() { + if (!categoryPill) { + categoryPill = new CategoryPill(); + } + + categoryPill.attachToPage(onMobileYouTube, onInvidious, voteAsync); +} + async function sponsorsLookup(id: string, keepOldSubmissions = true) { if (!video || !isVisible(video)) refreshVideoAttachments(); //there is still no video here @@ -692,7 +707,7 @@ async function sponsorsLookup(id: string, keepOldSubmissions = true) { const hashPrefix = (await utils.getHash(id, 1)).substr(0, 4); const response = await utils.asyncRequestToServer('GET', "/api/skipSegments/" + hashPrefix, { categories, - actionTypes: Config.config.muteSegments ? [ActionType.Skip, ActionType.Mute] : [ActionType.Skip], + actionTypes: getEnabledActionTypes(), userAgent: `${chrome.runtime.id}`, ...extraRequestData }); @@ -773,6 +788,18 @@ async function sponsorsLookup(id: string, keepOldSubmissions = true) { lookupVipInformation(id); } +function getEnabledActionTypes(): ActionType[] { + const actionTypes = [ActionType.Skip]; + if (Config.config.muteSegments) { + actionTypes.push(ActionType.Mute); + } + if (Config.config.fullVideoSegments) { + actionTypes.push(ActionType.Full); + } + + return actionTypes; +} + function lookupVipInformation(id: string): void { updateVipInfo().then((isVip) => { if (isVip) { @@ -884,6 +911,11 @@ function startSkipScheduleCheckingForStartSponsors() { } } + const fullVideoSegment = sponsorTimes.filter((time) => time.actionType === ActionType.Full)[0]; + if (fullVideoSegment) { + categoryPill?.setSegment(fullVideoSegment); + } + if (startingSegmentTime !== -1) { startSponsorSchedule(undefined, startingSegmentTime); } else { @@ -1005,6 +1037,7 @@ function updatePreviewBar(): void { segment: segment.segment as [number, number], category: segment.category, unsubmitted: false, + actionType: segment.actionType, showLarger: getCategoryActionType(segment.category) === CategoryActionType.POI }); }); @@ -1015,11 +1048,12 @@ function updatePreviewBar(): void { segment: segment.segment as [number, number], category: segment.category, unsubmitted: true, + actionType: segment.actionType, showLarger: getCategoryActionType(segment.category) === CategoryActionType.POI }); }); - previewBar.set(previewBarSegments, video?.duration) + previewBar.set(previewBarSegments.filter((segment) => segment.actionType !== ActionType.Full), video?.duration) if (Config.config.showTimeWithSkips) { const skippedDuration = utils.getTimestampsDuration(previewBarSegments.map(({segment}) => segment)); @@ -1391,7 +1425,7 @@ async function createButtons(): Promise { && playerButtons["info"]?.button && !controlsWithEventListeners.includes(controlsContainer)) { controlsWithEventListeners.push(controlsContainer); - utils.setupAutoHideAnimation(playerButtons["info"].button, controlsContainer); + AnimationUtils.setupAutoHideAnimation(playerButtons["info"].button, controlsContainer); } } @@ -1671,13 +1705,37 @@ function clearSponsorTimes() { } //if skipNotice is null, it will not affect the UI -function vote(type: number, UUID: SegmentUUID, category?: Category, skipNotice?: SkipNoticeComponent) { +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 warning from a moderator.")) { + skipNotice.setNoticeInfoMessageWithOnClick.bind(skipNotice)(() => { + Chat.openWarningChat(response.responseText); + skipNotice.closeListener.call(skipNotice); + }, chrome.i18n.getMessage("voteRejectedWarning")); + } else { + skipNotice.setNoticeInfoMessage.bind(skipNotice)(GenericUtils.getErrorMessage(response.statusCode, response.responseText)) + } + + skipNotice.resetVoteButtonInfo.bind(skipNotice)(); + } + } + } +} + +async function voteAsync(type: number, UUID: SegmentUUID, category?: Category): Promise { const sponsorIndex = utils.getSponsorIndexFromUUID(sponsorTimes, UUID); // Don't vote for preview sponsors @@ -1697,33 +1755,14 @@ function vote(type: number, UUID: SegmentUUID, category?: Category, skipNotice?: Config.config.skipCount = Config.config.skipCount + factor; } - - chrome.runtime.sendMessage({ - message: "submitVote", - type: type, - UUID: UUID, - category: category - }, function(response) { - 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 warning from a moderator.")) { - skipNotice.setNoticeInfoMessageWithOnClick.bind(skipNotice)(() => { - Chat.openWarningChat(response.responseText); - skipNotice.closeListener.call(skipNotice); - }, chrome.i18n.getMessage("voteRejectedWarning")); - } else { - skipNotice.setNoticeInfoMessage.bind(skipNotice)(utils.getErrorMessage(response.statusCode, response.responseText)) - } - - skipNotice.resetVoteButtonInfo.bind(skipNotice)(); - } - } - } + + return new Promise((resolve) => { + chrome.runtime.sendMessage({ + message: "submitVote", + type: type, + UUID: UUID, + category: category + }, resolve); }); } @@ -1766,7 +1805,7 @@ function submitSponsorTimes() { async function sendSubmitMessage() { // Add loading animation playerButtons.submit.image.src = chrome.extension.getURL("icons/PlayerUploadIconSponsorBlocker.svg"); - const stopAnimation = utils.applyLoadingAnimation(playerButtons.submit.button, 1, () => updateEditButtonsOnPlayer()); + 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++) { @@ -1838,7 +1877,7 @@ async function sendSubmitMessage() { if (response.status === 403 && response.responseText.startsWith("Submission rejected due to a warning from a moderator.")) { Chat.openWarningChat(response.responseText); } else { - alert(utils.getErrorMessage(response.status, response.responseText)); + alert(GenericUtils.getErrorMessage(response.status, response.responseText)); } } } diff --git a/src/js-components/previewBar.ts b/src/js-components/previewBar.ts index 4e840646..d4d041a9 100644 --- a/src/js-components/previewBar.ts +++ b/src/js-components/previewBar.ts @@ -6,6 +6,7 @@ https://github.com/videosegments/videosegments/commits/f1e111bdfe231947800c6efdd 'use strict'; import Config from "../config"; +import { ActionType } from "../types"; import Utils from "../utils"; const utils = new Utils(); @@ -15,6 +16,7 @@ export interface PreviewBarSegment { segment: [number, number]; category: string; unsubmitted: boolean; + actionType: ActionType; showLarger: boolean; } diff --git a/src/js-components/skipButtonControlBar.ts b/src/js-components/skipButtonControlBar.ts index 32018307..a27eefd0 100644 --- a/src/js-components/skipButtonControlBar.ts +++ b/src/js-components/skipButtonControlBar.ts @@ -3,6 +3,7 @@ import { SponsorTime } from "../types"; import { getSkippingText } from "../utils/categoryUtils"; import Utils from "../utils"; +import { AnimationUtils } from "../utils/animationUtils"; const utils = new Utils(); export interface SkipButtonControlBarProps { @@ -80,9 +81,9 @@ export class SkipButtonControlBar { } if (!this.onMobileYouTube) { - utils.setupAutoHideAnimation(this.skipIcon, mountingContainer, false, false); + AnimationUtils.setupAutoHideAnimation(this.skipIcon, mountingContainer, false, false); } else { - const { hide, show } = utils.setupCustomHideAnimation(this.skipIcon, mountingContainer, false, false); + const { hide, show } = AnimationUtils.setupCustomHideAnimation(this.skipIcon, mountingContainer, false, false); this.hideButton = hide; this.showButton = show; } @@ -104,7 +105,7 @@ export class SkipButtonControlBar { this.refreshText(); this.textContainer?.classList?.remove("hidden"); - utils.disableAutoHideAnimation(this.skipIcon); + AnimationUtils.disableAutoHideAnimation(this.skipIcon); this.startTimer(); } @@ -160,7 +161,7 @@ export class SkipButtonControlBar { this.getChapterPrefix()?.classList?.add("hidden"); - utils.enableAutoHideAnimation(this.skipIcon); + AnimationUtils.enableAutoHideAnimation(this.skipIcon); if (this.onMobileYouTube) { this.hideButton(); } diff --git a/src/messageTypes.ts b/src/messageTypes.ts index 4989c741..1b2949ea 100644 --- a/src/messageTypes.ts +++ b/src/messageTypes.ts @@ -61,3 +61,8 @@ export type MessageResponse = | IsChannelWhitelistedResponse | Record; +export interface VoteResponse { + successType: number; + statusCode: number; + responseText: string; +} \ No newline at end of file diff --git a/src/popup.ts b/src/popup.ts index 14d22dd8..4d1d6743 100644 --- a/src/popup.ts +++ b/src/popup.ts @@ -1,10 +1,12 @@ import Config from "./config"; import Utils from "./utils"; -import { SponsorTime, SponsorHideType, CategoryActionType } from "./types"; +import { SponsorTime, SponsorHideType, CategoryActionType, ActionType } from "./types"; import { Message, MessageResponse, IsInfoFoundMessageResponse } from "./messageTypes"; import { showDonationLink } from "./utils/configUtils"; import { getCategoryActionType } from "./utils/categoryUtils"; +import { AnimationUtils } from "./utils/animationUtils"; +import { GenericUtils } from "./utils/genericUtils"; const utils = new Utils(); interface MessageListener { @@ -405,10 +407,15 @@ async function runThePopup(messageListener?: MessageListener): Promise { const textNode = document.createTextNode(utils.shortCategoryName(segmentTimes[i].category) + extraInfo); const segmentTimeFromToNode = document.createElement("div"); - segmentTimeFromToNode.innerText = utils.getFormattedTime(segmentTimes[i].segment[0], true) + + if (segmentTimes[i].actionType === ActionType.Full) { + segmentTimeFromToNode.innerText = chrome.i18n.getMessage("full"); + } else { + segmentTimeFromToNode.innerText = utils.getFormattedTime(segmentTimes[i].segment[0], true) + (getCategoryActionType(segmentTimes[i].category) !== CategoryActionType.POI ? " " + chrome.i18n.getMessage("to") + " " + utils.getFormattedTime(segmentTimes[i].segment[1], true) : ""); + } + segmentTimeFromToNode.style.margin = "5px"; sponsorTimeButton.appendChild(categoryColorCircle); @@ -444,7 +451,7 @@ async function runThePopup(messageListener?: MessageListener): Promise { uuidButton.src = chrome.runtime.getURL("icons/clipboard.svg"); uuidButton.addEventListener("click", () => { navigator.clipboard.writeText(UUID); - const stopAnimation = utils.applyLoadingAnimation(uuidButton, 0.3); + const stopAnimation = AnimationUtils.applyLoadingAnimation(uuidButton, 0.3); stopAnimation(); }); @@ -550,7 +557,7 @@ async function runThePopup(messageListener?: MessageListener): Promise { PageElements.sponsorTimesContributionsContainer.classList.remove("hidden"); } else { - PageElements.setUsernameStatus.innerText = utils.getErrorMessage(response.status, response.responseText); + PageElements.setUsernameStatus.innerText = GenericUtils.getErrorMessage(response.status, response.responseText); } }); @@ -591,7 +598,7 @@ async function runThePopup(messageListener?: MessageListener): Promise { //success (treat rate limits as a success) addVoteMessage(chrome.i18n.getMessage("voted"), UUID); } else if (response.successType == -1) { - addVoteMessage(utils.getErrorMessage(response.statusCode, response.responseText), UUID); + addVoteMessage(GenericUtils.getErrorMessage(response.statusCode, response.responseText), UUID); } } }); @@ -694,7 +701,7 @@ async function runThePopup(messageListener?: MessageListener): Promise { } function refreshSegments() { - const stopAnimation = utils.applyLoadingAnimation(PageElements.refreshSegmentsButton, 0.3); + const stopAnimation = AnimationUtils.applyLoadingAnimation(PageElements.refreshSegmentsButton, 0.3); messageHandler.query({ active: true, diff --git a/src/render/CategoryPill.tsx b/src/render/CategoryPill.tsx new file mode 100644 index 00000000..d3530c38 --- /dev/null +++ b/src/render/CategoryPill.tsx @@ -0,0 +1,98 @@ +import * as React from "react"; +import * as ReactDOM from "react-dom"; +import CategoryPillComponent, { CategoryPillState } from "../components/CategoryPillComponent"; +import { VoteResponse } from "../messageTypes"; +import { Category, SegmentUUID, SponsorTime } from "../types"; +import { GenericUtils } from "../utils/genericUtils"; + +export class CategoryPill { + container: HTMLElement; + ref: React.RefObject; + + unsavedState: CategoryPillState; + + mutationObserver?: MutationObserver; + + constructor() { + this.ref = React.createRef(); + } + + async attachToPage(onMobileYouTube: boolean, onInvidious: boolean, + vote: (type: number, UUID: SegmentUUID, category?: Category) => Promise): Promise { + const referenceNode = + await GenericUtils.wait(() => + // YouTube, Mobile YouTube, Invidious + document.querySelector(".ytd-video-primary-info-renderer.title, .slim-video-information-title, #player-container + .h-box > h1") as HTMLElement); + + if (referenceNode && !referenceNode.contains(this.container)) { + this.container = document.createElement('span'); + this.container.id = "categoryPill"; + this.container.style.display = "relative"; + + referenceNode.prepend(this.container); + referenceNode.style.display = "flex"; + + if (this.ref.current) { + this.unsavedState = this.ref.current.state; + } + + ReactDOM.render( + , + this.container + ); + + if (this.unsavedState) { + this.ref.current?.setState(this.unsavedState); + this.unsavedState = null; + } + + if (onMobileYouTube) { + if (this.mutationObserver) { + this.mutationObserver.disconnect(); + } + + this.mutationObserver = new MutationObserver(() => this.attachToPage(onMobileYouTube, onInvidious, vote)); + + this.mutationObserver.observe(referenceNode, { + childList: true, + subtree: true + }); + } + } + } + + close(): void { + ReactDOM.unmountComponentAtNode(this.container); + this.container.remove(); + } + + setVisibility(show: boolean): void { + const newState = { + show, + open: show ? this.ref.current?.state.open : false + }; + + if (this.ref.current) { + this.ref.current?.setState(newState); + } else { + this.unsavedState = newState; + } + } + + setSegment(segment: SponsorTime): void { + if (this.ref.current?.state?.segment !== segment) { + const newState = { + segment, + show: true, + open: false + }; + + if (this.ref.current) { + this.ref.current?.setState(newState); + } else { + this.unsavedState = newState; + } + } + + } +} \ No newline at end of file diff --git a/src/render/SkipNotice.tsx b/src/render/SkipNotice.tsx index 5113c2da..b3ea571c 100644 --- a/src/render/SkipNotice.tsx +++ b/src/render/SkipNotice.tsx @@ -4,9 +4,10 @@ import * as ReactDOM from "react-dom"; import Utils from "../utils"; const utils = new Utils(); -import SkipNoticeComponent, { SkipNoticeAction } from "../components/SkipNoticeComponent"; +import SkipNoticeComponent from "../components/SkipNoticeComponent"; import { SponsorTime, ContentContainer, NoticeVisbilityMode } from "../types"; import Config from "../config"; +import { SkipNoticeAction } from "../utils/noticeUtils"; class SkipNotice { segments: SponsorTime[]; diff --git a/src/types.ts b/src/types.ts index 1caf257c..cac7e340 100644 --- a/src/types.ts +++ b/src/types.ts @@ -59,7 +59,8 @@ export enum CategoryActionType { export enum ActionType { Skip = "skip", - Mute = "mute" + Mute = "mute", + Full = "full" } export const ActionTypes = [ActionType.Skip, ActionType.Mute]; diff --git a/src/utils.ts b/src/utils.ts index 7912c184..f7bf748c 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -3,6 +3,7 @@ import { CategorySelection, SponsorTime, FetchResponse, BackgroundScriptContaine import * as CompileConfig from "../config.json"; import { findValidElementFromSelector } from "./utils/pageUtils"; +import { GenericUtils } from "./utils/genericUtils"; export default class Utils { @@ -24,27 +25,8 @@ export default class Utils { this.backgroundScriptContainer = backgroundScriptContainer; } - /** Function that can be used to wait for a condition before returning. */ async wait(condition: () => T | false, timeout = 5000, check = 100): Promise { - return await new Promise((resolve, reject) => { - setTimeout(() => { - clearInterval(interval); - reject("TIMEOUT"); - }, timeout); - - const intervalCheck = () => { - const result = condition(); - if (result !== false) { - resolve(result); - clearInterval(interval); - } - }; - - const interval = setInterval(intervalCheck, check); - - //run the check once first, this speeds it up a lot - intervalCheck(); - }); + return GenericUtils.wait(condition, timeout, check); } containsPermission(permissions: chrome.permissions.Permissions): Promise { @@ -162,75 +144,6 @@ export default class Utils { }); } - /** - * Starts a spinning animation and returns a function to be called when it should be stopped - * The callback will be called when the animation is finished - * It waits until a full rotation is complete - */ - applyLoadingAnimation(element: HTMLElement, time: number, callback?: () => void): () => void { - element.style.animation = `rotate ${time}s 0s infinite`; - - return () => { - // Make the animation finite - element.style.animation = `rotate ${time}s`; - - // When the animation is over, hide the button - const animationEndListener = () => { - if (callback) callback(); - - element.style.animation = "none"; - - element.removeEventListener("animationend", animationEndListener); - }; - - element.addEventListener("animationend", animationEndListener); - } - } - - setupCustomHideAnimation(element: Element, container: Element, enabled = true, rightSlide = true): { hide: () => void, show: () => void } { - if (enabled) element.classList.add("autoHiding"); - element.classList.add("hidden"); - element.classList.add("animationDone"); - if (!rightSlide) element.classList.add("autoHideLeft"); - - let mouseEntered = false; - - return { - hide: () => { - mouseEntered = false; - if (element.classList.contains("autoHiding")) { - element.classList.add("hidden"); - } - }, - show: () => { - mouseEntered = true; - element.classList.remove("animationDone"); - - // Wait for next event loop - setTimeout(() => { - if (mouseEntered) element.classList.remove("hidden") - }, 10); - } - }; - } - - setupAutoHideAnimation(element: Element, container: Element, enabled = true, rightSlide = true): void { - const { hide, show } = this.setupCustomHideAnimation(element, container, enabled, rightSlide); - - container.addEventListener("mouseleave", () => hide()); - container.addEventListener("mouseenter", () => show()); - } - - enableAutoHideAnimation(element: Element): void { - element.classList.add("autoHiding"); - element.classList.add("hidden"); - } - - disableAutoHideAnimation(element: Element): void { - element.classList.remove("autoHiding"); - element.classList.remove("hidden"); - } - /** * Merges any overlapping timestamp ranges into single segments and returns them as a new array. */ @@ -362,29 +275,6 @@ export default class Utils { } } - /** - * Gets the error message in a nice string - * - * @param {int} statusCode - * @returns {string} errorMessage - */ - getErrorMessage(statusCode: number, responseText: string): string { - let errorMessage = ""; - const postFix = (responseText ? "\n\n" + responseText : ""); - - if([400, 429, 409, 502, 503, 0].includes(statusCode)) { - //treat them the same - if (statusCode == 503) statusCode = 502; - - errorMessage = chrome.i18n.getMessage(statusCode + "") + " " + chrome.i18n.getMessage("errorCode") + statusCode - + "\n\n" + chrome.i18n.getMessage("statusReminder"); - } else { - errorMessage = chrome.i18n.getMessage("connectionError") + statusCode; - } - - return errorMessage + postFix; - } - /** * Sends a request to a custom server * diff --git a/src/utils/animationUtils.ts b/src/utils/animationUtils.ts new file mode 100644 index 00000000..933e6446 --- /dev/null +++ b/src/utils/animationUtils.ts @@ -0,0 +1,78 @@ + /** + * Starts a spinning animation and returns a function to be called when it should be stopped + * The callback will be called when the animation is finished + * It waits until a full rotation is complete + */ +function applyLoadingAnimation(element: HTMLElement, time: number, callback?: () => void): () => Promise { + element.style.animation = `rotate ${time}s 0s infinite`; + + return async () => new Promise((resolve) => { + // Make the animation finite + element.style.animation = `rotate ${time}s`; + + // When the animation is over, hide the button + const animationEndListener = () => { + if (callback) callback(); + + element.style.animation = "none"; + + element.removeEventListener("animationend", animationEndListener); + + resolve(); + }; + + element.addEventListener("animationend", animationEndListener); + }); +} + +function setupCustomHideAnimation(element: Element, container: Element, enabled = true, rightSlide = true): { hide: () => void, show: () => void } { + if (enabled) element.classList.add("autoHiding"); + element.classList.add("hidden"); + element.classList.add("animationDone"); + if (!rightSlide) element.classList.add("autoHideLeft"); + + let mouseEntered = false; + + return { + hide: () => { + mouseEntered = false; + if (element.classList.contains("autoHiding")) { + element.classList.add("hidden"); + } + }, + show: () => { + mouseEntered = true; + element.classList.remove("animationDone"); + + // Wait for next event loop + setTimeout(() => { + if (mouseEntered) element.classList.remove("hidden") + }, 10); + } + }; +} + +function setupAutoHideAnimation(element: Element, container: Element, enabled = true, rightSlide = true): void { + const { hide, show } = this.setupCustomHideAnimation(element, container, enabled, rightSlide); + + container.addEventListener("mouseleave", () => hide()); + container.addEventListener("mouseenter", () => show()); +} + +function enableAutoHideAnimation(element: Element): void { + element.classList.add("autoHiding"); + element.classList.add("hidden"); +} + +function disableAutoHideAnimation(element: Element): void { + element.classList.remove("autoHiding"); + element.classList.remove("hidden"); +} + +export const AnimationUtils = { + applyLoadingAnimation, + setupAutoHideAnimation, + setupCustomHideAnimation, + enableAutoHideAnimation, + disableAutoHideAnimation +}; \ No newline at end of file diff --git a/src/utils/genericUtils.ts b/src/utils/genericUtils.ts new file mode 100644 index 00000000..b146e57a --- /dev/null +++ b/src/utils/genericUtils.ts @@ -0,0 +1,50 @@ +/** Function that can be used to wait for a condition before returning. */ +async function wait(condition: () => T | false, timeout = 5000, check = 100): Promise { + return await new Promise((resolve, reject) => { + setTimeout(() => { + clearInterval(interval); + reject("TIMEOUT"); + }, timeout); + + const intervalCheck = () => { + const result = condition(); + if (result) { + resolve(result); + clearInterval(interval); + } + }; + + const interval = setInterval(intervalCheck, check); + + //run the check once first, this speeds it up a lot + intervalCheck(); + }); +} + +/** + * Gets the error message in a nice string + * + * @param {int} statusCode + * @returns {string} errorMessage + */ +function getErrorMessage(statusCode: number, responseText: string): string { + let errorMessage = ""; + const postFix = (responseText ? "\n\n" + responseText : ""); + + if([400, 429, 409, 502, 503, 0].includes(statusCode)) { + //treat them the same + if (statusCode == 503) statusCode = 502; + + errorMessage = chrome.i18n.getMessage(statusCode + "") + " " + chrome.i18n.getMessage("errorCode") + statusCode + + "\n\n" + chrome.i18n.getMessage("statusReminder"); + } else { + errorMessage = chrome.i18n.getMessage("connectionError") + statusCode; + } + + return errorMessage + postFix; +} + +export const GenericUtils = { + wait, + getErrorMessage +} \ No newline at end of file diff --git a/src/utils/noticeUtils.ts b/src/utils/noticeUtils.ts new file mode 100644 index 00000000..5d77063b --- /dev/null +++ b/src/utils/noticeUtils.ts @@ -0,0 +1,21 @@ +import Config from "../config"; +import { SponsorTime } from "../types"; + +export enum SkipNoticeAction { + None, + Upvote, + Downvote, + CategoryVote, + CopyDownvote, + Unskip +} + +export function downvoteButtonColor(segments: SponsorTime[], actionState: SkipNoticeAction, downvoteType: SkipNoticeAction): string { + // Also used for "Copy and Downvote" + if (segments?.length > 1) { + return (actionState === downvoteType) ? Config.config.colorPalette.red : Config.config.colorPalette.white; + } else { + // You dont have segment selectors so the lockbutton needs to be colored and cannot be selected. + return Config.config.isVip && segments[0].locked === 1 ? Config.config.colorPalette.locked : Config.config.colorPalette.white; + } +} \ No newline at end of file