diff --git a/src/assets/img/featureMenuGrid.svg b/src/assets/img/featureMenuGrid.svg new file mode 100644 index 00000000..fe666b78 --- /dev/null +++ b/src/assets/img/featureMenuGrid.svg @@ -0,0 +1,7 @@ + + + + + diff --git a/src/assets/img/maximize.svg b/src/assets/img/maximize.svg index 1832546c..616f002c 100644 --- a/src/assets/img/maximize.svg +++ b/src/assets/img/maximize.svg @@ -3,50 +3,23 @@ stroke="currentColor" class="w-6 h-6" fill="none" - height="100%" + height="24" version="1.1" - viewBox="0 0 36 36" - width="100%" + viewBox="0 0 23.999999 24.000001" + width="24" id="svg7037" - sodipodi:docname="maximize.svg" - inkscape:version="1.2.2 (732a01da63, 2022-12-09)" - xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" - xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" xmlns="http://www.w3.org/2000/svg" xmlns:svg="http://www.w3.org/2000/svg"> - - - diff --git a/src/features/featureMenu/index.ts b/src/features/featureMenu/index.ts new file mode 100644 index 00000000..f6a73f47 --- /dev/null +++ b/src/features/featureMenu/index.ts @@ -0,0 +1,133 @@ +import type { YouTubePlayerDiv } from "@/src/types"; +import eventManager from "@/src/utils/EventManager"; +import { isWatchPage, isShortsPage, createTooltip } from "@/src/utils/utilities"; + +function createFeatureMenu() { + // Create the feature menu div + const featureMenu = document.createElement("div"); + featureMenu.id = "yte-feature-menu"; + featureMenu.style.display = "none"; + featureMenu.classList.add("ytp-popup"); + featureMenu.classList.add("ytp-settings-menu"); + + // Create the feature menu panel + const featureMenuPanel = document.createElement("div"); + featureMenuPanel.classList.add("ytp-panel"); + featureMenuPanel.style.display = "contents"; + + // Append the panel to the menu + featureMenu.appendChild(featureMenuPanel); + + // Create the panel menu + const featureMenuPanelMenu = document.createElement("div"); + featureMenuPanelMenu.classList.add("ytp-panel-menu"); + featureMenuPanelMenu.id = "yte-panel-menu"; + featureMenuPanel.appendChild(featureMenuPanelMenu); + + return featureMenu; +} + +function createFeatureMenuButton() { + // Check if the feature menu already exists + const featureMenuExists = document.querySelector("#yte-feature-menu") as HTMLDivElement | null; + const featureMenu = featureMenuExists ? (document.querySelector("#yte-feature-menu") as HTMLDivElement) : createFeatureMenu(); + + // Create the feature menu button + const featureMenuButton = document.createElement("button"); + featureMenuButton.classList.add("ytp-button"); + featureMenuButton.id = "yte-feature-menu-button"; + featureMenuButton.dataset.title = "Feature menu"; + featureMenuButton.style.display = "none"; + // Create the SVG icon for the button + const featureButtonSVG = document.createElementNS("http://www.w3.org/2000/svg", "svg"); + const featureButtonSVGPath = document.createElementNS("http://www.w3.org/2000/svg", "path"); + featureButtonSVG.setAttribute("viewBox", "0 0 36 36"); + featureButtonSVG.setAttribute("height", "48px"); + featureButtonSVG.setAttribute("width", "48px"); + featureButtonSVG.setAttribute("fill", "white"); + featureButtonSVGPath.setAttribute( + "d", + "M 9.1273596,13.56368 H 13.56368 V 9.1273596 H 9.1273596 Z M 15.78184,26.872641 h 4.43632 V 22.43632 h -4.43632 z m -6.6544804,0 H 13.56368 V 22.43632 H 9.1273596 Z m 0,-6.654481 H 13.56368 V 15.78184 H 9.1273596 Z m 6.6544804,0 h 4.43632 V 15.78184 H 15.78184 Z M 22.43632,9.1273596 V 13.56368 h 4.436321 V 9.1273596 Z M 15.78184,13.56368 h 4.43632 V 9.1273596 h -4.43632 z m 6.65448,6.65448 h 4.436321 V 15.78184 H 22.43632 Z m 0,6.654481 h 4.436321 V 22.43632 H 22.43632 Z" + ); + featureButtonSVGPath.setAttribute("fill", "white"); + featureButtonSVG.appendChild(featureButtonSVGPath); + featureMenuButton.appendChild(featureButtonSVG); + + // Get references to various elements and check their existence + const settingsButton = document.querySelector("button.ytp-settings-button") as HTMLButtonElement | null; + if (!settingsButton) return; + const playerContainer = isWatchPage() ? (document.querySelector("div#movie_player") as YouTubePlayerDiv | null) : isShortsPage() ? null : null; + if (!playerContainer) return; + const bottomControls = document.querySelector("div.ytp-chrome-bottom") as HTMLDivElement | null; + if (!bottomControls) return; + + // Create a tooltip for the feature menu button + const { listener: featureMenuButtonMouseOverListener, remove: removeFeatureMenuTooltip } = createTooltip({ + featureName: "featureMenu", + id: "yte-feature-menu-tooltip", + element: featureMenuButton + }); + + // Event listeners for showing and hiding the feature menu + eventManager.addEventListener( + featureMenuButton, + "click", + () => { + const featureMenuVisible = featureMenu.style.display === "block"; + if (featureMenuVisible) { + bottomControls.style.opacity = ""; + featureMenu.style.display = "none"; + featureMenuButtonMouseOverListener(); + } else { + removeFeatureMenuTooltip(); + bottomControls.style.opacity = "1"; + featureMenu.style.display = "block"; + } + }, + "featureMenu" + ); + + eventManager.addEventListener( + featureMenuButton, + "mouseover", + () => { + const featureMenuVisible = featureMenu.style.display === "block"; + if (featureMenuVisible) return; + featureMenuButtonMouseOverListener(); + }, + "featureMenu" + ); + + eventManager.addEventListener( + featureMenuButton, + "mouseleave", + () => { + const featureMenuVisible = featureMenu.style.display === "block"; + if (featureMenuVisible) return; + removeFeatureMenuTooltip(); + }, + "featureMenu" + ); + + // Event listener to hide the menu when clicking outside + document.addEventListener("click", (event) => { + if (!featureMenuButton) return; + if (event.target === featureMenuButton) return; + if (event.target === featureMenu) return; + if (!featureMenu.contains(event.target as Node)) { + featureMenu.style.display = "none"; + bottomControls.style.opacity = ""; + } + }); + + // Insert the feature menu button and feature menu itself + settingsButton.insertAdjacentElement("beforebegin", featureMenuButton); + playerContainer.insertAdjacentElement("afterbegin", featureMenu); +} + +// Function to enable the feature menu +export function enableFeatureMenu() { + const featureMenuButtonExists = document.querySelector("#yte-feature-menu-button") as HTMLButtonElement | null; + if (featureMenuButtonExists) return; + createFeatureMenuButton(); +} diff --git a/src/features/featureMenu/utils.ts b/src/features/featureMenu/utils.ts new file mode 100644 index 00000000..0b829a50 --- /dev/null +++ b/src/features/featureMenu/utils.ts @@ -0,0 +1,175 @@ +import type { FeatureMenuItemIconId, FeatureMenuItemId, FeatureMenuItemLabelId, WithId } from "@/src/types"; +import eventManager, { type FeatureName } from "@/src/utils/EventManager"; +import { waitForAllElements } from "@/src/utils/utilities"; +/** + * Adds a feature item to the feature menu. + * @param icon - The SVG icon for the feature item. + * @param label - The label for the feature item. + * @param listener - The callback function when the item is clicked. + * @param featureName - The name of the feature. + * @param isToggle - (Optional) Indicates if the item is a toggle. + */ +export async function addFeatureItemToMenu({ + icon, + label, + listener, + featureName, + isToggle = false +}: { + icon: SVGElement; + label: string; + listener: () => void; + featureName: FeatureName; + isToggle?: boolean; +}) { + // Wait for the feature menu to exist + await waitForAllElements(["#yte-feature-menu"]); + + // Get the feature menu + const featureMenu = document.querySelector("#yte-feature-menu") as HTMLDivElement | null; + if (!featureMenu) return; + + // Check if the feature item already exists in the menu + const featureExistsInMenu = featureMenu.querySelector(`#yte-feature-${featureName}`) as HTMLDivElement | null; + if (featureExistsInMenu) { + const menuItem = getFeatureMenuItem(featureName); + if (!menuItem) return; + eventManager.removeEventListener(menuItem, "click", featureName); + eventManager.addEventListener( + menuItem, + "click", + () => { + listener(); + if (isToggle) menuItem.ariaChecked = menuItem.ariaChecked ? (!JSON.parse(menuItem.ariaChecked)).toString() : "false"; + }, + featureName + ); + return; + } + + // Get the feature menu panel + const featureMenuPanel = document.querySelector("#yte-panel-menu") as HTMLDivElement | null; + if (!featureMenuPanel) return; + + // Get the IDs for the feature item + const { featureMenuItemIconId, featureMenuItemId, featureMenuItemLabelId } = getFeatureIds(featureName); + + // Create a menu item element + const menuItem = document.createElement("div"); + menuItem.classList.add("ytp-menuitem"); + menuItem.id = featureMenuItemId; + + // Create the menu item icon element + const menuItemIcon = document.createElement("div"); + menuItemIcon.id = featureMenuItemIconId; + menuItemIcon.classList.add("ytp-menuitem-icon"); + menuItemIcon.appendChild(icon); + menuItem.appendChild(menuItemIcon); + + // Create the menu item label element + const menuItemLabel = document.createElement("div"); + menuItemLabel.classList.add("ytp-menuitem-label"); + menuItemLabel.textContent = label; + menuItemLabel.id = featureMenuItemLabelId; + eventManager.addEventListener( + menuItem, + "click", + () => { + listener(); + if (isToggle) menuItem.ariaChecked = menuItem.ariaChecked ? (!JSON.parse(menuItem.ariaChecked)).toString() : "false"; + }, + featureName + ); + menuItem.appendChild(menuItemLabel); + + // If it's a toggle item, create the toggle elements + if (isToggle) { + const menuItemContent = document.createElement("div"); + menuItemContent.classList.add("ytp-menuitem-content"); + const menuItemToggle = document.createElement("div"); + menuItemToggle.classList.add("ytp-menuitem-toggle-checkbox"); + menuItemContent.appendChild(menuItemToggle); + menuItem.appendChild(menuItemContent); + menuItem.ariaChecked = "false"; + } else { + const menuItemContent = document.createElement("div"); + menuItemContent.classList.add("ytp-menuitem-content"); + menuItem.appendChild(menuItemContent); + } + + // Add the item to the feature menu panel + featureMenuPanel.appendChild(menuItem); + + // Adjust the height and width of the feature menu + featureMenu.style.height = `${40 * featureMenuPanel.childElementCount + 16}px`; + featureMenu.style.width = "fit-content"; + // Show the feature menu button since an item has been added + const featureMenuButton = document.querySelector("#yte-feature-menu-button") as HTMLButtonElement | null; + if (featureMenuButton) { + featureMenuButton.style.display = "initial"; + } +} +/** + * Removes a feature item from the feature menu. + * @param featureName - The name of the feature to remove. + */ +export async function removeFeatureItemFromMenu(featureName: FeatureName) { + // Get the unique ID for the feature item + const { featureMenuItemId } = getFeatureIds(featureName); + // Find the feature menu + const featureMenu = document.querySelector("#yte-feature-menu") as HTMLDivElement | null; + if (!featureMenu) return; + // Find the feature menu panel + const featureMenuPanel = featureMenu.querySelector("#yte-panel-menu") as HTMLDivElement | null; + if (!featureMenuPanel) return; + + // Find the specific feature menu item + const featureMenuItem = featureMenuPanel.querySelector(`#${featureMenuItemId}`) as HTMLDivElement | null; + if (!featureMenuItem) return; + + // Remove the feature menu item + featureMenuItem.remove(); + + // Check if there are any items left in the menu + if (featureMenuPanel.childElementCount === 0) { + // If no items are left, hide the menu + featureMenu.style.display = "none"; + + // Find the feature menu button + const featureMenuButton = document.querySelector("#yte-feature-menu-button") as HTMLButtonElement | null; + if (!featureMenuButton) return; + + // Hide the feature menu button since the menu is empty + featureMenuButton.style.display = "none"; + } + + // Adjust the height and width of the feature menu panel + featureMenu.style.height = `${40 * featureMenuPanel.childElementCount + 16}px`; +} + +export function getFeatureIds(featureName: FeatureName): { + featureMenuItemIconId: FeatureMenuItemIconId; + featureMenuItemId: FeatureMenuItemId; + featureMenuItemLabelId: FeatureMenuItemLabelId; +} { + const featureMenuItemIconId: FeatureMenuItemIconId = `yte-${featureName}-icon`; + const featureMenuItemId: FeatureMenuItemId = `yte-feature-${featureName}`; + const featureMenuItemLabelId: FeatureMenuItemLabelId = `yte-${featureName}-label`; + return { + featureMenuItemIconId, + featureMenuItemId, + featureMenuItemLabelId + }; +} +export function getFeatureMenuItemIcon(featureName: FeatureName): HTMLDivElement | null { + const selector: WithId = `#yte-${featureName}-icon`; + return document.querySelector(selector); +} +export function getFeatureMenuItemLabel(featureName: FeatureName): HTMLDivElement | null { + const selector: WithId = `#yte-${featureName}-label`; + return document.querySelector(selector); +} +export function getFeatureMenuItem(featureName: FeatureName): HTMLDivElement | null { + const selector: WithId = `#yte-feature-${featureName}`; + return document.querySelector(selector); +} diff --git a/src/features/loopButton/index.ts b/src/features/loopButton/index.ts index 850d0090..8441b71d 100644 --- a/src/features/loopButton/index.ts +++ b/src/features/loopButton/index.ts @@ -1,5 +1,7 @@ import { waitForSpecificMessage } from "@/src/utils/utilities"; -import { makeLoopOffButton } from "./utils"; +import { loopButtonClickListener, makeLoopIcon } from "./utils"; +import { addFeatureItemToMenu, removeFeatureItemFromMenu } from "../featureMenu/utils"; +import eventManager from "@/src/utils/EventManager"; export async function addLoopButton() { // Wait for the "options" message from the content script @@ -12,17 +14,22 @@ export async function addLoopButton() { const { enable_loop_button } = options; // If the loop button option is disabled, return if (!enable_loop_button) return; - const loopButtonExists = document.querySelector("button#yte-loop-button") as HTMLButtonElement | null; - if (loopButtonExists) return; // Get the volume control element const volumeControl = document.querySelector("div.ytp-chrome-controls > div.ytp-left-controls > span.ytp-volume-area") as HTMLSpanElement | null; // If volume control element is not available, return if (!volumeControl) return; - const loopButton = makeLoopOffButton(); - volumeControl.before(loopButton); + const videoElement = document.querySelector("video.html5-main-video") as HTMLVideoElement | null; + if (!videoElement) return; + const loopSVG = makeLoopIcon(); + addFeatureItemToMenu({ + icon: loopSVG, + label: `Loop`, + featureName: "loopButton", + listener: loopButtonClickListener, + isToggle: true + }); } export function removeLoopButton() { - const loopButton = document.querySelector("button#yte-loop-button") as HTMLButtonElement | null; - if (!loopButton) return; - loopButton.remove(); + removeFeatureItemFromMenu("loopButton"); + eventManager.removeEventListeners("loopButton"); } diff --git a/src/features/loopButton/utils.ts b/src/features/loopButton/utils.ts index 905b1696..35df45b8 100644 --- a/src/features/loopButton/utils.ts +++ b/src/features/loopButton/utils.ts @@ -1,136 +1,26 @@ -import eventManager from "@/src/utils/EventManager"; -import { createTooltip } from "@/src/utils/utilities"; +import { getFeatureMenuItem } from "../featureMenu/utils"; -async function loopButtonClickListener() { +export async function loopButtonClickListener() { const videoElement = document.querySelector("video.html5-main-video") as HTMLVideoElement | null; if (!videoElement) return; - const loopButton = document.querySelector("button#yte-loop-button") as HTMLButtonElement | null; - if (!loopButton) return; + const loopMenuItem = getFeatureMenuItem("loopButton"); + if (!loopMenuItem) return; const loop = videoElement.hasAttribute("loop"); if (loop) { - loopButton.dataset.title = "Loop Off"; videoElement.removeAttribute("loop"); } else { videoElement.setAttribute("loop", ""); - loopButton.dataset.title = "Loop On"; } - const loopButtonSVG = loop ? makeLoopOffSVG() : makeLoopOnSVG(); - loopButton.removeChild(loopButton.firstChild as Node); - loopButton.appendChild(loopButtonSVG); } -export function makeLoopOnButton() { - const loopOnButton = document.createElement("button"); - loopOnButton.classList.add("ytp-button"); - loopOnButton.id = "yte-loop-button"; - loopOnButton.dataset.title = "Loop On"; - loopOnButton.style.padding = "0px"; - loopOnButton.style.display = "flex"; - loopOnButton.style.alignItems = "center"; - loopOnButton.style.justifyContent = "center"; - const loopOnButtonSVG = makeLoopOnSVG(); - loopOnButton.appendChild(loopOnButtonSVG); - const { listener: loopOnButtonMouseOverListener, update } = createTooltip({ - element: loopOnButton, - id: "yte-loop-button-tooltip", - featureName: "loopButton" - }); - eventManager.addEventListener( - loopOnButton, - "click", - () => { - loopButtonClickListener(); - update(); - }, - "loopButton" - ); - eventManager.addEventListener(loopOnButton, "mouseover", loopOnButtonMouseOverListener, "loopButton"); - return loopOnButton; -} -export function makeLoopOffButton() { - const loopOffButton = document.createElement("button"); - loopOffButton.classList.add("ytp-button"); - loopOffButton.id = "yte-loop-button"; - loopOffButton.dataset.title = "Loop Off"; - loopOffButton.style.padding = "0px"; - loopOffButton.style.display = "flex"; - loopOffButton.style.alignItems = "center"; - loopOffButton.style.justifyContent = "center"; - const loopOffButtonSVG = makeLoopOffSVG(); - loopOffButton.appendChild(loopOffButtonSVG); - const { listener: loopOffButtonListener, update } = createTooltip({ - element: loopOffButton, - id: "yte-loop-button-tooltip", - featureName: "loopButton" - }); - eventManager.addEventListener( - loopOffButton, - "click", - () => { - loopButtonClickListener(); - update(); - }, - "loopButton" - ); - eventManager.addEventListener(loopOffButton, "mouseover", loopOffButtonListener, "loopButton"); - return loopOffButton; -} -function makeLoopOnSVG(): SVGElement { - const loopOnSVG = document.createElementNS("http://www.w3.org/2000/svg", "svg"); - loopOnSVG.setAttributeNS(null, "stroke", "currentColor"); - loopOnSVG.setAttributeNS(null, "stroke-width", "1.5"); - loopOnSVG.setAttributeNS(null, "fill", "currentColor"); - loopOnSVG.setAttributeNS(null, "height", "36"); - loopOnSVG.setAttributeNS(null, "width", "36"); - loopOnSVG.setAttributeNS(null, "viewBox", "0 0 36 36"); - const loopOnGroup = document.createElementNS("http://www.w3.org/2000/svg", "g"); - loopOnGroup.setAttributeNS(null, "transform", "matrix(0.0943489,0,0,-0.09705882,-1.9972187,36.735291)"); - const loopOnTopArrowPath = document.createElementNS("http://www.w3.org/2000/svg", "path"); - loopOnTopArrowPath.setAttributeNS( - null, - "d", - "m 120.59273,172.42419 v 20.60606 20.60606 l -1e-5,20.60608 v 20.60606 h 40.55254 40.55253 40.55255 40.55253 v 30.9091 l 50.69068,-41.21213 -50.69068,-51.51516 v 30.9091 h -32.94893 -32.94893 -32.94895 -32.94893 v -12.8788 -12.87879 -12.87879 -12.87879 h -7.6036 -7.6036 -7.6036 z" - ); - loopOnTopArrowPath.setAttributeNS(null, "transform", "matrix(1.0454545,0,0,0.99999979,-14.814644,20.606103)"); - const loopOnBottomArrowPath = document.createElementNS("http://www.w3.org/2000/svg", "path"); - loopOnBottomArrowPath.setAttributeNS( - null, - "d", - "m 313.21727,172.4242 1e-5,-82.42427 H 151.00712 V 59.09087 l -50.69065,41.21209 50.69065,51.51516 v -30.90909 h 131.79575 v 51.51517 z" - ); - loopOnBottomArrowPath.setAttributeNS(null, "transform", "matrix(1.0454545,0,0,0.99999979,-14.814644,20.606103)"); - loopOnGroup.appendChild(loopOnTopArrowPath); - loopOnGroup.appendChild(loopOnBottomArrowPath); - loopOnSVG.appendChild(loopOnGroup); - return loopOnSVG; -} -function makeLoopOffSVG(): SVGElement { - const loopOffSVG = document.createElementNS("http://www.w3.org/2000/svg", "svg"); - loopOffSVG.setAttributeNS(null, "stroke", "currentColor"); - loopOffSVG.setAttributeNS(null, "stroke-width", "1.5"); - loopOffSVG.setAttributeNS(null, "fill", "currentColor"); - loopOffSVG.setAttributeNS(null, "height", "36"); - loopOffSVG.setAttributeNS(null, "width", "36"); - loopOffSVG.setAttributeNS(null, "viewBox", "0 0 36 36"); - const loopOffG = document.createElementNS("http://www.w3.org/2000/svg", "g"); - loopOffG.setAttributeNS(null, "transform", "matrix(0.09863748,0,0,-0.0970588,-3.3949621,34.735285)"); - const loopOffTopArrowPath = document.createElementNS("http://www.w3.org/2000/svg", "path"); - loopOffTopArrowPath.setAttributeNS( - null, - "d", - "M 282.80287,285.75755 V 270.303 254.84845 h -81.10508 -57.44282 l 35.48347,-30.9091 h 103.06443 v -15.45454 -15.45455 l 25.34533,25.75758 25.34534,25.75758 -25.34534,20.60607 z M 151.00712,211.73026 v -39.30607 h -15.2072 -15.2072 v 65.93941 z" - ); - const loopOffBottomArrowPath = document.createElementNS("http://www.w3.org/2000/svg", "path"); - loopOffBottomArrowPath.setAttributeNS( - null, - "d", - "m 282.80287,172.42419 v -25.75758 -12.51658 l 30.4144,-26.48201 v 23.54404 41.21213 h -15.2072 z M 151.00712,151.81812 125.66179,126.06054 100.31645,100.30296 125.66179,79.696895 151.00712,59.090829 v 15.45455 15.454549 h 81.10508 58.45648 l -35.48347,30.909102 h -38.18022 -65.89787 v 15.45455 z" - ); - const loopOffLinePath = document.createElementNS("http://www.w3.org/2000/svg", "path"); - loopOffLinePath.setAttributeNS(null, "d", "M 7,10 28,28 29.981167,25.855305 8.9811674,7.8553045 Z"); - loopOffLinePath.setAttributeNS(null, "transform", "matrix(10.138134,0,0,-10.303033,29.349514,357.87878)"); - loopOffG.appendChild(loopOffTopArrowPath); - loopOffG.appendChild(loopOffBottomArrowPath); - loopOffG.appendChild(loopOffLinePath); - loopOffSVG.appendChild(loopOffG); - return loopOffSVG; +export function makeLoopIcon() { + const loopSVG = document.createElementNS("http://www.w3.org/2000/svg", "svg"); + loopSVG.setAttributeNS(null, "stroke-width", "0"); + loopSVG.setAttributeNS(null, "fill", "white"); + loopSVG.setAttributeNS(null, "height", "24"); + loopSVG.setAttributeNS(null, "width", "24"); + loopSVG.setAttributeNS(null, "viewBox", "0 0 24 24"); + const loopPath = document.createElementNS("http://www.w3.org/2000/svg", "path"); + loopPath.setAttributeNS(null, "d", "M7 7h10v3l4-4-4-4v3H5v6h2V7zm10 10H7v-3l-4 4 4 4v-3h12v-6h-2v4z"); + loopSVG.appendChild(loopPath); + return loopSVG; } diff --git a/src/features/maximizePlayerButton/index.ts b/src/features/maximizePlayerButton/index.ts index f3f7fc3e..efabcada 100644 --- a/src/features/maximizePlayerButton/index.ts +++ b/src/features/maximizePlayerButton/index.ts @@ -1,7 +1,8 @@ import type { YouTubePlayerDiv } from "@/src/types"; import eventManager from "@/src/utils/EventManager"; -import { createTooltip, waitForSpecificMessage } from "@/src/utils/utilities"; +import { waitForSpecificMessage } from "@/src/utils/utilities"; import { makeMaximizeSVG, updateProgressBarPositions, setupVideoPlayerTimeUpdate, maximizePlayer } from "./utils"; +import { addFeatureItemToMenu, getFeatureMenuItem, removeFeatureItemFromMenu } from "../featureMenu/utils"; // TODO: fix the "default/theatre" view button and pip button not making the player minimize to the previous state. export async function addMaximizePlayerButton(): Promise { // Wait for the "options" message from the content script @@ -14,45 +15,14 @@ export async function addMaximizePlayerButton(): Promise { const { enable_maximize_player_button: enableMaximizePlayerButton } = options; // If the maximize player button option is disabled, return if (!enableMaximizePlayerButton) return; - const maximizePlayerButtonExists = document.querySelector("button.yte-maximize-player-button") as HTMLButtonElement | null; - // If maximize player button already exists, return - if (maximizePlayerButtonExists) return; - // Get the volume control element - const sizeButton = document.querySelector( - "div.ytp-chrome-controls > div.ytp-right-controls > button.ytp-button.ytp-size-button" - ) as HTMLButtonElement | null; - // If volume control element is not available, return - if (!sizeButton) return; - // Create the maximize player button element - const maximizePlayerButton = document.createElement("button"); - maximizePlayerButton.classList.add("ytp-button"); - maximizePlayerButton.classList.add("yte-maximize-player-button"); - maximizePlayerButton.dataset.title = "Maximize Player"; const maximizeSVG = makeMaximizeSVG(); - maximizePlayerButton.appendChild(maximizeSVG); // Add a click event listener to the maximize button async function maximizePlayerButtonClickListener() { - maximizePlayer(maximizePlayerButton); + maximizePlayer(); updateProgressBarPositions(); setupVideoPlayerTimeUpdate(); } - // Append the maximize player button to before the volume control element - sizeButton.before(maximizePlayerButton); - const { listener: maximizePlayerButtonMouseOverListener, update } = createTooltip({ - featureName: "maximizePlayerButton", - element: maximizePlayerButton, - id: "yte-maximize-player-button-tooltip" - }); - eventManager.addEventListener( - maximizePlayerButton, - "click", - () => { - maximizePlayerButtonClickListener(); - update(); - }, - "maximizePlayerButton" - ); - eventManager.addEventListener(maximizePlayerButton, "mouseover", maximizePlayerButtonMouseOverListener, "maximizePlayerButton"); + const pipElement: HTMLButtonElement | null = document.querySelector("button.ytp-pip-button"); const sizeElement: HTMLButtonElement | null = document.querySelector("button.ytp-size-button"); const miniPlayerElement: HTMLButtonElement | null = document.querySelector("button.ytp-miniplayer-button"); @@ -64,9 +34,19 @@ export async function addMaximizePlayerButton(): Promise { const videoContainer = document.querySelector("#movie_player") as YouTubePlayerDiv | null; if (!videoContainer) return; if (videoContainer.classList.contains("maximized_video_container") && videoElement.classList.contains("maximized_video")) { - maximizePlayer(maximizePlayerButton); + const maximizePlayerMenuItem = getFeatureMenuItem("maximizePlayerButton"); + if (!maximizePlayerMenuItem) return; + maximizePlayer(); + maximizePlayerMenuItem.ariaChecked = "false"; } } + addFeatureItemToMenu({ + featureName: "maximizePlayerButton", + icon: maximizeSVG, + label: "Maximize Player", + listener: maximizePlayerButtonClickListener, + isToggle: true + }); function ytpLeftButtonMouseEnterListener(event: MouseEvent) { const ytTooltip = document.querySelector("#movie_player > div.ytp-tooltip") as HTMLDivElement | null; if (!ytTooltip) return; @@ -176,11 +156,6 @@ export async function addMaximizePlayerButton(): Promise { }); } export async function removeMaximizePlayerButton(): Promise { - // Try to get the existing maximize player button element - const maximizePlayerButton = document.querySelector("button.yte-maximize-player-button") as HTMLButtonElement | null; - // If maximize player button is not available, return - if (!maximizePlayerButton) return; - // Remove the maximize player button element - maximizePlayerButton.remove(); + removeFeatureItemFromMenu("maximizePlayerButton"); eventManager.removeEventListeners("maximizePlayerButton"); } diff --git a/src/features/maximizePlayerButton/utils.ts b/src/features/maximizePlayerButton/utils.ts index 278ceacf..3c9b8af9 100644 --- a/src/features/maximizePlayerButton/utils.ts +++ b/src/features/maximizePlayerButton/utils.ts @@ -9,19 +9,19 @@ export function makeMaximizeSVG(): SVGElement { maximizeSVG.setAttributeNS(null, "width", "100%"); maximizeSVG.setAttributeNS(null, "fill", "none"); maximizeSVG.setAttributeNS(null, "stroke-width", "1.5"); - maximizeSVG.setAttributeNS(null, "viewBox", "0 0 36 36"); + maximizeSVG.setAttributeNS(null, "viewBox", "0 0 24 24"); const maximize_SVG_FirstPath = document.createElementNS("http://www.w3.org/2000/svg", "path"); maximize_SVG_FirstPath.setAttributeNS( null, "d", - "M 26.171872,26.171876 H 9.8281282 V 9.8281241 H 26.171872 Z m -16.3437437,0 V 9.8281241 H 26.171872 V 26.171876 Z" + "M 21.283309,21.283314 H 2.7166914 V 2.7166868 H 21.283309 Z m -18.5666175,0 V 2.7166868 H 21.283309 V 21.283314 Z" ); maximize_SVG_FirstPath.setAttributeNS(null, "stroke-linecap", "round"); maximize_SVG_FirstPath.setAttributeNS(null, "stroke-linejoin", "round"); maximize_SVG_FirstPath.style.strokeWidth = "1.5"; maximize_SVG_FirstPath.style.strokeLinejoin = "round"; const maximize_SVG_SecondPath = document.createElementNS("http://www.w3.org/2000/svg", "path"); - maximize_SVG_SecondPath.setAttributeNS(null, "d", "m 18,14.497768 v 7.004464 M 21.502231,18 h -7.004462"); + maximize_SVG_SecondPath.setAttributeNS(null, "d", "M 12,8.0214379 V 15.978562 M 15.978561,12 H 8.0214389"); maximize_SVG_SecondPath.setAttributeNS(null, "stroke-linecap", "round"); maximize_SVG_SecondPath.setAttributeNS(null, "stroke-linejoin", "round"); maximize_SVG_SecondPath.style.strokeWidth = "1.5"; @@ -30,34 +30,8 @@ export function makeMaximizeSVG(): SVGElement { maximizeSVG.appendChild(maximize_SVG_SecondPath); return maximizeSVG; } -export function makeMinimizeSVG(): SVGElement { - const minimizeSVG = document.createElementNS("http://www.w3.org/2000/svg", "svg"); - minimizeSVG.setAttributeNS(null, "stroke", "currentColor"); - minimizeSVG.setAttributeNS(null, "height", "100%"); - minimizeSVG.setAttributeNS(null, "width", "100%"); - minimizeSVG.setAttributeNS(null, "fill", "none"); - minimizeSVG.setAttributeNS(null, "stroke-width", "1.5"); - minimizeSVG.setAttributeNS(null, "viewBox", "0 0 36 36"); - const minimize_SVG_FirstPath = document.createElementNS("http://www.w3.org/2000/svg", "path"); - minimize_SVG_FirstPath.setAttributeNS( - null, - "d", - "M 26.171872,26.171876 H 9.8281282 V 9.8281241 H 26.171872 Z m -16.3437437,0 V 9.8281241 H 26.171872 V 26.171876 Z" - ); - minimize_SVG_FirstPath.setAttributeNS(null, "stroke-linecap", "round"); - minimize_SVG_FirstPath.setAttributeNS(null, "stroke-linejoin", "round"); - minimize_SVG_FirstPath.style.strokeWidth = "1.5"; - minimize_SVG_FirstPath.style.strokeLinejoin = "round"; - const minimize_SVG_SecondPath = document.createElementNS("http://www.w3.org/2000/svg", "path"); - minimize_SVG_SecondPath.setAttributeNS(null, "d", "M 21.502231,18 H 14.497769"); - minimize_SVG_SecondPath.setAttributeNS(null, "stroke-linecap", "round"); - minimize_SVG_SecondPath.setAttributeNS(null, "stroke-linejoin", "round"); - minimize_SVG_SecondPath.style.strokeWidth = "1.5"; - minimize_SVG_SecondPath.style.strokeLinejoin = "round"; - minimizeSVG.appendChild(minimize_SVG_FirstPath); - minimizeSVG.appendChild(minimize_SVG_SecondPath); - return minimizeSVG; -} // TODO: get played progress bar to be accurate when maximized from default view + +// TODO: get played progress bar to be accurate when maximized from default view // TODO: Add event listener that updates scrubber position when maximize button is clicked export function updateProgressBarPositions() { const seekBar = document.querySelector("div.ytp-progress-bar") as HTMLDivElement | null; @@ -84,7 +58,7 @@ export function setupVideoPlayerTimeUpdate() { }; eventManager.addEventListener(videoElement, "timeupdate", videoPlayerTimeUpdateListener, "maximizePlayerButton"); } -export function maximizePlayer(maximizePlayerButton: HTMLButtonElement) { +export function maximizePlayer() { // Get the video element const videoElement = document.querySelector("video.video-stream.html5-main-video") as HTMLVideoElement | null; // If video element is not available, return @@ -143,13 +117,10 @@ export function maximizePlayer(maximizePlayerButton: HTMLButtonElement) { // sizeElement.dataset.titleNoTooltip = dataTitleNoTooltip; // sizeElement.title = title; // } - - maximizePlayerButton.dataset.title = "Maximize Player"; + document.body.style.overflow = ""; videoElement.classList.remove("maximized_video"); videoContainer.classList.remove("maximized_video_container"); controlsElement.classList.remove("maximized_controls"); - maximizePlayerButton.removeChild(maximizePlayerButton.firstChild as Node); - maximizePlayerButton.appendChild(makeMaximizeSVG()); } else { // sizeElement.click(); @@ -166,12 +137,9 @@ export function maximizePlayer(maximizePlayerButton: HTMLButtonElement) { // sizeElement.dataset.titleNoTooltip = dataTitleNoTooltip; // sizeElement.title = title; // } - - maximizePlayerButton.dataset.title = "Minimize Player"; + document.body.style.overflow = "hidden"; videoElement.classList.add("maximized_video"); videoContainer.classList.add("maximized_video_container"); controlsElement.classList.add("maximized_controls"); - maximizePlayerButton.removeChild(maximizePlayerButton.firstChild as Node); - maximizePlayerButton.appendChild(makeMinimizeSVG()); } } diff --git a/src/features/remainingTime/index.ts b/src/features/remainingTime/index.ts index 9c00b1c0..7e8b7de8 100644 --- a/src/features/remainingTime/index.ts +++ b/src/features/remainingTime/index.ts @@ -14,7 +14,7 @@ async function playerTimeUpdateListener() { if (!playerContainer) return; // Get the video element - const videoElement = playerContainer.childNodes[0]?.childNodes[0] as HTMLVideoElement | null; + const videoElement = playerContainer.querySelector("video") as HTMLVideoElement | null; // If video element is not available, return if (!videoElement) return; @@ -46,7 +46,7 @@ export async function setupRemainingTime() { // If player element is not available, return if (!playerContainer) return; // Get the video element - const videoElement = playerContainer.childNodes[0]?.childNodes[0] as HTMLVideoElement | null; + const videoElement = playerContainer.querySelector("video") as HTMLVideoElement | null; // If video element is not available, return if (!videoElement) return; const remainingTime = await calculateRemainingTime({ videoElement, playerContainer }); diff --git a/src/features/screenshotButton/index.ts b/src/features/screenshotButton/index.ts index d71a9651..c4219c34 100644 --- a/src/features/screenshotButton/index.ts +++ b/src/features/screenshotButton/index.ts @@ -1,5 +1,6 @@ import eventManager from "@/src/utils/EventManager"; -import { createTooltip, waitForSpecificMessage } from "@/src/utils/utilities"; +import { waitForSpecificMessage } from "@/src/utils/utilities"; +import { addFeatureItemToMenu, removeFeatureItemFromMenu } from "../featureMenu/utils"; async function takeScreenshot(videoElement: HTMLVideoElement) { try { @@ -65,44 +66,7 @@ export async function addScreenshotButton(): Promise { const { enable_screenshot_button: enableScreenshotButton } = options; // If the screenshot button option is disabled, return if (!enableScreenshotButton) return; - const screenshotButtonExists = document.querySelector("button.yte-screenshot-button") as HTMLButtonElement | null; - // If screenshot button already exists, return - if (screenshotButtonExists) return; - // Get the volume control element - const volumeControl = document.querySelector("div.ytp-chrome-controls > div.ytp-left-controls > span.ytp-volume-area") as HTMLSpanElement | null; - // If volume control element is not available, return - if (!volumeControl) return; - // Create the screenshot button element - const screenshotButton = document.createElement("button"); - screenshotButton.classList.add("ytp-button"); - screenshotButton.classList.add("yte-screenshot-button"); - screenshotButton.dataset.title = "Screenshot"; - screenshotButton.style.padding = "0px"; - screenshotButton.style.display = "flex"; - screenshotButton.style.alignItems = "center"; - screenshotButton.style.justifyContent = "center"; - const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); - svg.style.height = "28px"; - svg.style.width = "28px"; - svg.setAttributeNS(null, "stroke-width", "1.5"); - svg.setAttributeNS(null, "stroke", "currentColor"); - svg.setAttributeNS(null, "fill", "none"); - svg.setAttributeNS(null, "viewBox", "0 0 24 24"); - const firstPath = document.createElementNS("http://www.w3.org/2000/svg", "path"); - firstPath.setAttributeNS( - null, - "d", - "M6.827 6.175A2.31 2.31 0 015.186 7.23c-.38.054-.757.112-1.134.175C2.999 7.58 2.25 8.507 2.25 9.574V18a2.25 2.25 0 002.25 2.25h15A2.25 2.25 0 0021.75 18V9.574c0-1.067-.75-1.994-1.802-2.169a47.865 47.865 0 00-1.134-.175 2.31 2.31 0 01-1.64-1.055l-.822-1.316a2.192 2.192 0 00-1.736-1.039 48.774 48.774 0 00-5.232 0 2.192 2.192 0 00-1.736 1.039l-.821 1.316z" - ); - firstPath.setAttributeNS(null, "stroke-linecap", "round"); - firstPath.setAttributeNS(null, "stroke-linejoin", "round"); - const secondPath = document.createElementNS("http://www.w3.org/2000/svg", "path"); - secondPath.setAttributeNS(null, "d", "M16.5 12.75a4.5 4.5 0 11-9 0 4.5 4.5 0 019 0zM18.75 10.5h.008v.008h-.008V10.5z"); - secondPath.setAttributeNS(null, "stroke-linecap", "round"); - secondPath.setAttributeNS(null, "stroke-linejoin", "round"); - svg.appendChild(firstPath); - svg.appendChild(secondPath); - screenshotButton.appendChild(svg); + // Add a click event listener to the screenshot button async function screenshotButtonClickListener() { // Get the video element @@ -116,30 +80,38 @@ export async function addScreenshotButton(): Promise { console.error(error); } } - // Append the screenshot button to before the volume control element - volumeControl.before(screenshotButton); - const { listener: screenshotButtonMouseOverListener, update } = createTooltip({ - element: screenshotButton, + addFeatureItemToMenu({ featureName: "screenshotButton", - id: "yte-screenshot-tooltip" + icon: makeScreenshotIcon(), + label: "Screenshot", + listener: screenshotButtonClickListener }); - eventManager.addEventListener( - screenshotButton, - "click", - () => { - screenshotButtonClickListener(); - update(); - }, - "screenshotButton" - ); - eventManager.addEventListener(screenshotButton, "mouseover", screenshotButtonMouseOverListener, "screenshotButton"); } export async function removeScreenshotButton(): Promise { - // Try to get the existing screenshot button element - const screenshotButton = document.querySelector("button.yte-screenshot-button") as HTMLButtonElement | null; - // If screenshot button is not available, return - if (!screenshotButton) return; - // Remove the screenshot button element - screenshotButton.remove(); + removeFeatureItemFromMenu("screenshotButton"); eventManager.removeEventListeners("screenshotButton"); } +function makeScreenshotIcon() { + const screenshotSVG = document.createElementNS("http://www.w3.org/2000/svg", "svg"); + screenshotSVG.style.height = "24px"; + screenshotSVG.style.width = "24px"; + screenshotSVG.setAttributeNS(null, "stroke-width", "1.5"); + screenshotSVG.setAttributeNS(null, "stroke", "currentColor"); + screenshotSVG.setAttributeNS(null, "fill", "none"); + screenshotSVG.setAttributeNS(null, "viewBox", "0 0 24 24"); + const firstPath = document.createElementNS("http://www.w3.org/2000/svg", "path"); + firstPath.setAttributeNS( + null, + "d", + "M6.827 6.175A2.31 2.31 0 015.186 7.23c-.38.054-.757.112-1.134.175C2.999 7.58 2.25 8.507 2.25 9.574V18a2.25 2.25 0 002.25 2.25h15A2.25 2.25 0 0021.75 18V9.574c0-1.067-.75-1.994-1.802-2.169a47.865 47.865 0 00-1.134-.175 2.31 2.31 0 01-1.64-1.055l-.822-1.316a2.192 2.192 0 00-1.736-1.039 48.774 48.774 0 00-5.232 0 2.192 2.192 0 00-1.736 1.039l-.821 1.316z" + ); + firstPath.setAttributeNS(null, "stroke-linecap", "round"); + firstPath.setAttributeNS(null, "stroke-linejoin", "round"); + const secondPath = document.createElementNS("http://www.w3.org/2000/svg", "path"); + secondPath.setAttributeNS(null, "d", "M16.5 12.75a4.5 4.5 0 11-9 0 4.5 4.5 0 019 0zM18.75 10.5h.008v.008h-.008V10.5z"); + secondPath.setAttributeNS(null, "stroke-linecap", "round"); + secondPath.setAttributeNS(null, "stroke-linejoin", "round"); + screenshotSVG.appendChild(firstPath); + screenshotSVG.appendChild(secondPath); + return screenshotSVG; +} diff --git a/src/pages/content/index.tsx b/src/pages/content/index.tsx index 91738881..3c82a7dd 100644 --- a/src/pages/content/index.tsx +++ b/src/pages/content/index.tsx @@ -1,17 +1,19 @@ -import type { ExtensionSendOnlyMessageMappings, Messages, YouTubePlayerDiv } from "@/src/types"; -import { browserColorLog, formatError } from "@/utils/utilities"; -import eventManager from "@/utils/EventManager"; +import { enableFeatureMenu } from "@/src/features/featureMenu"; +import { addLoopButton, removeLoopButton } from "@/src/features/loopButton"; import { addMaximizePlayerButton, removeMaximizePlayerButton } from "@/src/features/maximizePlayerButton"; import { maximizePlayer } from "@/src/features/maximizePlayerButton/utils"; import setPlayerQuality from "@/src/features/playerQuality"; import setPlayerSpeed from "@/src/features/playerSpeed"; +import { removeRemainingTimeDisplay, setupRemainingTime } from "@/src/features/remainingTime"; import enableRememberVolume from "@/src/features/rememberVolume"; import { addScreenshotButton, removeScreenshotButton } from "@/src/features/screenshotButton"; import adjustVolumeOnScrollWheel from "@/src/features/scrollWheelVolumeControl"; -import { setupVideoHistory, promptUserToResumeVideo } from "@/src/features/videoHistory"; +import { promptUserToResumeVideo, setupVideoHistory } from "@/src/features/videoHistory"; import volumeBoost from "@/src/features/volumeBoost"; -import { removeRemainingTimeDisplay, setupRemainingTime } from "@/src/features/remainingTime"; -import { addLoopButton, removeLoopButton } from "@/src/features/loopButton"; +import eventManager from "@/utils/EventManager"; +import { browserColorLog, formatError } from "@/utils/utilities"; + +import type { ExtensionSendOnlyMessageMappings, Messages, YouTubePlayerDiv } from "@/src/types"; // TODO: Add always show progressbar feature // eslint-disable-next-line @typescript-eslint/no-unused-vars @@ -64,11 +66,12 @@ document.documentElement.appendChild(element); window.onload = function () { enableRememberVolume(); + enableFeatureMenu(); const enableFeatures = () => { - eventManager.removeAllEventListeners(); + eventManager.removeAllEventListeners(["featureMenu"]); addLoopButton(); - addScreenshotButton(); addMaximizePlayerButton(); + addScreenshotButton(); enableRememberVolume(); setPlayerQuality(); setPlayerSpeed(); @@ -162,7 +165,7 @@ window.onload = function () { const videoContainer = document.querySelector("#movie_player") as YouTubePlayerDiv | null; if (!videoContainer) return; if (videoContainer.classList.contains("maximized_video_container") && videoElement.classList.contains("maximized_video")) { - maximizePlayer(maximizePlayerButton); + maximizePlayer(); } } break; diff --git a/src/types.ts b/src/types.ts index 86590254..e15b3be5 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,5 +1,6 @@ import z from "zod"; import type { YouTubePlayer } from "node_modules/@types/youtube-player/dist/types"; +import type { FeatureName } from "./utils/EventManager"; /* eslint-disable no-mixed-spaces-and-tabs */ export type Writeable = { -readonly [P in keyof T]: T[P] }; @@ -148,3 +149,7 @@ export type ConfigurationToZodSchema = z.ZodObject<{ export type Prettify = { [K in keyof T]: T[K]; }; +export type FeatureMenuItemIconId = `yte-${FeatureName}-icon`; +export type FeatureMenuItemId = `yte-feature-${FeatureName}`; +export type FeatureMenuItemLabelId = `yte-${FeatureName}-label`; +export type WithId = `#${S}`; diff --git a/src/utils/EventManager.ts b/src/utils/EventManager.ts index 0d7b0310..a64d13b0 100644 --- a/src/utils/EventManager.ts +++ b/src/utils/EventManager.ts @@ -8,7 +8,8 @@ export type FeatureName = | "playerSpeed" | "playerQuality" | "loopButton" - | "rememberVolume"; + | "rememberVolume" + | "featureMenu"; type EventCallback = (event: HTMLElementEventMap[K]) => void; export interface EventListenerInfo { @@ -37,7 +38,7 @@ export type EventManager = { removeEventListeners: (featureName: FeatureName) => void; - removeAllEventListeners: () => void; + removeAllEventListeners: (exclude?: FeatureName[]) => void; }; export const eventManager: EventManager = { @@ -92,15 +93,15 @@ export const eventManager: EventManager = { }, // Removes all event listeners - removeAllEventListeners: function () { - // Remove all event listeners from all targets - this.listeners.forEach((targetListeners) => { + removeAllEventListeners: function (exclude) { + // Remove all event listeners from all targets excluding the given feature names + this.listeners.forEach((targetListeners, featureName) => { + if (exclude && exclude.includes(featureName)) return; targetListeners.forEach(({ target, eventName, callback }) => { target.removeEventListener(eventName, callback); }); + this.listeners.delete(featureName); }); - // Remove all maps of target listeners from the map - this.listeners.clear(); } }; export default eventManager; diff --git a/src/utils/utilities.ts b/src/utils/utilities.ts index ef3a100d..eb8a6616 100644 --- a/src/utils/utilities.ts +++ b/src/utils/utilities.ts @@ -337,31 +337,12 @@ export function formatError(error: unknown) { */ export function waitForAllElements(selectors: Selector[]): Promise { return new Promise((resolve) => { - setTimeout(() => { - browserColorLog("Waiting for target nodes", "FgMagenta"); - const elementsMap = new Map(); - const { length: selectorsCount } = selectors; - let resolvedCount = 0; - - const observer = new MutationObserver(() => { - selectors.forEach((selector) => { - const element = document.querySelector(selector); - elementsMap.set(selector, element); - if (!element) { - return; - } - - resolvedCount++; - if (resolvedCount === selectorsCount) { - observer.disconnect(); - const resolvedElements = selectors.map((selector) => (elementsMap.get(selector) ? selector : undefined)).filter(Boolean); - resolve(resolvedElements); - } - }); - }); - - observer.observe(document, { childList: true, subtree: true }); + browserColorLog("Waiting for target nodes", "FgMagenta"); + const elementsMap = new Map(); + const { length: selectorsCount } = selectors; + let resolvedCount = 0; + const observer = new MutationObserver(() => { selectors.forEach((selector) => { const element = document.querySelector(selector); elementsMap.set(selector, element); @@ -376,7 +357,24 @@ export function waitForAllElements(selectors: Selector[]): Promise { resolve(resolvedElements); } }); - }, 2_500); + }); + + observer.observe(document, { childList: true, subtree: true }); + + selectors.forEach((selector) => { + const element = document.querySelector(selector); + elementsMap.set(selector, element); + if (!element) { + return; + } + + resolvedCount++; + if (resolvedCount === selectorsCount) { + observer.disconnect(); + const resolvedElements = selectors.map((selector) => (elementsMap.get(selector) ? selector : undefined)).filter(Boolean); + resolve(resolvedElements); + } + }); }); } export function settingsAreDefault(defaultSettings: Partial, currentSettings: Partial): boolean { @@ -437,28 +435,38 @@ export function createTooltip({ element, text, id, featureName }: { text?: strin remove: () => void; update: () => void; } { + function makeTooltip() { + // Create tooltip element + const tooltip = document.createElement("div"); + const rect = element.getBoundingClientRect(); + tooltip.classList.add("yte-button-tooltip"); + tooltip.classList.add("ytp-tooltip"); + tooltip.classList.add("ytp-rounded-tooltip"); + tooltip.classList.add("ytp-bottom"); + tooltip.id = id; + tooltip.style.left = `${rect.left + rect.width / 2}px`; + tooltip.style.top = `${rect.top - 2}px`; + tooltip.style.zIndex = "99999"; + const { + dataset: { title } + } = element; + tooltip.textContent = text ?? title ?? ""; + function mouseLeaveListener() { + tooltip.remove(); + eventManager.removeEventListener(element, "mouseleave", featureName); + } + eventManager.addEventListener(element, "mouseleave", mouseLeaveListener, featureName); + return tooltip; + } return { listener: () => { - // Create tooltip element - const tooltip = document.createElement("div"); - const rect = element.getBoundingClientRect(); - tooltip.classList.add("yte-button-tooltip"); - tooltip.classList.add("ytp-tooltip"); - tooltip.classList.add("ytp-rounded-tooltip"); - tooltip.classList.add("ytp-bottom"); - tooltip.id = id; - tooltip.style.left = `${rect.left + rect.width / 2}px`; - tooltip.style.top = `${rect.top - 2}px`; - tooltip.style.zIndex = "99999"; - const { - dataset: { title } - } = element; - tooltip.textContent = text ?? title ?? ""; - function mouseLeaveListener() { + const tooltipExists = document.getElementById(id) !== null; + if (tooltipExists) { + const tooltip = document.getElementById(id); + if (!tooltip) return; tooltip.remove(); - eventManager.removeEventListener(element, "mouseleave", featureName); } - eventManager.addEventListener(element, "mouseleave", mouseLeaveListener, featureName); + const tooltip = makeTooltip(); document.body.appendChild(tooltip); }, remove: () => {