import * as React from "react"; import * as CompileConfig from "../../config.json"; import Config from "../config" import { Category, ContentContainer, SponsorTime, NoticeVisbilityMode, ActionType, SponsorSourceType, SegmentUUID } from "../types"; import NoticeComponent from "./NoticeComponent"; import NoticeTextSelectionComponent from "./NoticeTextSectionComponent"; import Utils from "../utils"; const utils = new Utils(); import { getSkippingText, getUpcomingText } 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"; import { downvoteButtonColor, SkipNoticeAction } from "../utils/noticeUtils"; import { generateUserID } from "../../maze-utils/src/setup"; import { keybindToString } from "../../maze-utils/src/config"; import { getFormattedTime } from "../../maze-utils/src/formating"; import { getCurrentTime, getVideo } from "../../maze-utils/src/video"; enum SkipButtonState { Undo, // Unskip Redo, // Reskip Start // Skip } export interface SkipNoticeProps { segments: SponsorTime[]; autoSkip: boolean; startReskip?: boolean; upcomingNotice?: boolean; // Contains functions and variables from the content script needed by the skip notice contentContainer: ContentContainer; closeListener: () => void; showKeybindHint?: boolean; smaller: boolean; fadeIn: boolean; maxCountdownTime?: number; componentDidMount?: () => void; unskipTime?: number; } export interface SkipNoticeState { noticeTitle?: string; messages?: string[]; messageOnClick?: (event: React.MouseEvent) => unknown; countdownTime?: number; maxCountdownTime?: () => number; countdownText?: string; skipButtonStates?: SkipButtonState[]; skipButtonCallbacks?: Array<(buttonIndex: number, index: number, forceSeek: boolean) => void>; showSkipButton?: boolean[]; editing?: boolean; choosingCategory?: boolean; thanksForVotingText?: string; //null until the voting buttons should be hidden actionState?: SkipNoticeAction; showKeybindHint?: boolean; smaller?: boolean; voted?: SkipNoticeAction[]; copied?: SkipNoticeAction[]; } class SkipNoticeComponent extends React.Component { segments: SponsorTime[]; autoSkip: boolean; // Contains functions and variables from the content script needed by the skip notice contentContainer: ContentContainer; amountOfPreviousNotices: number; showInSecondSlot: boolean; idSuffix: string; noticeRef: React.MutableRefObject; categoryOptionRef: React.RefObject; selectedColor: string; unselectedColor: string; lockedColor: string; // Used to update on config change configListener: () => void; constructor(props: SkipNoticeProps) { super(props); this.noticeRef = React.createRef(); this.categoryOptionRef = React.createRef(); this.segments = props.segments; this.autoSkip = props.autoSkip; this.contentContainer = props.contentContainer; const noticeTitle = !this.props.upcomingNotice ? getSkippingText(this.segments, this.props.autoSkip) : getUpcomingText(this.segments); const previousSkipNotices = document.querySelectorAll(".sponsorSkipNoticeParent:not(.sponsorSkipUpcomingNotice)"); this.amountOfPreviousNotices = previousSkipNotices.length; // If there is at least one already in the first slot this.showInSecondSlot = previousSkipNotices.length > 0 && [...previousSkipNotices].some(notice => !notice.classList.contains("secondSkipNotice")); // Sort segments if (this.segments.length > 1) { this.segments.sort((a, b) => a.segment[0] - b.segment[0]); } // This is the suffix added at the end of every id for (const segment of this.segments) { this.idSuffix += segment.UUID; } this.idSuffix += this.amountOfPreviousNotices; this.selectedColor = Config.config.colorPalette.red; this.unselectedColor = Config.config.colorPalette.white; this.lockedColor = Config.config.colorPalette.locked; const isMuteSegment = this.segments[0].actionType === ActionType.Mute; const maxCountdownTime = props.maxCountdownTime ? () => props.maxCountdownTime : (isMuteSegment ? this.getFullDurationCountdown(0) : () => Config.config.skipNoticeDuration); const defaultSkipButtonState = this.props.startReskip ? SkipButtonState.Redo : SkipButtonState.Undo; const skipButtonStates = [defaultSkipButtonState, isMuteSegment ? SkipButtonState.Start : defaultSkipButtonState]; const defaultSkipButtonCallback = this.props.startReskip ? this.reskip.bind(this) : this.unskip.bind(this); const skipButtonCallbacks = [defaultSkipButtonCallback, isMuteSegment ? this.reskip.bind(this) : defaultSkipButtonCallback]; // Setup state this.state = { noticeTitle, messages: [], messageOnClick: null, //the countdown until this notice closes maxCountdownTime, countdownTime: maxCountdownTime(), countdownText: null, skipButtonStates, skipButtonCallbacks, showSkipButton: [true, true], editing: false, choosingCategory: false, thanksForVotingText: null, actionState: SkipNoticeAction.None, showKeybindHint: this.props.showKeybindHint ?? true, smaller: this.props.smaller ?? false, // Keep track of what segment the user interacted with. voted: new Array(this.props.segments.length).fill(SkipNoticeAction.None), copied: new Array(this.props.segments.length).fill(SkipNoticeAction.None), } if (!this.autoSkip) { // Assume manual skip is only skipping 1 submission Object.assign(this.state, this.getUnskippedModeInfo(null, 0, SkipButtonState.Start)); } } render(): React.ReactElement { const noticeStyle: React.CSSProperties = { } if (this.contentContainer().onMobileYouTube) { noticeStyle.bottom = "4em"; noticeStyle.transform = "scale(0.8) translate(10%, 10%)"; } const firstColumn = this.getSkipButton(0); return ( = NoticeVisbilityMode.FadedForAll || (Config.config.noticeVisibilityMode >= NoticeVisbilityMode.FadedForAutoSkip && this.autoSkip)} timed={true} maxCountdownTime={this.state.maxCountdownTime} style={noticeStyle} biggerCloseButton={this.contentContainer().onMobileYouTube} ref={this.noticeRef} closeListener={() => this.closeListener()} smaller={this.state.smaller} logoFill={Config.config.barTypes[this.segments[0].category].color} limitWidth={true} firstColumn={firstColumn} dontPauseCountdown={!!this.props.upcomingNotice} bottomRow={[...this.getMessageBoxes(), ...this.getBottomRow() ]} extraClass={this.props.upcomingNotice ? "sponsorSkipUpcomingNotice" : ""} onMouseEnter={() => this.onMouseEnter() } > ); } componentDidMount(): void { if (this.props.componentDidMount) { this.props.componentDidMount(); } } getBottomRow(): JSX.Element[] { return [ /* Bottom Row */ ( {/* Vote Button Container */} {!this.state.thanksForVotingText ? {/* Upvote Button */}
this.prepAction(SkipNoticeAction.Upvote)}>
{/* Report Button */}
this.prepAction(SkipNoticeAction.Downvote)}>
{/* Copy and Downvote Button */}
this.openEditingOptions()}>
: {/* Submitted string */} {this.state.thanksForVotingText} {/* Continue Voting Button */} } {/* Unskip/Skip Button */} {!this.props.smaller || this.segments[0].actionType === ActionType.Mute ? this.getSkipButton(1) : null} {/* Never show button */} {!this.autoSkip || this.props.startReskip ? "" : } ), /* Edit Segments Row */ (this.state.editing && !this.state.thanksForVotingText && !(this.state.choosingCategory || this.state.actionState === SkipNoticeAction.CopyDownvote) && {/* Copy Segment */} {/* Category vote */} ), /* Category Chooser Row */ (this.state.choosingCategory && !this.state.thanksForVotingText && {/* Category Selector */} {/* Submit Button */} {this.segments.length === 1 && } ), /* Segment Chooser Row */ (this.state.actionState !== SkipNoticeAction.None && this.segments.length > 1 && !this.state.thanksForVotingText && {this.getSubmissionChooser()} ) ]; } getSkipButton(buttonIndex: number): JSX.Element { if (this.state.showSkipButton[buttonIndex] && (this.segments.length > 1 || this.segments[0].actionType !== ActionType.Poi || this.props.unskipTime)) { const forceSeek = buttonIndex === 1 && this.segments[0].actionType === ActionType.Mute; const style: React.CSSProperties = { marginLeft: "4px", color: ([SkipNoticeAction.Unskip0, SkipNoticeAction.Unskip1].includes(this.state.actionState)) ? this.selectedColor : this.unselectedColor }; if (this.contentContainer().onMobileYouTube) { style.padding = "20px"; style.minWidth = "100px"; } const showSkipButton = (buttonIndex !== 0 || this.props.smaller || this.segments[0].actionType === ActionType.Mute) && !this.props.upcomingNotice; return ( ); } return null; } getSubmissionChooser(): JSX.Element[] { const elements: JSX.Element[] = []; for (let i = 0; i < this.segments.length; i++) { elements.push( ); } return elements; } getSubmissionChooserOpacity(index: number): number { const isUpvote = this.state.actionState === SkipNoticeAction.Upvote; const isDownvote = this.state.actionState == SkipNoticeAction.Downvote; const isCopyDownvote = this.state.actionState == SkipNoticeAction.CopyDownvote; const shouldBeGray: boolean = (isUpvote && this.state.voted[index] == SkipNoticeAction.Upvote) || (isDownvote && this.state.voted[index] == SkipNoticeAction.Downvote) || (isCopyDownvote && this.state.copied[index] == SkipNoticeAction.CopyDownvote); return shouldBeGray ? 0.35 : 1; } getSubmissionChooserColor(index: number): string { const isDownvote = this.state.actionState == SkipNoticeAction.Downvote; const isCopyDownvote = this.state.actionState == SkipNoticeAction.CopyDownvote; const shouldWarnUser = Config.config.isVip && (isDownvote || isCopyDownvote) && this.segments[index].locked === 1; return shouldWarnUser ? this.lockedColor : this.unselectedColor; } onMouseEnter(): void { if (this.state.smaller && !this.props.upcomingNotice) { this.setState({ smaller: false }); } } getMessageBoxes(): JSX.Element[] { if (this.state.messages.length === 0) { // Add a spacer if there is no text return [ ]; } const elements: JSX.Element[] = []; for (let i = 0; i < this.state.messages.length; i++) { elements.push( ) } return elements; } prepAction(action: SkipNoticeAction): void { if (this.segments.length === 1) { this.performAction(0, action); } else { if (this.state.smaller) { this.setState({ smaller: false }); this.noticeRef.current.fadedMouseEnter(); this.noticeRef.current.resetCountdown(); } switch (action ?? this.state.actionState) { case SkipNoticeAction.None: this.resetStateToStart(); break; case SkipNoticeAction.Upvote: this.resetStateToStart(SkipNoticeAction.Upvote); break; case SkipNoticeAction.Downvote: this.resetStateToStart(SkipNoticeAction.Downvote); break; case SkipNoticeAction.CategoryVote: this.resetStateToStart(SkipNoticeAction.CategoryVote, true, true); break; case SkipNoticeAction.CopyDownvote: this.resetStateToStart(SkipNoticeAction.CopyDownvote, true); break; case SkipNoticeAction.Unskip0: this.resetStateToStart(SkipNoticeAction.Unskip0); break; case SkipNoticeAction.Unskip1: this.resetStateToStart(SkipNoticeAction.Unskip1); break; } } } /** * Performs the action from the current state * * @param index */ performAction(index: number, action?: SkipNoticeAction): void { switch (action ?? this.state.actionState) { case SkipNoticeAction.None: this.noAction(index); break; case SkipNoticeAction.Upvote: this.upvote(index); break; case SkipNoticeAction.Downvote: this.downvote(index); break; case SkipNoticeAction.CategoryVote: this.categoryVote(index); break; case SkipNoticeAction.CopyDownvote: this.copyDownvote(index); break; case SkipNoticeAction.Unskip0: this.unskipAction(0, index, false); break; case SkipNoticeAction.Unskip1: this.unskipAction(1, index, true); break; default: this.resetStateToStart(); break; } } noAction(index: number): void { const voted = this.state.voted; voted[index] = SkipNoticeAction.None; this.setState({ voted }); } upvote(index: number): void { if (this.segments.length === 1) this.resetStateToStart(); this.contentContainer().vote(1, this.segments[index].UUID, undefined, this); } downvote(index: number): void { if (this.segments.length === 1) this.resetStateToStart(); this.contentContainer().vote(0, this.segments[index].UUID, undefined, this); } categoryVote(index: number): void { this.contentContainer().vote(undefined, this.segments[index].UUID, this.categoryOptionRef.current.value as Category, this) } copyDownvote(index: number): void { const sponsorVideoID = this.props.contentContainer().sponsorVideoID; const sponsorTimesSubmitting : SponsorTime = { segment: this.segments[index].segment, UUID: generateUserID() as SegmentUUID, category: this.segments[index].category, actionType: this.segments[index].actionType, source: SponsorSourceType.Local }; const segmentTimes = Config.local.unsubmittedSegments[sponsorVideoID] || []; segmentTimes.push(sponsorTimesSubmitting); Config.local.unsubmittedSegments[sponsorVideoID] = segmentTimes; Config.forceLocalUpdate("unsubmittedSegments"); this.props.contentContainer().sponsorTimesSubmitting.push(sponsorTimesSubmitting); this.props.contentContainer().updatePreviewBar(); this.props.contentContainer().resetSponsorSubmissionNotice(); this.props.contentContainer().updateEditButtonsOnPlayer(); this.contentContainer().vote(0, this.segments[index].UUID, undefined, this); const copied = this.state.copied; copied[index] = SkipNoticeAction.CopyDownvote; this.setState({ copied }); } unskipAction(buttonIndex: number, index: number, forceSeek: boolean): void { this.state.skipButtonCallbacks[buttonIndex](buttonIndex, index, forceSeek); } openEditingOptions(): void { this.resetStateToStart(undefined, true); } getCategoryOptions(): React.ReactElement[] { const elements = []; const categories = (CompileConfig.categoryList.filter((cat => CompileConfig.categorySupport[cat].includes(ActionType.Skip)))) as Category[]; for (const category of categories) { elements.push( ); } return elements; } getCategoryNameClass(category: string): string { return this.props.contentContainer().lockedCategories.includes(category) ? "sponsorBlockLockedColor" : "" } unskip(buttonIndex: number, index: number, forceSeek: boolean): void { this.contentContainer().unskipSponsorTime(this.segments[index], this.props.unskipTime, forceSeek); this.unskippedMode(buttonIndex, index, SkipButtonState.Redo); } reskip(buttonIndex: number, index: number, forceSeek: boolean): void { this.contentContainer().reskipSponsorTime(this.segments[index], forceSeek); const skipButtonStates = this.state.skipButtonStates; skipButtonStates[buttonIndex] = SkipButtonState.Undo; const skipButtonCallbacks = this.state.skipButtonCallbacks; skipButtonCallbacks[buttonIndex] = this.unskip.bind(this); const newState: SkipNoticeState = { skipButtonStates, skipButtonCallbacks, maxCountdownTime: () => Config.config.skipNoticeDuration, countdownTime: Config.config.skipNoticeDuration }; //reset countdown this.setState(newState, () => { this.noticeRef.current.resetCountdown(); }); } /** Sets up notice to be not skipped yet */ unskippedMode(buttonIndex: number, index: number, skipButtonState: SkipButtonState): void { //setup new callback and reset countdown this.setState(this.getUnskippedModeInfo(buttonIndex, index, skipButtonState), () => { this.noticeRef.current.resetCountdown(); }); } getUnskippedModeInfo(buttonIndex: number, index: number, skipButtonState: SkipButtonState): SkipNoticeState { const changeCountdown = this.segments[index].actionType !== ActionType.Poi; const maxCountdownTime = changeCountdown ? this.getFullDurationCountdown(index) : this.state.maxCountdownTime; const skipButtonStates = this.state.skipButtonStates; const skipButtonCallbacks = this.state.skipButtonCallbacks; if (buttonIndex === null) { for (let i = 0; i < this.segments.length; i++) { skipButtonStates[i] = skipButtonState; skipButtonCallbacks[i] = this.reskip.bind(this); } } else { skipButtonStates[buttonIndex] = skipButtonState; skipButtonCallbacks[buttonIndex] = this.reskip.bind(this); if (buttonIndex === 1) { // Trigger both to move at once skipButtonStates[0] = SkipButtonState.Redo; skipButtonCallbacks[0] = this.reskip.bind(this); } } return { skipButtonStates, skipButtonCallbacks, // change max duration to however much of the sponsor is left maxCountdownTime, countdownTime: maxCountdownTime(), showSkipButton: buttonIndex === 1 ? [true, true] : this.state.showSkipButton } as SkipNoticeState; } getFullDurationCountdown(index: number): () => number { return () => { const sponsorTime = this.segments[index]; const duration = Math.round((sponsorTime.segment[1] - getCurrentTime()) * (1 / getVideo().playbackRate)); return Math.max(duration, Config.config.skipNoticeDuration); }; } afterVote(segment: SponsorTime, type: number, category: Category): void { const index = utils.getSponsorIndexFromUUID(this.segments, segment.UUID); const wikiLinkText = CompileConfig.wikiLinks[segment.category]; const voted = this.state.voted; switch (type) { case 0: this.clearConfigListener(); this.setNoticeInfoMessageWithOnClick(() => window.open(wikiLinkText), chrome.i18n.getMessage("OpenCategoryWikiPage")); voted[index] = SkipNoticeAction.Downvote; break; case 1: voted[index] = SkipNoticeAction.Upvote; break; case 20: voted[index] = SkipNoticeAction.None; break; } this.setState({ voted }); this.addVoteButtonInfo(chrome.i18n.getMessage("voted")); if (segment && category) { // This is the segment inside the skip notice this.segments[index].category = category; } } setNoticeInfoMessageWithOnClick(onClick: (event: React.MouseEvent) => unknown, ...messages: string[]): void { this.setState({ messages, messageOnClick: (event) => onClick(event) }); } setNoticeInfoMessage(...messages: string[]): void { this.setState({ messages }); } addVoteButtonInfo(message: string): void { this.setState({ thanksForVotingText: message }); } resetVoteButtonInfo(): void { this.setState({ thanksForVotingText: null }); } closeListener(): void { this.clearConfigListener(); this.props.closeListener(); } clearConfigListener(): void { if (this.configListener) { Config.configSyncListeners.splice(Config.configSyncListeners.indexOf(this.configListener), 1); this.configListener = null; } } unmutedListener(time: number): void { if (this.props.segments.length === 1 && this.props.segments[0].actionType === ActionType.Mute && time >= this.props.segments[0].segment[1]) { this.setState({ showSkipButton: [false, true] }); } } resetStateToStart(actionState: SkipNoticeAction = SkipNoticeAction.None, editing = false, choosingCategory = false): void { this.setState({ actionState: actionState, editing: editing, choosingCategory: choosingCategory, thanksForVotingText: null, messages: [] }); } private getSkipButtonText(buttonIndex: number, forceType?: ActionType): string { switch (this.state.skipButtonStates[buttonIndex]) { case SkipButtonState.Undo: return this.getUndoText(forceType); case SkipButtonState.Redo: return this.getRedoText(forceType); case SkipButtonState.Start: return this.getStartText(forceType); } } private getUndoText(forceType?: ActionType): string { const actionType = forceType || this.segments[0].actionType; switch (actionType) { case ActionType.Mute: { return chrome.i18n.getMessage("unmute"); } case ActionType.Skip: default: { return chrome.i18n.getMessage("unskip"); } } } private getRedoText(forceType?: ActionType): string { const actionType = forceType || this.segments[0].actionType; switch (actionType) { case ActionType.Mute: { return chrome.i18n.getMessage("mute"); } case ActionType.Skip: default: { return chrome.i18n.getMessage("reskip"); } } } private getStartText(forceType?: ActionType): string { const actionType = forceType || this.segments[0].actionType; switch (actionType) { case ActionType.Mute: { return chrome.i18n.getMessage("mute"); } case ActionType.Skip: default: { return chrome.i18n.getMessage("skip"); } } } } export default SkipNoticeComponent;