Merge branch 'master' into loop-chapters

This commit is contained in:
Gosha
2025-09-15 07:34:25 +02:00
committed by GitHub
38 changed files with 2659 additions and 3988 deletions

View File

@@ -1,22 +1,24 @@
import * as React from "react";
import { YourWorkComponent } from "./YourWorkComponent";
// import { ToggleOptionComponent } from "./ToggleOptionComponent";
// import { FormattingOptionsComponent } from "./FormattingOptionsComponent";
import { isSafari } from "../../maze-utils/src/config";
import { showDonationLink } from "../utils/configUtils";
import Config, { generateDebugDetails } from "../config";
import { GetChannelIDResponse, IsInfoFoundMessageResponse, LogResponse, Message, MessageResponse, PopupMessage } from "../messageTypes";
import Config, { ConfigurationID, generateDebugDetails } from "../config";
import { IsInfoFoundMessageResponse, LogResponse, Message, MessageResponse, PopupMessage } from "../messageTypes";
import { AnimationUtils } from "../../maze-utils/src/animationUtils";
import { SegmentListComponent } from "./SegmentListComponent";
import { ActionType, SegmentUUID, SponsorSourceType, SponsorTime } from "../types";
import { SegmentSubmissionComponent } from "./SegmentSubmissionComponent";
import { copyToClipboardPopup } from "./popupUtils";
import { getSkipProfileID, getSkipProfileIDForChannel, getSkipProfileIDForTab, getSkipProfileIDForTime, getSkipProfileIDForVideo, setCurrentTabSkipProfile } from "../utils/skipProfiles";
import { SelectOptionComponent } from "../components/options/SelectOptionComponent";
import * as Video from "../../maze-utils/src/video";
export enum LoadingStatus {
Loading,
SegmentsFound,
NoSegmentsFound,
ConnectionError,
JSError,
StillLoading,
NoVideo
}
@@ -24,6 +26,42 @@ export enum LoadingStatus {
export interface LoadingData {
status: LoadingStatus;
code?: number;
error?: Error | string;
}
type SkipProfileAction = "forJustThisVideo" | "forThisChannel" | "forThisTab" | "forAnHour" | null;
interface SkipProfileOption {
name: SkipProfileAction;
active: () => boolean;
}
interface SegmentsLoadedProps {
setStatus: (status: LoadingData) => void;
setVideoID: (videoID: string | null) => void;
setCurrentTime: (time: number) => void;
setSegments: (segments: SponsorTime[]) => void;
setLoopedChapter: (loopedChapter: SegmentUUID | null) => void;
}
interface LoadSegmentsProps extends SegmentsLoadedProps {
updating: boolean;
}
interface SkipProfileRadioButtonsProps {
selected: SkipProfileAction;
setSelected: (s: SkipProfileAction, updateConfig: boolean) => void;
disabled: boolean;
configID: ConfigurationID | null;
videoID: string;
}
interface SkipOptionActionComponentProps {
selected: boolean;
setSelected: (s: boolean) => void;
highlighted: boolean;
disabled: boolean;
overridden: boolean;
label: string;
}
let loadRetryCount = 0;
@@ -33,7 +71,6 @@ export const PopupComponent = () => {
status: LoadingStatus.Loading
});
const [extensionEnabled, setExtensionEnabled] = React.useState(!Config.config!.disableSkipping);
const [channelWhitelisted, setChannelWhitelisted] = React.useState<boolean | null>(null);
const [showForceChannelCheckWarning, setShowForceChannelCheckWarning] = React.useState(false);
const [showNoticeButton, setShowNoticeButton] = React.useState(Config.config!.dontShowNotice);
@@ -47,7 +84,6 @@ export const PopupComponent = () => {
loadSegments({
updating: false,
setStatus,
setChannelWhitelisted,
setVideoID,
setCurrentTime,
setSegments,
@@ -56,7 +92,6 @@ export const PopupComponent = () => {
setupComPort({
setStatus,
setChannelWhitelisted,
setVideoID,
setCurrentTime,
setSegments,
@@ -107,7 +142,6 @@ export const PopupComponent = () => {
loadSegments({
updating: true,
setStatus,
setChannelWhitelisted,
setVideoID,
setCurrentTime,
setSegments,
@@ -129,54 +163,10 @@ export const PopupComponent = () => {
{/* Toggle Box */}
<div className="sbControlsMenu">
{/* github: mbledkowski/toggle-switch */}
{channelWhitelisted !== null && (
<label id="whitelistButton" htmlFor="whitelistToggle" className="toggleSwitchContainer sbControlsMenu-item" role="button" tabIndex={0}>
<input type="checkbox"
style={{ "display": "none" }}
id="whitelistToggle"
checked={channelWhitelisted}
onChange={async (e) => {
const response = await sendMessage({ message: 'getChannelID' }) as GetChannelIDResponse;
if (!response.channelID) {
if (response.isYTTV) {
alert(chrome.i18n.getMessage("yttvNoChannelWhitelist"));
} else {
alert(chrome.i18n.getMessage("channelDataNotFound") + " https://github.com/ajayyy/SponsorBlock/issues/753");
}
return;
}
const whitelistedChannels = Config.config.whitelistedChannels ?? [];
if (e.target.checked) {
whitelistedChannels.splice(whitelistedChannels.indexOf(response.channelID), 1);
} else {
whitelistedChannels.push(response.channelID);
}
Config.config.whitelistedChannels = whitelistedChannels;
setChannelWhitelisted(!e.target.checked);
if (!Config.config.forceChannelCheck) setShowForceChannelCheckWarning(true);
// Send a message to the client
sendMessage({
message: 'whitelistChange',
value: !e.target.checked
});
}}/>
<svg viewBox="0 0 24 24" width="23" height="23" className={"SBWhitelistIcon sbControlsMenu-itemIcon " + (channelWhitelisted ? " rotated" : "")}>
<path d="M24 10H14V0h-4v10H0v4h10v10h4V14h10z" />
</svg>
<span id="whitelistChannel" className={channelWhitelisted ? " hidden" : ""}>
{chrome.i18n.getMessage("whitelistChannel")}
</span>
<span id="unwhitelistChannel" className={!channelWhitelisted ? " hidden" : ""}>
{chrome.i18n.getMessage("removeFromWhitelist")}
</span>
</label>
)}
<SkipProfileButton
videoID={videoID}
setShowForceChannelCheckWarning={setShowForceChannelCheckWarning}
/>
<label id="disableExtension" htmlFor="toggleSwitch" className="toggleSwitchContainer sbControlsMenu-item" role="button" tabIndex={0}>
<span className="toggleSwitchContainer-switch">
<input type="checkbox"
@@ -302,7 +292,9 @@ function getVideoStatusText(status: LoadingData): string {
case LoadingStatus.NoSegmentsFound:
return chrome.i18n.getMessage("sponsor404");
case LoadingStatus.ConnectionError:
return chrome.i18n.getMessage("connectionError") + status.code;
return `${chrome.i18n.getMessage("connectionError")} ${chrome.i18n.getMessage("errorCode").replace("{code}", `${status.code}`)}`;
case LoadingStatus.JSError:
return `${chrome.i18n.getMessage("connectionError")} ${status.error}`;
case LoadingStatus.StillLoading:
return chrome.i18n.getMessage("segmentsStillLoading");
case LoadingStatus.NoVideo:
@@ -310,19 +302,6 @@ function getVideoStatusText(status: LoadingData): string {
}
}
interface SegmentsLoadedProps {
setStatus: (status: LoadingData) => void;
setChannelWhitelisted: (whitelisted: boolean | null) => void;
setVideoID: (videoID: string | null) => void;
setCurrentTime: (time: number) => void;
setSegments: (segments: SponsorTime[]) => void;
setLoopedChapter: (loopedChapter: SegmentUUID | null) => void;
}
interface LoadSegmentsProps extends SegmentsLoadedProps {
updating: boolean;
}
async function loadSegments(props: LoadSegmentsProps): Promise<void> {
const response = await sendMessage({ message: "isInfoFound", updating: props.updating }) as IsInfoFoundMessageResponse;
@@ -350,6 +329,11 @@ function segmentsLoaded(response: IsInfoFoundMessageResponse, props: SegmentsLoa
props.setStatus({
status: LoadingStatus.SegmentsFound
});
} else if (typeof response.status !== "number") {
props.setStatus({
status: LoadingStatus.JSError,
error: response.status,
})
} else if (response.status === 404 || response.status === 200) {
props.setStatus({
status: LoadingStatus.NoSegmentsFound
@@ -367,8 +351,10 @@ function segmentsLoaded(response: IsInfoFoundMessageResponse, props: SegmentsLoa
props.setVideoID(response.videoID);
Video.setVideoID(response.videoID as Video.VideoID);
props.setCurrentTime(response.time);
props.setChannelWhitelisted(response.channelWhitelisted);
Video.setChanelIDInfo(response.channelID, response.channelAuthor);
setCurrentTabSkipProfile(response.currentTabSkipProfileID);
props.setSegments((response.sponsorTimes || [])
.filter((segment) => segment.source === SponsorSourceType.Server)
.sort((a, b) => b.segment[1] - a.segment[1])
@@ -390,16 +376,13 @@ function sendMessage(request: Message): Promise<MessageResponse> {
});
}
interface ComPortProps extends SegmentsLoadedProps {
}
function setupComPort(props: ComPortProps): void {
function setupComPort(props: SegmentsLoadedProps): void {
const port = chrome.runtime.connect({ name: "popup" });
port.onDisconnect.addListener(() => setupComPort(props));
port.onMessage.addListener((msg) => onMessage(props, msg));
}
function onMessage(props: ComPortProps, msg: PopupMessage) {
function onMessage(props: SegmentsLoadedProps, msg: PopupMessage) {
switch (msg.message) {
case "time":
props.setCurrentTime(msg.time);
@@ -412,7 +395,8 @@ function onMessage(props: ComPortProps, msg: PopupMessage) {
status: LoadingStatus.StillLoading
});
props.setVideoID(msg.videoID);
props.setChannelWhitelisted(msg.whitelisted);
Video.setVideoID(msg.videoID as Video.VideoID);
Video.setChanelIDInfo(msg.channelID, msg.channelAuthor);
props.setSegments([]);
break;
}
@@ -457,4 +441,270 @@ window.addEventListener("message", async (e): Promise<void> => {
style.textContent = e.data.css;
document.head.appendChild(style);
}
});
});
function SkipProfileButton(props: {videoID: string; setShowForceChannelCheckWarning: (v: boolean) => void}): JSX.Element {
const [menuOpen, setMenuOpen] = React.useState(false);
const skipProfileSet = getSkipProfileIDForChannel() !== null;
React.useEffect(() => {
setMenuOpen(false);
}, [props.videoID]);
return (
<>
<label id="skipProfileButton"
htmlFor="skipProfileToggle"
className="toggleSwitchContainer sbControlsMenu-item"
role="button"
tabIndex={0}
onClick={() => {
if (menuOpen && !Config.config.forceChannelCheck && getSkipProfileID() !== null) {
props.setShowForceChannelCheckWarning(true);
}
setMenuOpen(!menuOpen);
}}>
<svg viewBox="0 0 24 24" width="23" height="23" className={"SBWhitelistIcon sbControlsMenu-itemIcon " + (menuOpen ? " rotated" : "")}>
<path d="M24 10H14V0h-4v10H0v4h10v10h4V14h10z" />
</svg>
<span id="whitelistChannel" className={(menuOpen || skipProfileSet) ? " hidden" : ""}>
{chrome.i18n.getMessage("addChannelToSkipProfile")}
</span>
<span id="whitelistChannel" className={(menuOpen || !skipProfileSet) ? " hidden" : ""}>
{chrome.i18n.getMessage("editChannelsSkipProfile")}
</span>
<span id="unwhitelistChannel" className={!menuOpen ? " hidden" : ""}>
{chrome.i18n.getMessage("closeSkipProfileMenu")}
</span>
</label>
{
props.videoID &&
<SkipProfileMenu open={menuOpen} videoID={props.videoID} />
}
</>
);
}
const skipProfileOptions: SkipProfileOption[] = [{
name: "forAnHour",
active: () => getSkipProfileIDForTime() !== null
}, {
name: "forThisTab",
active: () => getSkipProfileIDForTab() !== null
}, {
name: "forJustThisVideo",
active: () => getSkipProfileIDForVideo() !== null
}, {
name: "forThisChannel",
active: () => getSkipProfileIDForChannel() !== null
}];
function SkipProfileMenu(props: {open: boolean; videoID: string}): JSX.Element {
const [configID, setConfigID] = React.useState<ConfigurationID | null>(null);
const [selectedSkipProfileAction, setSelectedSkipProfileAction] = React.useState<SkipProfileAction>(null);
const [allSkipProfiles, setAllSkipProfiles] = React.useState(Object.entries(Config.local!.skipProfiles));
React.useEffect(() => {
if (props.open) {
const channelInfo = Video.getChannelIDInfo();
if (!channelInfo) {
if (Video.isOnYTTV()) {
alert(chrome.i18n.getMessage("yttvNoChannelWhitelist"));
} else {
alert(chrome.i18n.getMessage("channelDataNotFound") + " https://github.com/ajayyy/SponsorBlock/issues/753");
}
}
}
setConfigID(getSkipProfileID());
}, [props.open, props.videoID]);
React.useEffect(() => {
Config.configLocalListeners.push(() => {
setAllSkipProfiles(Object.entries(Config.local!.skipProfiles));
});
}, []);
return (
<div id="skipProfileMenu" className={`${!props.open ? " hidden" : ""}`}
aria-label={chrome.i18n.getMessage("SkipProfileMenu")}>
<div style={{position: "relative"}}>
<SelectOptionComponent
id="sbSkipProfileSelection"
title={chrome.i18n.getMessage("SelectASkipProfile")}
onChange={(value) => {
if (value === "new") {
chrome.runtime.sendMessage({ message: "openConfig", hash: "newProfile" });
return;
}
const configID = value === "null" ? null : value as ConfigurationID;
setConfigID(configID);
if (configID === null) {
setSelectedSkipProfileAction(null);
}
if (selectedSkipProfileAction) {
updateSkipProfileSetting(selectedSkipProfileAction, configID);
if (configID === null) {
for (const option of skipProfileOptions) {
if (option.name !== selectedSkipProfileAction && option.active()) {
updateSkipProfileSetting(option.name, null);
}
}
}
}
}}
value={configID ?? "null"}
options={[{
value: "null",
label: chrome.i18n.getMessage("DefaultConfiguration")
}].concat(allSkipProfiles.map(([key, value]) => ({
value: key,
label: value.name
}))).concat([{
value: "new",
label: chrome.i18n.getMessage("CreateNewConfiguration")
}])}
/>
<SkipProfileRadioButtons
selected={selectedSkipProfileAction}
setSelected={(s, updateConfig) => {
if (updateConfig) {
if (s === null) {
updateSkipProfileSetting(selectedSkipProfileAction, null);
} else {
updateSkipProfileSetting(s, configID);
}
} else if (s !== null) {
setConfigID(getSkipProfileID());
}
setSelectedSkipProfileAction(s);
}}
disabled={configID === null}
configID={configID}
videoID={props.videoID}
/>
</div>
</div>
);
}
function SkipProfileRadioButtons(props: SkipProfileRadioButtonsProps): JSX.Element {
const result: JSX.Element[] = [];
React.useEffect(() => {
if (props.configID === null) {
props.setSelected(null, false);
} else {
for (const option of skipProfileOptions) {
if (option.active()) {
if (props.selected !== option.name) {
props.setSelected(option.name, false);
}
return;
}
}
}
}, [props.configID, props.videoID]);
let alreadySelected = false;
for (const option of skipProfileOptions) {
const highlighted = option.active() && props.selected !== option.name;
const overridden = !highlighted && alreadySelected;
result.push(
<SkipOptionActionComponent
highlighted={highlighted}
label={chrome.i18n.getMessage(`skipProfile_${option.name}`)}
selected={props.selected === option.name}
overridden={overridden}
disabled={props.disabled || overridden}
key={option.name}
setSelected={(s) => {
props.setSelected(s ? option.name : null, true);
}}/>
);
if (props.selected === option.name) {
alreadySelected = true;
}
}
return <div id="skipProfileActions">
{result}
</div>
}
function SkipOptionActionComponent(props: SkipOptionActionComponentProps): JSX.Element {
let title = "";
if (props.selected) {
title = chrome.i18n.getMessage("clickToNotApplyThisProfile");
} else if ((props.highlighted && !props.disabled) || props.overridden) {
title = chrome.i18n.getMessage("skipProfileBeingOverriddenByHigherPriority");
} else if (!props.highlighted && !props.disabled) {
title = chrome.i18n.getMessage("clickToApplyThisProfile");
} else if (props.disabled) {
title = chrome.i18n.getMessage("selectASkipProfileFirst");
}
return (
<div className={`skipOptionAction ${props.selected ? "selected" : ""} ${props.highlighted ? "highlighted" : ""} ${props.disabled ? "disabled" : ""}`}
title={title}
role="button"
tabIndex={0}
aria-pressed={props.selected}
onClick={() => {
// Need to uncheck or disable a higher priority option first
if (!props.disabled && !props.highlighted) {
props.setSelected(!props.selected);
}
}}>
{props.label}
</div>
);
}
function updateSkipProfileSetting(action: SkipProfileAction, configID: ConfigurationID | null) {
switch (action) {
case "forAnHour":
Config.local!.skipProfileTemp = configID ? { time: Date.now(), configID } : null;
break;
case "forThisTab":
setCurrentTabSkipProfile(configID);
sendMessage({
message: "setCurrentTabSkipProfile",
configID
});
break;
case "forJustThisVideo":
if (configID) {
Config.local!.channelSkipProfileIDs[Video.getVideoID()!] = configID;
} else {
delete Config.local!.channelSkipProfileIDs[Video.getVideoID()!];
}
Config.forceLocalUpdate("channelSkipProfileIDs");
break;
case "forThisChannel": {
const channelInfo = Video.getChannelIDInfo();
if (configID) {
Config.local!.channelSkipProfileIDs[channelInfo.id] = configID;
delete Config.local!.channelSkipProfileIDs[channelInfo.author];
} else {
delete Config.local!.channelSkipProfileIDs[channelInfo.id];
delete Config.local!.channelSkipProfileIDs[channelInfo.author];
}
Config.forceLocalUpdate("channelSkipProfileIDs");
break;
}
}
}

View File

@@ -3,7 +3,7 @@ import { ActionType, SegmentUUID, SponsorHideType, SponsorTime, VideoID } from "
import Config from "../config";
import { waitFor } from "../../maze-utils/src";
import { shortCategoryName } from "../utils/categoryUtils";
import { getErrorMessage, getFormattedTime } from "../../maze-utils/src/formating";
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";
@@ -11,6 +11,7 @@ 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;
@@ -229,17 +230,24 @@ function SegmentListItem({ segment, videoID, currentTime, isVip, loopedChapter,
onClick={async (e) => {
const stopAnimation = AnimationUtils.applyLoadingAnimation(e.currentTarget, 0.3);
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);
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();
}
stopAnimation();
@@ -386,14 +394,24 @@ async function vote(props: {
}) as VoteResponse;
if (response != undefined) {
let messageDuration = 1_500;
// See if it was a success or failure
if (response.successType == 1 || (response.successType == -1 && response.statusCode == 429)) {
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 if (response.successType == -1) {
props.setVoteMessage(getErrorMessage(response.statusCode, response.responseText));
} else {
// Error
logRequest({headers: null, ...response}, "SB", "vote on segment");
props.setVoteMessage(getShortErrorMessage(response.status, response.responseText));
messageDuration = 10_000;
}
setTimeout(() => props.setVoteMessage(null), 1500);
setTimeout(() => props.setVoteMessage(null), messageDuration);
}
}
@@ -521,4 +539,4 @@ function ImportSegments(props: ImportSegmentsProps) {
</span>
</div>
)
}
}

View File

@@ -1,12 +1,13 @@
import * as React from "react";
import { getHash } from "../../maze-utils/src/hash";
import { getErrorMessage } from "../../maze-utils/src/formating";
import { formatJSErrorMessage, getShortErrorMessage } from "../../maze-utils/src/formating";
import Config from "../config";
import { asyncRequestToServer } from "../utils/requests";
import PencilIcon from "../svg-icons/pencilIcon";
import ClipboardIcon from "../svg-icons/clipboardIcon";
import CheckIcon from "../svg-icons/checkIcon";
import { showDonationLink } from "../utils/configUtils";
import { FetchResponse, logRequest } from "../../maze-utils/src/background-request-proxy";
export const YourWorkComponent = () => {
const [isSettingUsername, setIsSettingUsername] = React.useState(false);
@@ -21,10 +22,16 @@ export const YourWorkComponent = () => {
React.useEffect(() => {
(async () => {
const values = ["userName", "viewCount", "minutesSaved", "vip", "permissions", "segmentCount"];
const result = await asyncRequestToServer("GET", "/api/userInfo", {
publicUserID: await getHash(Config.config!.userID!),
values
});
let result: FetchResponse;
try {
result = await asyncRequestToServer("GET", "/api/userInfo", {
publicUserID: await getHash(Config.config!.userID!),
values
});
} catch (e) {
console.error("[SB] Caught error while fetching user info", e);
return
}
if (result.ok) {
const userInfo = JSON.parse(result.responseText);
@@ -38,6 +45,8 @@ export const YourWorkComponent = () => {
setShowDonateMessage(Config.config.showDonationLink && Config.config.donateClicked <= 0 && Config.config.showPopupDonationCount < 5
&& viewCount < 50000 && !Config.config.isVip && Config.config.skipCount > 10 && showDonationLink());
} else {
logRequest(result, "SB", "user info");
}
})();
}, []);
@@ -94,10 +103,12 @@ export const YourWorkComponent = () => {
setUsername(newUsername);
setIsSettingUsername(!isSettingUsername);
} else {
setUsernameSubmissionStatus(getErrorMessage(result.status, result.responseText));
logRequest(result, "SB", "username change");
setUsernameSubmissionStatus(getShortErrorMessage(result.status, result.responseText));
}
}).catch((e) => {
setUsernameSubmissionStatus(`${chrome.i18n.getMessage("Error")}: ${e}`);
console.error("[SB] Caught error while requesting a username change", e)
setUsernameSubmissionStatus(formatJSErrorMessage(e));
});
}
}}>
@@ -205,4 +216,4 @@ function getFormattedHours(minutes) {
const days = Math.floor(minutes / 1440) % 365;
const hours = Math.floor(minutes / 60) % 24;
return (years > 0 ? years + chrome.i18n.getMessage("yearAbbreviation") + " " : "") + (days > 0 ? days + chrome.i18n.getMessage("dayAbbreviation") + " " : "") + (hours > 0 ? hours + chrome.i18n.getMessage("hourAbbreviation") + " " : "") + (minutes % 60).toFixed(1);
}
}