import * as React from "react"; import * as CompileConfig from "../../config.json"; import Config from "../config"; import { ActionType, Category, ChannelIDStatus, ContentContainer, SponsorTime } from "../types"; import Utils from "../utils"; import SubmissionNoticeComponent from "./SubmissionNoticeComponent"; import { RectangleTooltip } from "../render/RectangleTooltip"; import SelectorComponent, { SelectorOption } from "./SelectorComponent"; import { GenericUtils } from "../utils/genericUtils"; import { noRefreshFetchingChaptersAllowed } from "../utils/licenseKey"; import { DEFAULT_CATEGORY } from "../utils/categoryUtils"; const utils = new Utils(); export interface SponsorTimeEditProps { index: number, idSuffix: string, // Contains functions and variables from the content script needed by the skip notice contentContainer: ContentContainer, submissionNotice: SubmissionNoticeComponent; categoryList?: Category[]; categoryChangeListener?: (index: number, category: Category) => void; children?: React.ReactNode; } export interface SponsorTimeEditState { editing: boolean; sponsorTimeEdits: [string, string]; selectedCategory: Category; description: string; suggestedNames: SelectorOption[]; chapterNameSelectorOpen: boolean; chapterNameSelectorHovering: boolean; } const categoryNamesGrams: string[] = [].concat(...CompileConfig.categoryList.filter((name) => name !== "chapter") .map((name) => chrome.i18n.getMessage("category_" + name).split(/\/|\s|-/))); class SponsorTimeEditComponent extends React.Component { idSuffix: string; categoryOptionRef: React.RefObject; actionTypeOptionRef: React.RefObject; descriptionOptionRef: React.RefObject; configUpdateListener: () => void; previousSkipType: ActionType; // Used when selecting POI or Full timesBeforeChanging: number[] = []; fullVideoWarningShown = false; categoryNameWarningShown = false; // For description auto-complete fetchingSuggestions: boolean; constructor(props: SponsorTimeEditProps) { super(props); this.categoryOptionRef = React.createRef(); this.actionTypeOptionRef = React.createRef(); this.descriptionOptionRef = React.createRef(); this.idSuffix = this.props.idSuffix; this.previousSkipType = ActionType.Skip; const sponsorTime = this.props.contentContainer().sponsorTimesSubmitting[this.props.index]; this.state = { editing: false, sponsorTimeEdits: [null, null], selectedCategory: DEFAULT_CATEGORY as Category, description: sponsorTime.description || "", suggestedNames: [], chapterNameSelectorOpen: false, chapterNameSelectorHovering: false }; } componentDidMount(): void { // Prevent inputs from triggering key events document.getElementById("sponsorTimeEditContainer" + this.idSuffix).addEventListener('keydown', function (event) { event.stopPropagation(); }); // Prevent scrolling while changing times document.getElementById("sponsorTimesContainer" + this.idSuffix).addEventListener('wheel', function (event) { event.preventDefault(); }, {passive: false}); // Add as a config listener if (!this.configUpdateListener) { this.configUpdateListener = () => this.configUpdate(); Config.configSyncListeners.push(this.configUpdate.bind(this)); } this.checkToShowFullVideoWarning(); } componentWillUnmount(): void { if (this.configUpdateListener) { Config.configSyncListeners.splice(Config.configSyncListeners.indexOf(this.configUpdate.bind(this)), 1); } } render(): React.ReactElement { this.checkToShowFullVideoWarning(); this.checkToShowChapterWarning(); const style: React.CSSProperties = { textAlign: "center" }; if (this.props.index != 0) { style.marginTop = "15px"; } // Create time display let timeDisplay: JSX.Element; const timeDisplayStyle: React.CSSProperties = {}; const sponsorTime = this.props.contentContainer().sponsorTimesSubmitting[this.props.index]; const segment = sponsorTime.segment; if (sponsorTime?.actionType === ActionType.Full) timeDisplayStyle.display = "none"; if (this.state.editing) { timeDisplay = (
this.setTimeToNow(0)}> {chrome.i18n.getMessage("bracketNow")} this.handleOnChange(0, e, sponsorTime, e.target.value)} onWheel={(e) => this.changeTimesWhenScrolling(0, e, sponsorTime)}> {sponsorTime.actionType !== ActionType.Poi ? ( {" " + chrome.i18n.getMessage("to") + " "} this.handleOnChange(1, e, sponsorTime, e.target.value)} onWheel={(e) => this.changeTimesWhenScrolling(1, e, sponsorTime)}> this.setTimeToNow(1)}> {chrome.i18n.getMessage("bracketNow")} this.setTimeToEnd()}> {chrome.i18n.getMessage("bracketEnd")} ): ""}
); } else { timeDisplay = (
{GenericUtils.getFormattedTime(segment[0], true) + ((!isNaN(segment[1]) && sponsorTime.actionType !== ActionType.Poi) ? " " + chrome.i18n.getMessage("to") + " " + GenericUtils.getFormattedTime(segment[1], true) : "")}
); } return (
{timeDisplay} {/* Category */}
{/* open in new tab */}
{/* Action Type */} {CompileConfig.categorySupport[sponsorTime.category] && (CompileConfig.categorySupport[sponsorTime.category]?.length > 1 || CompileConfig.categorySupport[sponsorTime.category]?.[0] === ActionType.Full) ? (
): ""} {/* Chapter Name */} {sponsorTime.actionType === ActionType.Chapter ? (
this.setState({chapterNameSelectorOpen: false})}> e.stopPropagation()} onChange={(e) => this.descriptionUpdate(e.target.value)} onFocus={() => this.setState({chapterNameSelectorOpen: true})}> {this.state.description && (this.state.chapterNameSelectorOpen || this.state.chapterNameSelectorHovering) && this.setState({chapterNameSelectorHovering: true})} onMouseLeave={() => this.setState({chapterNameSelectorHovering: false})} onChange={(v) => this.descriptionUpdate(v)} /> }
): ""}
{/* Editing Tools */} {chrome.i18n.getMessage("delete")} {(!isNaN(segment[1]) && ![ActionType.Poi, ActionType.Full].includes(sponsorTime.actionType)) && sponsorTime.actionType !== ActionType.Chapter ? ( this.previewTime(e.ctrlKey, e.shiftKey)}> {chrome.i18n.getMessage("preview")} ): ""} {(!isNaN(segment[1]) && sponsorTime.actionType != ActionType.Full) ? ( {chrome.i18n.getMessage("inspect")} ): ""} {(!isNaN(segment[1]) && ![ActionType.Poi, ActionType.Full].includes(sponsorTime.actionType)) && sponsorTime.actionType === ActionType.Chapter ? ( this.previewTime(e.ctrlKey, e.shiftKey)}> {chrome.i18n.getMessage("End")} ): ""} {(!isNaN(segment[1]) && sponsorTime.actionType != ActionType.Full) ? ( {this.state.editing ? chrome.i18n.getMessage("save") : chrome.i18n.getMessage("edit")} ): ""}
); } handleOnChange(index: number, e: React.ChangeEvent, sponsorTime: SponsorTime, targetValue: string): void { const sponsorTimeEdits = this.state.sponsorTimeEdits; // check if change is small engough to show tooltip const before = GenericUtils.getFormattedTimeToSeconds(sponsorTimeEdits[index]); const after = GenericUtils.getFormattedTimeToSeconds(targetValue); const difference = Math.abs(before - after); if (0 < difference && difference < 0.5) this.showScrollToEditToolTip(); sponsorTimeEdits[index] = targetValue; if (index === 0 && sponsorTime.actionType === ActionType.Poi) sponsorTimeEdits[1] = targetValue; this.setState({sponsorTimeEdits}, () => this.saveEditTimes()); } changeTimesWhenScrolling(index: number, e: React.WheelEvent, sponsorTime: SponsorTime): void { let step = 0; // shift + ctrl = 1 // ctrl = 0.1 // default = 0.01 // shift = 0.001 if (e.shiftKey) { step = (e.ctrlKey) ? 1 : 0.001; } else { step = (e.ctrlKey) ? 0.1 : 0.01; } const sponsorTimeEdits = this.state.sponsorTimeEdits; let timeAsNumber = GenericUtils.getFormattedTimeToSeconds(this.state.sponsorTimeEdits[index]); if (timeAsNumber !== null && e.deltaY != 0) { if (e.deltaY < 0) { timeAsNumber += step; } else if (timeAsNumber >= step) { timeAsNumber -= step; } else { timeAsNumber = 0; } sponsorTimeEdits[index] = GenericUtils.getFormattedTime(timeAsNumber, true); if (sponsorTime.actionType === ActionType.Poi) sponsorTimeEdits[1] = sponsorTimeEdits[0]; this.setState({sponsorTimeEdits}); this.saveEditTimes(); } } showScrollToEditToolTip(): void { if (!Config.config.scrollToEditTimeUpdate && document.getElementById("sponsorRectangleTooltip" + "sponsorTimesContainer" + this.idSuffix) === null) { this.showToolTip(chrome.i18n.getMessage("SponsorTimeEditScrollNewFeature"), "scrollToEdit", () => { Config.config.scrollToEditTimeUpdate = true }); } } showToolTip(text: string, id: string, buttonFunction?: () => void): boolean { const element = document.getElementById("sponsorTimesContainer" + this.idSuffix); if (element) { const htmlId = `sponsorRectangleTooltip${id + this.idSuffix}`; if (!document.getElementById(htmlId)) { new RectangleTooltip({ text, referenceNode: element.parentElement, prependElement: element, timeout: 15, bottomOffset: 0 + "px", leftOffset: -318 + "px", backgroundColor: "rgba(28, 28, 28, 1.0)", htmlId, 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"), "fullVideoWarning")) { this.fullVideoWarningShown = true; } } } checkToShowChapterWarning(): void { const sponsorTime = this.props.contentContainer().sponsorTimesSubmitting[this.props.index]; if (sponsorTime.actionType === ActionType.Chapter && sponsorTime.description && !this.categoryNameWarningShown && categoryNamesGrams.some( (category) => sponsorTime.description.toLowerCase().includes(category.toLowerCase()))) { if (this.showToolTip(chrome.i18n.getMessage("chapterNameTooltipWarning"), "chapterWarning")) { this.categoryNameWarningShown = true; } } } getCategoryOptions(): React.ReactElement[] { const elements = [( )]; for (const category of (this.props.categoryList ?? CompileConfig.categoryList)) { // If permission not loaded, treat it like we have permission except chapter const defaultBlockCategories = ["chapter"]; const permission = (Config.config.showCategoryWithoutPermission || Config.config.permissions[category as Category]) && (category !== "chapter" || noRefreshFetchingChaptersAllowed()); if ((defaultBlockCategories.includes(category) || (permission !== undefined && !Config.config.showCategoryWithoutPermission)) && !permission) continue; elements.push( ); } return elements; } getCategoryLockedClass(category: string): string { return this.props.contentContainer().lockedCategories.includes(category) ? "sponsorBlockLockedColor" : ""; } categorySelectionChange(event: React.ChangeEvent): void { const chosenCategory = event.target.value as Category; // See if show more categories was pressed if (chosenCategory !== DEFAULT_CATEGORY && !Config.config.categorySelections.some((category) => category.name === chosenCategory)) { event.target.value = DEFAULT_CATEGORY; // Alert that they have to enable this category first if (confirm(chrome.i18n.getMessage("enableThisCategoryFirst") .replace("{0}", chrome.i18n.getMessage("category_" + chosenCategory)))) { // Open options page chrome.runtime.sendMessage({message: "openConfig", hash: "behavior"}); } return; } const sponsorTime = this.props.contentContainer().sponsorTimesSubmitting[this.props.index]; this.handleReplacingLostTimes(chosenCategory, sponsorTime.actionType, sponsorTime); this.saveEditTimes(); if (this.props.categoryChangeListener) { this.props.categoryChangeListener(this.props.index, chosenCategory); } } actionTypeSelectionChange(event: React.ChangeEvent): void { const sponsorTime = this.props.contentContainer().sponsorTimesSubmitting[this.props.index]; this.handleReplacingLostTimes(sponsorTime.category, event.target.value as ActionType, sponsorTime); this.saveEditTimes(); } private handleReplacingLostTimes(category: Category, actionType: ActionType, segment: SponsorTime): void { if (CompileConfig.categorySupport[category]?.includes(ActionType.Poi)) { if (this.previousSkipType !== ActionType.Poi) { this.timesBeforeChanging = [null, segment.segment[1]]; } this.setTimeTo(1, null); this.props.contentContainer().updateEditButtonsOnPlayer(); if (this.props.contentContainer().sponsorTimesSubmitting .some((segment, i) => segment.category === category && i !== this.props.index)) { alert(chrome.i18n.getMessage("poiOnlyOneSegment")); } this.previousSkipType = ActionType.Poi; } else if (CompileConfig.categorySupport[category]?.length === 1 && CompileConfig.categorySupport[category]?.[0] === ActionType.Full) { if (this.previousSkipType !== ActionType.Full) { this.timesBeforeChanging = [...segment.segment]; } this.previousSkipType = ActionType.Full; } else if ((category === "chooseACategory" || ((CompileConfig.categorySupport[category]?.includes(ActionType.Skip) || CompileConfig.categorySupport[category]?.includes(ActionType.Chapter)) && ![ActionType.Poi, ActionType.Full].includes(this.getNextActionType(category, actionType)))) && this.previousSkipType !== ActionType.Skip) { if (this.timesBeforeChanging[0]) { this.setTimeTo(0, this.timesBeforeChanging[0]); } if (this.timesBeforeChanging[1]) { this.setTimeTo(1, this.timesBeforeChanging[1]); } this.previousSkipType = ActionType.Skip; } } getActionTypeOptions(sponsorTime: SponsorTime): React.ReactElement[] { const elements = []; for (const actionType of CompileConfig.categorySupport[sponsorTime.category]) { elements.push( ); } return elements; } setTimeToNow(index: number): void { this.setTimeTo(index, this.props.contentContainer().getRealCurrentTime()); } setTimeToEnd(): void { this.setTimeTo(1, this.props.contentContainer().v.duration); } /** * @param index * @param time If null, will set time to the first index's time */ setTimeTo(index: number, time: number): void { const sponsorTime = this.props.contentContainer().sponsorTimesSubmitting[this.props.index]; if (time === null) time = sponsorTime.segment[0]; sponsorTime.segment[index] = time; if (sponsorTime.actionType === ActionType.Poi) sponsorTime.segment[1] = time; this.setState({ sponsorTimeEdits: this.getFormattedSponsorTimesEdits(sponsorTime) }, () => this.saveEditTimes()); } toggleEditTime(): void { if (this.state.editing) { this.setState({ editing: false }); this.saveEditTimes(); } else { const sponsorTime = this.props.contentContainer().sponsorTimesSubmitting[this.props.index]; this.setState({ editing: true, sponsorTimeEdits: this.getFormattedSponsorTimesEdits(sponsorTime) }); } } /** Returns an array in the sponsorTimeEdits form (formatted time string) from a normal seconds sponsor time */ getFormattedSponsorTimesEdits(sponsorTime: SponsorTime): [string, string] { return [GenericUtils.getFormattedTime(sponsorTime.segment[0], true), GenericUtils.getFormattedTime(sponsorTime.segment[1], true)]; } saveEditTimes(): void { const sponsorTimesSubmitting = this.props.contentContainer().sponsorTimesSubmitting; if (this.state.editing) { const startTime = GenericUtils.getFormattedTimeToSeconds(this.state.sponsorTimeEdits[0]); const endTime = GenericUtils.getFormattedTimeToSeconds(this.state.sponsorTimeEdits[1]); // Change segment time only if the format was correct if (startTime !== null && endTime !== null) { sponsorTimesSubmitting[this.props.index].segment = [startTime, endTime]; } } const category = this.categoryOptionRef.current.value as Category sponsorTimesSubmitting[this.props.index].category = category; const actionType = this.getNextActionType(category, this.actionTypeOptionRef?.current?.value as ActionType); sponsorTimesSubmitting[this.props.index].actionType = actionType; const description = actionType === ActionType.Chapter ? this.descriptionOptionRef?.current?.value : ""; sponsorTimesSubmitting[this.props.index].description = description; Config.config.unsubmittedSegments[this.props.contentContainer().sponsorVideoID] = sponsorTimesSubmitting; Config.forceSyncUpdate("unsubmittedSegments"); this.props.contentContainer().updatePreviewBar(); if (sponsorTimesSubmitting[this.props.index].actionType === ActionType.Full && (sponsorTimesSubmitting[this.props.index].segment[0] !== 0 || sponsorTimesSubmitting[this.props.index].segment[1] !== 0)) { this.setTimeTo(0, 0); this.setTimeTo(1, 0); } } private getNextActionType(category: Category, actionType: ActionType): ActionType { return actionType && CompileConfig.categorySupport[category]?.includes(actionType) ? actionType : CompileConfig.categorySupport[category]?.[0] ?? ActionType.Skip } previewTime(ctrlPressed = false, shiftPressed = false): void { const sponsorTimes = this.props.contentContainer().sponsorTimesSubmitting; const index = this.props.index; let seekTime = 2; if (ctrlPressed) seekTime = 0.5; if (shiftPressed) seekTime = 0.25; const startTime = sponsorTimes[index].segment[0]; const endTime = sponsorTimes[index].segment[1]; const isChapter = sponsorTimes[index].actionType === ActionType.Chapter; // If segment starts at 0:00, start playback at the end of the segment const skipToEndTime = startTime === 0 || isChapter; const skipTime = skipToEndTime ? endTime : (startTime - (seekTime * this.props.contentContainer().v.playbackRate)); this.props.contentContainer().previewTime(skipTime, !isChapter); } inspectTime(): void { const sponsorTimes = this.props.contentContainer().sponsorTimesSubmitting; const index = this.props.index; const skipTime = sponsorTimes[index].segment[0]; this.props.contentContainer().previewTime(skipTime + 0.0001, false); } deleteTime(): void { const sponsorTimes = this.props.contentContainer().sponsorTimesSubmitting; const index = this.props.index; const removingIncomplete = sponsorTimes[index].segment.length < 2; sponsorTimes.splice(index, 1); //save this if (sponsorTimes.length > 0) { Config.config.unsubmittedSegments[this.props.contentContainer().sponsorVideoID] = sponsorTimes; } else { delete Config.config.unsubmittedSegments[this.props.contentContainer().sponsorVideoID]; } Config.forceSyncUpdate("unsubmittedSegments"); this.props.contentContainer().updatePreviewBar(); //if they are all removed if (sponsorTimes.length == 0) { this.props.submissionNotice.cancel(); } else { //update display this.props.submissionNotice.forceUpdate(); } //if it is not a complete segment, or all are removed if (sponsorTimes.length === 0 || removingIncomplete) { //update video player this.props.contentContainer().updateEditButtonsOnPlayer(); } } descriptionUpdate(description: string): void { this.setState({ description }); if (!this.fetchingSuggestions) { this.fetchSuggestions(description); } this.saveEditTimes(); } async fetchSuggestions(description: string): Promise { if (this.props.contentContainer().channelIDInfo.status !== ChannelIDStatus.Found) return; this.fetchingSuggestions = true; const result = await utils.asyncRequestToServer("GET", "/api/chapterNames", { description, channelID: this.props.contentContainer().channelIDInfo.id }); if (result.ok) { try { const names = JSON.parse(result.responseText) as {description: string}[]; this.setState({ suggestedNames: names.map(n => ({ label: n.description })) }); } catch (e) {} //eslint-disable-line no-empty } this.fetchingSuggestions = false; } configUpdate(): void { this.forceUpdate(); } } export default SponsorTimeEditComponent;