import * as React from "react"; import { ActionType, SegmentListDefaultTab, SegmentUUID, SponsorHideType, SponsorTime, VideoID } from "../types"; import Config from "../config"; import { waitFor } from "../../maze-utils/src"; import { shortCategoryName } from "../utils/categoryUtils"; import { formatJSErrorMessage, getFormattedTime, getShortErrorMessage } from "../../maze-utils/src/formating"; import { AnimationUtils } from "../../maze-utils/src/animationUtils"; import { asyncRequestToServer } from "../utils/requests"; import { Message, MessageResponse, VoteResponse } from "../messageTypes"; import { LoadingStatus } from "./PopupComponent"; import GenericNotice from "../render/GenericNotice"; import { exportTimes } from "../utils/exporter"; import { copyToClipboardPopup } from "./popupUtils"; import { logRequest } from "../../maze-utils/src/background-request-proxy"; interface SegmentListComponentProps { videoID: VideoID; currentTime: number; status: LoadingStatus; segments: SponsorTime[]; loopedChapter: SegmentUUID | null; sendMessage: (request: Message) => Promise; } enum SegmentListTab { Segments, Chapter } interface SegmentWithNesting extends SponsorTime { innerChapters?: (SegmentWithNesting|SponsorTime)[]; } function isSegment(segment) { return segment.actionType !== ActionType.Chapter; } function isChapter(segment) { return segment.actionType === ActionType.Chapter; } export const SegmentListComponent = (props: SegmentListComponentProps) => { const [tab, setTab] = React.useState(SegmentListTab.Segments); const [isVip, setIsVip] = React.useState(Config.config?.isVip ?? false); React.useEffect(() => { if (!Config.isReady()) { waitFor(() => Config.isReady()).then(() => { setIsVip(Config.config.isVip); }); } else { setIsVip(Config.config.isVip); } }, []); const [hasSegments, hasChapters] = React.useMemo(() => { const hasSegments = Boolean(props.segments.find(isSegment)) const hasChapters = Boolean(props.segments.find(isChapter)) return [hasSegments, hasChapters]; }, [props.segments]); React.useEffect(() => { const setTabBasedOnConfig = () => { const preferChapters = Config.config.segmentListDefaultTab === SegmentListDefaultTab.Chapters; if (preferChapters) { setTab(hasChapters ? SegmentListTab.Chapter : SegmentListTab.Segments); } else { setTab(hasSegments ? SegmentListTab.Segments : SegmentListTab.Chapter); } }; if (Config.isReady()) { setTabBasedOnConfig(); } else { waitFor(() => Config.isReady()).then(setTabBasedOnConfig); } }, [props.videoID, hasSegments, hasChapters]); const segmentsWithNesting = React.useMemo(() => { const result: SegmentWithNesting[] = []; const chapterStack: SegmentWithNesting[] = []; for (let seg of props.segments) { seg = {...seg}; // non-chapter, do not nest if (seg.actionType !== ActionType.Chapter) { result.push(seg); continue; } // traverse the stack while (chapterStack.length !== 0) { // where's Array.prototype.at() :sob: const lastChapter = chapterStack[chapterStack.length - 1]; // we know lastChapter.startTime <= seg.startTime, as content.ts sorts these // so only compare endTime - if new ends before last, new is nested inside last if (lastChapter.segment[1] >= seg.segment[1]) { lastChapter.innerChapters ??= []; lastChapter.innerChapters.push(seg); chapterStack.push(seg); break; } // last did not match, pop it off the stack chapterStack.pop(); } // chapter stack not empty = we found a place for the chapter if (chapterStack.length !== 0) { continue; } // push the chapter to the top-level list and to the stack result.push(seg); chapterStack.push(seg); } return result; }, [props.segments]) return (
{ setTab(SegmentListTab.Segments); }}> {chrome.i18n.getMessage("SegmentsCap")} { setTab(SegmentListTab.Chapter); }}> {chrome.i18n.getMessage("Chapters")}
selectSegment({ segment: null, sendMessage: props.sendMessage })}> { segmentsWithNesting.map((segment) => ( )) }
); }; function SegmentListItem({ segment, videoID, currentTime, isVip, loopedChapter, tabFilter, sendMessage }: { segment: SegmentWithNesting; videoID: VideoID; currentTime: number; isVip: boolean; loopedChapter: SegmentUUID; tabFilter: (segment: SponsorTime) => boolean; sendMessage: (request: Message) => Promise; }) { const [voteMessage, setVoteMessage] = React.useState(null); const [hidden, setHidden] = React.useState(segment.hidden ?? SponsorHideType.Visible); // undefined ?? undefined lol const [isLooped, setIsLooped] = React.useState(loopedChapter === segment.UUID); // Update internal state if the hidden property of the segment changes React.useEffect(() => { setHidden(segment.hidden ?? SponsorHideType.Visible); }, [segment.hidden]) let extraInfo: string; switch (hidden) { case SponsorHideType.Visible: extraInfo = ""; break; case SponsorHideType.Downvoted: extraInfo = " (" + chrome.i18n.getMessage("hiddenDueToDownvote") + ")"; break; case SponsorHideType.MinimumDuration: extraInfo = " (" + chrome.i18n.getMessage("hiddenDueToDuration") + ")"; break; case SponsorHideType.Hidden: extraInfo = " (" + chrome.i18n.getMessage("manuallyHidden") + ")"; break; default: // hidden satisfies never; // need to upgrade TS console.warn(`[SB] Unhandled variant of SponsorHideType in SegmentListItem: ${hidden}`); extraInfo = ""; } return (
skipSegment({ segment, sendMessage })} onMouseEnter={() => { selectSegment({ segment, sendMessage }); }} className={"votingButtons"} > = segment.segment[0] ? ( currentTime < segment.segment[1] ? "segmentActive" : "segmentPassed" ) : "" )}>
{ segment.actionType !== ActionType.Chapter && } {(segment.description || shortCategoryName(segment.category)) + extraInfo}
{ segment.actionType === ActionType.Full ? chrome.i18n.getMessage("full") : (getFormattedTime(segment.segment[0], true) + (segment.actionType !== ActionType.Poi ? " " + chrome.i18n.getMessage("to") + " " + getFormattedTime(segment.segment[1], true) : "")) }
{ vote({ type: 1, UUID: segment.UUID, setVoteMessage: setVoteMessage, sendMessage }); }}/> { vote({ type: 0, UUID: segment.UUID, setVoteMessage: setVoteMessage, sendMessage }); }}/> { const stopAnimation = AnimationUtils.applyLoadingAnimation(e.currentTarget, 0.3); try { if (segment.UUID.length > 60) { copyToClipboardPopup(segment.UUID, sendMessage); } else { const segmentIDData = await asyncRequestToServer("GET", "/api/segmentID", { UUID: segment.UUID, videoID: videoID }); if (segmentIDData.ok && segmentIDData.responseText) { copyToClipboardPopup(segmentIDData.responseText, sendMessage); } else { logRequest(segmentIDData, "SB", "segment UUID resolution"); } } } catch (e) { console.error("[SB] Caught error while attempting to resolve and copy segment UUID", e); } finally { stopAnimation(); } }}/> { segment.actionType === ActionType.Chapter && { if (isLooped) { loopChapter({ segment: null, element: e.currentTarget, sendMessage }); } else { loopChapter({ segment, element: e.currentTarget, sendMessage }); } setIsLooped(!isLooped); }}/> } { (segment.actionType === ActionType.Skip || segment.actionType === ActionType.Mute || segment.actionType === ActionType.Poi && [SponsorHideType.Visible, SponsorHideType.Hidden].includes(hidden)) && { const stopAnimation = AnimationUtils.applyLoadingAnimation(e.currentTarget, 0.4); stopAnimation(); const newState = hidden === SponsorHideType.Hidden ? SponsorHideType.Visible : SponsorHideType.Hidden; setHidden(newState); sendMessage({ message: "hideSegment", type: newState, UUID: segment.UUID }); }}/> } { segment.actionType !== ActionType.Full && { skipSegment({ segment, element: e.currentTarget, sendMessage }); }}/> }
{voteMessage}
{ segment.innerChapters && }
); } function InnerChapterList({ chapters, videoID, currentTime, isVip, loopedChapter, tabFilter, sendMessage }: { chapters: (SegmentWithNesting)[]; videoID: VideoID; currentTime: number; isVip: boolean; loopedChapter: SegmentUUID; tabFilter: (segment: SponsorTime) => boolean; sendMessage: (request: Message) => Promise; }) { return
{ e.currentTarget.firstChild.textContent = (e.currentTarget.parentElement as HTMLDetailsElement).open ? chrome.i18n.getMessage("expandChapters").replace("{0}", String(chapters.length)) : chrome.i18n.getMessage("collapseChapters"); }}> {chrome.i18n.getMessage("collapseChapters")}
{ chapters.map((chapter) => { return }) }
} async function vote(props: { type: number; UUID: SegmentUUID; setVoteMessage: (message: string | null) => void; sendMessage: (request: Message) => Promise; }): Promise { props.setVoteMessage(chrome.i18n.getMessage("Loading")); const response = await props.sendMessage({ message: "submitVote", type: props.type, UUID: props.UUID }) as VoteResponse; if (response != undefined) { let messageDuration = 1_500; // See if it was a success or failure if ("error" in response) { // JS error console.error("[SB] Caught error while attempting to submit a vote", response.error); props.setVoteMessage(formatJSErrorMessage(response.error)); messageDuration = 10_000; } else if (response.ok || response.status === 429) { // Success (treat rate limits as a success) props.setVoteMessage(chrome.i18n.getMessage("voted")); } else { // Error logRequest({headers: null, ...response}, "SB", "vote on segment"); props.setVoteMessage(getShortErrorMessage(response.status, response.responseText)); messageDuration = 10_000; } setTimeout(() => props.setVoteMessage(null), messageDuration); } } function skipSegment({ segment, element, sendMessage }: { segment: SponsorTime; element?: HTMLElement; sendMessage: (request: Message) => Promise; }): void { if (segment.actionType === ActionType.Chapter) { sendMessage({ message: "unskip", UUID: segment.UUID }); } else { sendMessage({ message: "reskip", UUID: segment.UUID }); } if (element) { const stopAnimation = AnimationUtils.applyLoadingAnimation(element, 0.3); stopAnimation(); } } function selectSegment({ segment, sendMessage }: { segment: SponsorTime | null; sendMessage: (request: Message) => Promise; }): void { sendMessage({ message: "selectSegment", UUID: segment?.UUID }); } function loopChapter({ segment, element, sendMessage }: { segment: SponsorTime; element: HTMLElement; sendMessage: (request: Message) => Promise; }): void { sendMessage({ message: "loopChapter", UUID: segment?.UUID }); if (element) { const stopAnimation = AnimationUtils.applyLoadingAnimation(element, 0.3); stopAnimation(); } } interface ImportSegmentsProps { status: LoadingStatus; segments: SponsorTime[]; sendMessage: (request: Message) => Promise; } function ImportSegments(props: ImportSegmentsProps) { const [importMenuVisible, setImportMenuVisible] = React.useState(false); const textArea = React.useRef(null); return (
) }