mirror of
https://github.com/Anarios/return-youtube-dislike.git
synced 2026-09-12 10:22:10 +02:00
e500088e65
Add Firefox data consent, repair contributor sign-in, and introduce optional Hide clutter buttons. Harden video navigation, voting, and action layouts with shared browser regression coverage and reproducible release packaging.
500 lines
18 KiB
JavaScript
500 lines
18 KiB
JavaScript
import {
|
|
ensureSyntheticShortsDislikeButton,
|
|
getButtonControls,
|
|
getButtons,
|
|
getDislikeTextContainer,
|
|
getShortsCandidateVideoId,
|
|
getShortsControlSurfaceSignature,
|
|
hasRenderedBox,
|
|
markButtonsForVideo,
|
|
setSyntheticShortsDislikeEnabled,
|
|
shortsControlSurfaceIsReadyForMutation,
|
|
shortsNativeControlInventoryIsReadyForFallback,
|
|
} from "./src/buttons";
|
|
import {
|
|
hasLoadedStateForVideo,
|
|
initExtConfig,
|
|
isLikesDisabled,
|
|
isShorts,
|
|
clearRenderedVoteState,
|
|
restoreCurrentState,
|
|
setInitialState,
|
|
} from "./src/state";
|
|
import { getBrowser, getVideoId, isVideoLoaded } from "./src/utils";
|
|
import { addLikeDislikeEventListener, createSmartimationObserver, storageChangeHandler } from "./src/events";
|
|
import { hasUsableRateBar } from "./src/bar";
|
|
import {
|
|
createInitializationCycleRunner,
|
|
createPendingNavigationTracker,
|
|
pendingIncompleteShortsControlsCanInitialize,
|
|
pendingNavigationControlsAreReady,
|
|
reactionControlsCanInitialize,
|
|
} from "./src/initialization-cycle";
|
|
import { initPatreonFeatures } from "./src/patreon";
|
|
import { cancelObsoleteVoteDataRequests, clearVoteDataRequestCache } from "./src/vote-data-request";
|
|
import {
|
|
actionBarHasHydratedData,
|
|
captureShortsNativeControlInventory,
|
|
getShortsControlSurfaceStabilityMs,
|
|
shortsNativeControlInventoryMatches,
|
|
} from "../common/shorts-control-readiness";
|
|
|
|
if (__RYD_LIVE_TEST_BUILD__) {
|
|
document.documentElement.setAttribute("data-ryd-extension-version", getBrowser().runtime.getManifest().version);
|
|
document.documentElement.setAttribute("data-ryd-extension-build", __RYD_LIVE_BUILD_ID__);
|
|
}
|
|
|
|
let jsInitChecktimer = null;
|
|
let isSetInitialStateDone = false;
|
|
let isStorageListenerRegistered = false;
|
|
let shortsNavigationObserver = null;
|
|
let shortsNavigationObserverTarget = null;
|
|
let initializedVideoId = null;
|
|
let initializedButtons = null;
|
|
let initializedLikeButton = null;
|
|
let initializedDislikeButton = null;
|
|
let initializedNativeLikeButton = null;
|
|
let initializedNativeDislikeButton = null;
|
|
let initializationCheckRunning = false;
|
|
let pendingShortsMutationSurface = null;
|
|
let shortsRouteEpoch = 0;
|
|
let observedRouteKey = null;
|
|
const pendingNavigation = createPendingNavigationTracker();
|
|
const SHORTS_HYDRATION_ATTRIBUTE = "data-ryd-shorts-action-bar-hydrated";
|
|
const SHORTS_HYDRATION_REQUEST_EVENT = "ryd-shorts-action-bar-hydration-request";
|
|
|
|
function injectPageWorldHelpers() {
|
|
return new Promise((resolve) => {
|
|
const script = document.createElement("script");
|
|
script.src = getBrowser().runtime.getURL("menu-fixer.js");
|
|
script.onload = function () {
|
|
this.remove();
|
|
resolve(true);
|
|
};
|
|
script.onerror = function () {
|
|
this.remove();
|
|
console.warn("Page-world helpers failed to load; Shorts initialization will retry.");
|
|
resolve(false);
|
|
};
|
|
(document.head || document.documentElement).appendChild(script);
|
|
});
|
|
}
|
|
|
|
function pageWorldActionBarHasHydratedData(actionBar) {
|
|
if (!actionBar?.matches?.("reel-action-bar-view-model")) return actionBarHasHydratedData(actionBar);
|
|
|
|
actionBar.removeAttribute(SHORTS_HYDRATION_ATTRIBUTE);
|
|
actionBar.dispatchEvent(new Event(SHORTS_HYDRATION_REQUEST_EVENT, { bubbles: false }));
|
|
const hydrated = actionBar.getAttribute(SHORTS_HYDRATION_ATTRIBUTE) === "true";
|
|
actionBar.removeAttribute(SHORTS_HYDRATION_ATTRIBUTE);
|
|
return hydrated;
|
|
}
|
|
|
|
const pageWorldHelpersReady = injectPageWorldHelpers();
|
|
await initExtConfig();
|
|
await pageWorldHelpersReady;
|
|
initPatreonFeatures();
|
|
|
|
// Settings that affect page-world helpers must continue to update even when
|
|
// YouTube's reaction controls have not finished initializing.
|
|
getBrowser().storage.onChanged.addListener(storageChangeHandler);
|
|
isStorageListenerRegistered = true;
|
|
|
|
function ensureShortsNavigationObserver() {
|
|
if (!isShorts()) {
|
|
return;
|
|
}
|
|
|
|
const shortsRoot = document.querySelector("ytd-shorts");
|
|
if (!shortsRoot) {
|
|
if (shortsNavigationObserverTarget && !shortsNavigationObserverTarget.isConnected) {
|
|
shortsNavigationObserver?.disconnect();
|
|
shortsNavigationObserverTarget = null;
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (!shortsNavigationObserver) {
|
|
shortsNavigationObserver = new MutationObserver((mutations) => {
|
|
for (const mutation of mutations) {
|
|
const activatedRenderer =
|
|
mutation.type === "attributes" &&
|
|
mutation.attributeName === "is-active" &&
|
|
mutation.target.tagName === "YTD-REEL-VIDEO-RENDERER" &&
|
|
mutation.target.hasAttribute("is-active");
|
|
const currentShortsLinkChanged =
|
|
mutation.type === "attributes" &&
|
|
mutation.attributeName === "href" &&
|
|
mutation.target.matches?.("a[href*='/shorts/']") &&
|
|
getShortsCandidateVideoId(getButtons()) === getVideoId(window.location.href);
|
|
const nativeActionTreeChanged =
|
|
mutation.type === "childList" &&
|
|
[...mutation.addedNodes, ...mutation.removedNodes].some(
|
|
(node) =>
|
|
node.nodeType === Node.ELEMENT_NODE &&
|
|
(node.matches?.("like-button-view-model, dislike-button-view-model, reel-action-bar-view-model") ||
|
|
node.querySelector?.("like-button-view-model, dislike-button-view-model, reel-action-bar-view-model")),
|
|
);
|
|
if (activatedRenderer || currentShortsLinkChanged || nativeActionTreeChanged) {
|
|
triggerInitializationCycle();
|
|
break;
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
if (shortsNavigationObserverTarget !== shortsRoot) {
|
|
shortsNavigationObserver.disconnect();
|
|
shortsNavigationObserver.observe(shortsRoot, {
|
|
attributes: true,
|
|
childList: true,
|
|
subtree: true,
|
|
attributeFilter: ["href", "is-active"],
|
|
});
|
|
shortsNavigationObserverTarget = shortsRoot;
|
|
}
|
|
}
|
|
|
|
function syncShortsRouteEpoch() {
|
|
const videoId = getVideoId(window.location.href);
|
|
const routeKey = `${isShorts() ? "shorts" : "other"}:${videoId ?? "none"}`;
|
|
if (observedRouteKey !== routeKey) {
|
|
cancelObsoleteVoteDataRequests(videoId);
|
|
observedRouteKey = routeKey;
|
|
shortsRouteEpoch += 1;
|
|
pendingShortsMutationSurface = null;
|
|
}
|
|
}
|
|
|
|
function getStableShortsControls(videoId) {
|
|
const buttons = getButtons();
|
|
const isHydrated = pageWorldActionBarHasHydratedData(buttons);
|
|
const needsSyntheticDislike =
|
|
buttons?.tagName === "REEL-ACTION-BAR-VIEW-MODEL" &&
|
|
(!buttons.querySelector("dislike-button-view-model, #dislike-button") ||
|
|
buttons.querySelector("[data-ryd-synthetic-shorts-dislike]"));
|
|
if (!needsSyntheticDislike && isHydrated) {
|
|
pendingShortsMutationSurface = null;
|
|
return buttons ? getButtonControls(buttons) : null;
|
|
}
|
|
const inventory = captureShortsNativeControlInventory(buttons);
|
|
if (
|
|
!buttons ||
|
|
!inventory ||
|
|
(!isHydrated && !shortsNativeControlInventoryIsReadyForFallback(inventory)) ||
|
|
!shortsControlSurfaceIsReadyForMutation(buttons, videoId, {
|
|
allowUnhydratedFallback: !isHydrated,
|
|
isHydrated,
|
|
isStable: true,
|
|
})
|
|
) {
|
|
pendingShortsMutationSurface = null;
|
|
return null;
|
|
}
|
|
|
|
const signature = getShortsControlSurfaceSignature(buttons);
|
|
const now = performance.now();
|
|
if (
|
|
pendingShortsMutationSurface?.buttons !== buttons ||
|
|
pendingShortsMutationSurface.routeEpoch !== shortsRouteEpoch ||
|
|
pendingShortsMutationSurface.videoId !== videoId ||
|
|
pendingShortsMutationSurface.signature !== signature ||
|
|
!shortsNativeControlInventoryMatches(pendingShortsMutationSurface.inventory, inventory)
|
|
) {
|
|
pendingShortsMutationSurface = {
|
|
buttons,
|
|
inventory,
|
|
observedAt: now,
|
|
routeEpoch: shortsRouteEpoch,
|
|
signature,
|
|
unhydratedObservedAt: isHydrated ? null : now,
|
|
videoId,
|
|
};
|
|
return null;
|
|
}
|
|
|
|
if (isHydrated) {
|
|
pendingShortsMutationSurface.unhydratedObservedAt = null;
|
|
} else if (pendingShortsMutationSurface.unhydratedObservedAt == null) {
|
|
pendingShortsMutationSurface.unhydratedObservedAt = now;
|
|
}
|
|
const stabilityObservedAt = isHydrated
|
|
? pendingShortsMutationSurface.observedAt
|
|
: pendingShortsMutationSurface.unhydratedObservedAt;
|
|
if (now - stabilityObservedAt < getShortsControlSurfaceStabilityMs(isHydrated)) {
|
|
return null;
|
|
}
|
|
|
|
const allowUnhydratedFallback = !isHydrated;
|
|
const preMutationButtons = getButtons();
|
|
const preMutationInventory = captureShortsNativeControlInventory(preMutationButtons);
|
|
const preMutationIsHydrated = pageWorldActionBarHasHydratedData(preMutationButtons);
|
|
const preMutationAllowsFallback = !preMutationIsHydrated && allowUnhydratedFallback;
|
|
if (
|
|
preMutationButtons !== buttons ||
|
|
!shortsNativeControlInventoryMatches(inventory, preMutationInventory) ||
|
|
(preMutationAllowsFallback && !shortsNativeControlInventoryIsReadyForFallback(preMutationInventory)) ||
|
|
getShortsControlSurfaceSignature(preMutationButtons) !== signature ||
|
|
!shortsControlSurfaceIsReadyForMutation(preMutationButtons, videoId, {
|
|
allowUnhydratedFallback: preMutationAllowsFallback,
|
|
isHydrated: preMutationIsHydrated,
|
|
isStable: true,
|
|
})
|
|
) {
|
|
pendingShortsMutationSurface = null;
|
|
return null;
|
|
}
|
|
|
|
if (needsSyntheticDislike) {
|
|
ensureSyntheticShortsDislikeButton(preMutationButtons, {
|
|
allowUnhydratedFallback: preMutationAllowsFallback,
|
|
currentVideoId: videoId,
|
|
isHydrated: preMutationIsHydrated,
|
|
isStable: true,
|
|
});
|
|
}
|
|
|
|
const confirmedButtons = getButtons();
|
|
const confirmedInventory = captureShortsNativeControlInventory(confirmedButtons);
|
|
const confirmedIsHydrated = pageWorldActionBarHasHydratedData(confirmedButtons);
|
|
if (
|
|
confirmedButtons !== buttons ||
|
|
!shortsNativeControlInventoryMatches(inventory, confirmedInventory) ||
|
|
(preMutationAllowsFallback && !shortsNativeControlInventoryIsReadyForFallback(confirmedInventory)) ||
|
|
getShortsControlSurfaceSignature(confirmedButtons) !== signature ||
|
|
!shortsControlSurfaceIsReadyForMutation(confirmedButtons, videoId, {
|
|
allowUnhydratedFallback: !confirmedIsHydrated && preMutationAllowsFallback,
|
|
isHydrated: confirmedIsHydrated,
|
|
isStable: true,
|
|
})
|
|
) {
|
|
pendingShortsMutationSurface = null;
|
|
return null;
|
|
}
|
|
|
|
pendingShortsMutationSurface = null;
|
|
return getButtonControls(confirmedButtons);
|
|
}
|
|
|
|
async function checkForInitialization() {
|
|
if (initializationCheckRunning) return;
|
|
initializationCheckRunning = true;
|
|
try {
|
|
syncShortsRouteEpoch();
|
|
if (isShorts()) {
|
|
ensureShortsNavigationObserver();
|
|
}
|
|
|
|
const videoId = getVideoId(window.location.href);
|
|
const pendingOrigin = pendingNavigation.get();
|
|
if (pendingOrigin && (!pendingOrigin.videoId || pendingOrigin.videoId === videoId)) return;
|
|
const controls = isShorts() ? getStableShortsControls(videoId) : getButtonControls();
|
|
if (!controls) return;
|
|
const { buttons, dislikeButton, likeButton, nativeDislikeButton, nativeLikeButton } = controls;
|
|
const shortsRoute = isShorts();
|
|
if (
|
|
controls.ready &&
|
|
reactionControlsCanInitialize({
|
|
hasRenderedButtons: hasRenderedBox(buttons),
|
|
isShortsRoute: shortsRoute,
|
|
isVideoLoaded: !shortsRoute && isVideoLoaded(),
|
|
})
|
|
) {
|
|
if (!getDislikeTextContainer(controls)) return;
|
|
createSmartimationObserver(buttons);
|
|
addLikeDislikeEventListener(likeButton, dislikeButton);
|
|
let rendered;
|
|
if (hasLoadedStateForVideo(videoId)) {
|
|
rendered = restoreCurrentState();
|
|
} else {
|
|
rendered = await setInitialState();
|
|
}
|
|
if (rendered === false) return;
|
|
const currentControls = getButtonControls();
|
|
if (
|
|
videoId !== getVideoId(window.location.href) ||
|
|
!currentControls.ready ||
|
|
!currentControls.dislikeTextContainer ||
|
|
buttons !== currentControls.buttons ||
|
|
likeButton !== currentControls.likeButton ||
|
|
dislikeButton !== currentControls.dislikeButton ||
|
|
nativeLikeButton !== currentControls.nativeLikeButton ||
|
|
nativeDislikeButton !== currentControls.nativeDislikeButton
|
|
) {
|
|
return;
|
|
}
|
|
setSyntheticShortsDislikeEnabled(true, currentControls.dislikeButton);
|
|
markButtonsForVideo(buttons, videoId);
|
|
initializedVideoId = videoId;
|
|
initializedButtons = buttons;
|
|
initializedLikeButton = likeButton;
|
|
initializedDislikeButton = dislikeButton;
|
|
initializedNativeLikeButton = nativeLikeButton;
|
|
initializedNativeDislikeButton = nativeDislikeButton;
|
|
pendingNavigation.clear();
|
|
isSetInitialStateDone = true;
|
|
if (jsInitChecktimer !== null) {
|
|
clearInterval(jsInitChecktimer);
|
|
jsInitChecktimer = null;
|
|
}
|
|
if (!isStorageListenerRegistered) {
|
|
getBrowser().storage.onChanged.addListener(storageChangeHandler);
|
|
isStorageListenerRegistered = true;
|
|
}
|
|
}
|
|
} catch (exception) {
|
|
console.warn("Initialization failed; retrying when the current controls are ready.", exception);
|
|
} finally {
|
|
initializationCheckRunning = false;
|
|
}
|
|
}
|
|
|
|
const initializationCycle = createInitializationCycleRunner(async () => {
|
|
isSetInitialStateDone = false;
|
|
|
|
if (jsInitChecktimer !== null) {
|
|
clearInterval(jsInitChecktimer);
|
|
jsInitChecktimer = null;
|
|
}
|
|
|
|
await checkForInitialization();
|
|
|
|
if (!isSetInitialStateDone) {
|
|
jsInitChecktimer = setInterval(() => {
|
|
checkForInitialization();
|
|
}, 111);
|
|
|
|
setTimeout(() => {
|
|
if (!isSetInitialStateDone) {
|
|
checkForInitialization();
|
|
}
|
|
}, 2000);
|
|
}
|
|
});
|
|
|
|
function triggerInitializationCycle() {
|
|
return initializationCycle.request();
|
|
}
|
|
|
|
async function setEventListeners() {
|
|
await triggerInitializationCycle();
|
|
}
|
|
|
|
document.addEventListener("yt-navigate-start", function () {
|
|
clearVoteDataRequestCache();
|
|
shortsRouteEpoch += 1;
|
|
pendingShortsMutationSurface = null;
|
|
pendingNavigation.begin(() => {
|
|
let controls;
|
|
if (initializedButtons) {
|
|
controls = {
|
|
buttons: initializedButtons,
|
|
dislikeButton: initializedDislikeButton,
|
|
likeButton: initializedLikeButton,
|
|
nativeDislikeButton: initializedNativeDislikeButton,
|
|
nativeLikeButton: initializedNativeLikeButton,
|
|
};
|
|
} else {
|
|
const currentControls = getButtonControls();
|
|
controls = currentControls.ready ? currentControls : null;
|
|
}
|
|
return {
|
|
controls,
|
|
videoId: initializedVideoId ?? getVideoId(window.location.href),
|
|
};
|
|
});
|
|
if (jsInitChecktimer !== null) {
|
|
clearInterval(jsInitChecktimer);
|
|
jsInitChecktimer = null;
|
|
}
|
|
|
|
clearRenderedVoteState(initializedButtons ? getButtonControls(initializedButtons) : undefined);
|
|
initializedVideoId = null;
|
|
initializedButtons = null;
|
|
initializedLikeButton = null;
|
|
initializedDislikeButton = null;
|
|
initializedNativeLikeButton = null;
|
|
initializedNativeDislikeButton = null;
|
|
isSetInitialStateDone = false;
|
|
});
|
|
|
|
document.addEventListener("yt-navigate-finish", async function (event) {
|
|
pendingNavigation.clear();
|
|
syncShortsRouteEpoch();
|
|
await setEventListeners();
|
|
});
|
|
|
|
function watchControlsNeedInitialization() {
|
|
const videoId = getVideoId(window.location.href);
|
|
if (!videoId) return false;
|
|
|
|
let controls;
|
|
const pendingOrigin = pendingNavigation.get();
|
|
if (pendingOrigin) {
|
|
if (!pendingOrigin.videoId || videoId === pendingOrigin.videoId) return false;
|
|
controls = getButtonControls();
|
|
if (!controls.ready) {
|
|
// getButtons() returns no surface when the visible Shorts candidates are
|
|
// ambiguous or when YouTube has replaced the Shorts root before mounting
|
|
// its action bar. Starting the cycle for a changed route is non-mutating;
|
|
// getStableShortsControls() still owns every identity, hydration,
|
|
// inventory, viewport, and stability guard.
|
|
return pendingIncompleteShortsControlsCanInitialize({
|
|
destinationVideoId: videoId,
|
|
isShortsRoute: isShorts(),
|
|
previousVideoId: pendingOrigin.videoId,
|
|
});
|
|
}
|
|
const pendingControlsReady = pendingNavigationControlsAreReady({
|
|
currentControls: controls,
|
|
destinationVideoId: videoId,
|
|
previousControls: pendingOrigin.controls,
|
|
shortsControlsVideoId: isShorts() ? getShortsCandidateVideoId(controls.buttons) : null,
|
|
});
|
|
if (!pendingControlsReady) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
if (initializationCycle.isRunning() || jsInitChecktimer !== null) return false;
|
|
controls ??= getButtonControls();
|
|
if (!controls.ready) return true;
|
|
const { buttons, dislikeButton, likeButton, nativeDislikeButton, nativeLikeButton } = controls;
|
|
const syntheticShortsDislikes = isShorts()
|
|
? buttons?.querySelectorAll("[data-ryd-synthetic-shorts-dislike]").length ?? 0
|
|
: 0;
|
|
const hasNativeShortsDislike = Boolean(
|
|
isShorts() && buttons?.querySelector("dislike-button-view-model, #dislike-button"),
|
|
);
|
|
return (
|
|
initializedVideoId !== videoId ||
|
|
initializedButtons !== buttons ||
|
|
initializedLikeButton !== likeButton ||
|
|
initializedDislikeButton !== dislikeButton ||
|
|
initializedNativeLikeButton !== nativeLikeButton ||
|
|
initializedNativeDislikeButton !== nativeDislikeButton ||
|
|
!buttons?.isConnected ||
|
|
!likeButton?.isConnected ||
|
|
!dislikeButton?.isConnected ||
|
|
!nativeLikeButton?.isConnected ||
|
|
!nativeDislikeButton?.isConnected ||
|
|
!buttons?.contains(likeButton) ||
|
|
!buttons?.contains(dislikeButton) ||
|
|
!likeButton?.contains(nativeLikeButton) ||
|
|
!dislikeButton?.contains(nativeDislikeButton) ||
|
|
(isShorts() &&
|
|
((hasNativeShortsDislike && syntheticShortsDislikes > 0) ||
|
|
(!hasNativeShortsDislike && syntheticShortsDislikes !== 1))) ||
|
|
(!isShorts() && hasLoadedStateForVideo(videoId) && !isLikesDisabled() && !hasUsableRateBar(buttons, videoId))
|
|
);
|
|
}
|
|
|
|
setInterval(() => {
|
|
syncShortsRouteEpoch();
|
|
if (isShorts()) ensureShortsNavigationObserver();
|
|
if (watchControlsNeedInitialization()) void triggerInitializationCycle();
|
|
}, 500);
|
|
|
|
// Navigation must be observable even while the first count request is pending.
|
|
await setEventListeners();
|