Selaa lähdekoodia

ref: auto-like toggle button

Sv443 11 kuukautta sitten
vanhempi
commit
89709058aa

+ 24 - 0
assets/icons/auto_like_disabled.svg

@@ -0,0 +1,24 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<svg
+   height="24"
+   viewBox="0 -960 960 960"
+   width="24"
+   fill="#e8eaed"
+   version="1.1"
+   id="svg1"
+   xmlns="http://www.w3.org/2000/svg"
+   xmlns:svg="http://www.w3.org/2000/svg">
+  <defs
+     id="defs1" />
+  <path
+     d="m 520.92907,-327.82878 34.9134,38.1164 H 368.60595 v -189.50038 l 38.11633,38.15053 v 113.23345 z m -233.9302,-228.698 v 228.698 h 81.60708 v 38.1164 H 248.88253 v -304.9307 z"
+     id="path1-5"
+     style="stroke-width:0.635272" />
+  <path
+     d="M 645.68629,-334.18148 718.98401,-505.705 v -43.0016 c 0,-2.2827 -0.73268,-4.1568 -2.19804,-5.6222 -1.46537,-1.4653 -3.33942,-2.198 -5.62217,-2.198 H 492.73179 l 31.85891,-139.2707 -116.2955,115.81281 -28.93858,-25.3463 157.84424,-156.92241 21.49761,21.4977 c 2.6385,2.6385 4.83019,6.1324 6.57507,10.482 1.74064,4.3494 2.61096,8.447 2.61096,12.2925 v 6.448 l -26.97366,116.8901 H 711.1638 c 12.08712,0 22.76394,4.6354 32.03043,13.9061 9.27075,9.2665 13.90612,19.9433 13.90612,32.0304 v 41.0449 c 0,2.6385 -0.34305,5.4888 -1.02914,8.5508 -0.68186,3.062 -1.44631,5.9144 -2.29333,8.5571 l -77.84207,183.75752 z"
+     id="path1-5-4"
+     style="stroke-width:0.635272" />
+  <path
+     d="m 829.77231,-42.419903 -129.15,-129.149987 c -31.48667,23.12667 -65.92333,40.99999 -103.31,53.61998 -37.38667,12.61333 -77.13,18.920007 -119.23,18.920007 -52.56667,0 -101.96667,-9.976677 -148.2,-29.929997 -46.23333,-19.94666 -86.45,-47.01999 -120.65,-81.21999 -34.2,-34.2 -61.27332,-74.41667 -81.21999,-120.65 -19.95332,-46.23333 -29.929987,-95.63333 -29.929987,-148.2 0,-42.1 6.306667,-81.84333 18.919997,-119.23 12.61999,-37.38667 30.49332,-71.82333 53.61998,-103.31 l -121.309977,-121.31 42.77,-42.76 780.459977,780.459991 z m -351.69,-116.609987 c 33.59333,0 65.36333,-5.02667 95.31,-15.08 29.94667,-10.04667 57.69,-23.86667 83.23,-41.46 l -442,-442 c -17.59333,25.54 -31.41333,53.28333 -41.46,83.23 -10.05333,29.94667 -15.08,61.71667 -15.08,95.31 0,88.66667 31.16667,164.16667 93.5,226.5 62.33333,62.33333 137.83333,93.5 226.5,93.5 z m 320,-113.85 -44.38,-44.38 c 14.25333,-23.59333 25.21333,-48.93 32.88,-76.01 7.66667,-27.08 11.5,-55.66667 11.5,-85.76 0,-88.66667 -31.16667,-164.16667 -93.5,-226.5 -62.33333,-62.33333 -137.83333,-93.5 -226.5,-93.5 -30,0 -58.56333,3.83333 -85.69,11.5 -27.12667,7.66667 -52.48667,18.62667 -76.08,32.88 l -44.38,-44.38 c 29.98667,-19.07333 62.31333,-33.84333 96.98,-44.31 34.67333,-10.46 71.06333,-15.69 109.17,-15.69 52.56667,0 101.96667,9.97667 148.2,29.93 46.23333,19.94667 86.45,47.02 120.65,81.22 34.2,34.2 61.27333,74.41667 81.22,120.65 19.95333,46.23333 29.93,95.63333 29.93,148.2 0,38.10667 -5.23,74.49667 -15.69,109.17 -10.46667,34.66667 -25.23667,66.99333 -44.31,96.98 z"
+     id="path1-4" />
+</svg>

+ 20 - 0
assets/icons/auto_like_enabled.svg

@@ -0,0 +1,20 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<svg
+   height="24"
+   viewBox="0 -960 960 960"
+   width="24"
+   fill="#e8eaed"
+   version="1.1"
+   id="svg1"
+   xmlns="http://www.w3.org/2000/svg"
+   xmlns:svg="http://www.w3.org/2000/svg">
+  <defs
+     id="defs1" />
+  <path
+     d="m 480.07,-100 q -78.84,0 -148.21,-29.92 -69.37,-29.92 -120.68,-81.21 -51.31,-51.29 -81.25,-120.63 Q 100,-401.1 100,-479.93 q 0,-78.84 29.92,-148.21 29.92,-69.37 81.21,-120.68 51.29,-51.31 120.63,-81.25 69.34,-29.93 148.17,-29.93 78.84,0 148.21,29.92 69.37,29.92 120.68,81.21 51.31,51.29 81.25,120.63 29.93,69.34 29.93,148.17 0,78.84 -29.92,148.21 -29.92,69.37 -81.21,120.68 -51.29,51.31 -120.63,81.25 Q 558.9,-100 480.07,-100 Z M 480,-160 q 134,0 227,-93 93,-93 93,-227 0,-134 -93,-227 -93,-93 -227,-93 -134,0 -227,93 -93,93 -93,227 0,134 93,227 93,93 227,93 z m 0,-320 z"
+     id="path1" />
+  <path
+     d="m 635.90945,-289.71238 h -267.3035 v -304.9307 l 168.59491,-167.6103 21.49761,21.4977 c 2.6385,2.6385 4.83019,6.1324 6.57507,10.482 1.74064,4.3494 2.61096,8.447 2.61096,12.2925 v 6.448 l -26.97366,116.8901 H 711.1638 c 12.08712,0 22.76394,4.6354 32.03043,13.9061 9.27075,9.2665 13.90612,19.9433 13.90612,32.0304 v 41.0449 c 0,2.6385 -0.34305,5.4888 -1.02914,8.5508 -0.68186,3.062 -1.44631,5.9144 -2.29333,8.5571 l -72.81491,171.8158 c -3.64646,8.1442 -9.75355,15.0178 -18.32126,20.6209 -8.5677,5.6031 -17.47845,8.4047 -26.73226,8.4047 z m -229.18717,-38.1164 h 229.18717 c 1.79148,0 3.62317,-0.4891 5.49511,-1.4675 1.87617,-0.9783 3.30342,-2.6067 4.28173,-4.8852 l 73.29772,-171.5235 v -43.0016 c 0,-2.2827 -0.73268,-4.1568 -2.19804,-5.6222 -1.46537,-1.4653 -3.33942,-2.198 -5.62217,-2.198 H 492.73179 l 31.85891,-139.2707 -117.86842,117.3792 z m 0,-250.5895 v 250.5895 z m -38.11633,-16.2248 v 38.1163 h -81.60708 v 228.698 h 81.60708 v 38.1164 H 248.88253 v -304.9307 z"
+     id="path1-5"
+     style="stroke-width:0.635272" />
+</svg>

+ 3 - 1
assets/resources.json

@@ -7,12 +7,14 @@
   "doc-changelog": "/changelog.md",
   "icon-advanced_mode": "icons/plus_circle_small.svg",
   "icon-arrow_down": "icons/arrow_down.svg",
+  "icon-auto_like_disabled": "icons/auto_like_disabled.svg",
+  "icon-auto_like_enabled": "icons/auto_like_enabled.svg",
   "icon-clear_list": "icons/clear_list.svg",
   "icon-delete": "icons/delete.svg",
   "icon-error": "icons/error.svg",
   "icon-experimental": "icons/beaker_small.svg",
-  "icon-globe": "icons/globe.svg",
   "icon-globe_small": "icons/globe_small.svg",
+  "icon-globe": "icons/globe.svg",
   "icon-help": "icons/help.svg",
   "icon-image_filled": "icons/image_filled.svg",
   "icon-image": "icons/image.svg",

+ 95 - 0
src/components/longButton.ts

@@ -0,0 +1,95 @@
+import { onInteraction, resourceToHTMLString } from "../utils";
+import type { ResourceKey } from "../types";
+
+type LongBtnOptions = (
+  | {
+    /** Resource key for the button icon */
+    resourceName: ResourceKey | "_";
+  }
+  | {
+    src: string;
+  }
+) & (
+  | {
+    /** URL to navigate to when the button is clicked */
+    href: string;
+  }
+  | {
+    /** Callback function to execute when the button is clicked */
+    onClick: (event: MouseEvent | KeyboardEvent) => void;
+  }
+  | {
+    /** Whether the button can be toggled on and off */
+    toggle: true;
+    /** Initial state of the button if `toggle` is `true` - defaults to `false` */
+    toggleInitialState?: boolean;
+    /** Callback function to execute when the button is toggled */
+    onToggle: (enabled: boolean) => void;
+  }
+) & (
+  {
+    /** Button text */
+    text: string;
+    /** Tooltip and aria-label of the button */
+    title: string;
+  }
+);
+
+/**
+ * Creates a generic, circular, long button element with an icon and text.  
+ * Has classes for the enabled and disabled states for easier styling.  
+ * If `href` is provided, the button will be an anchor element.  
+ * If `onClick` or `onToggle` is provided, the button will be a div element.  
+ * Provide either `resourceName` or `src` to specify the icon inside the button.
+ */
+export async function createLongBtn({
+  title,
+  ...rest
+}: LongBtnOptions) {
+  if(["href", "onClick", "onToggle"].every((key) => !(key in rest)))
+    throw new TypeError("Either 'href', 'onClick' or 'onToggle' must be provided");
+
+  let btnElem: HTMLElement;
+  if("href" in rest && rest.href) {
+    btnElem = document.createElement("a");
+    (btnElem as HTMLAnchorElement).href = rest.href;
+    btnElem.role = "button";
+    (btnElem as HTMLAnchorElement).target = "_blank";
+    (btnElem as HTMLAnchorElement).rel = "noopener noreferrer";
+  }
+  else
+    btnElem = document.createElement("div");
+
+  if("toggle" in rest && rest.toggle) {
+    if("toggleInitialState" in rest && rest.toggleInitialState)
+      btnElem.classList.add("toggled");
+  }
+
+  onInteraction(btnElem, () => {
+    if("onClick" in rest && rest.onClick)
+      rest.onClick && onInteraction(btnElem, rest.onClick);
+    if("toggle" in rest && rest.toggle && "onToggle" in rest && rest.onToggle) {
+      const enabled = btnElem.classList.toggle("toggled");
+      //@ts-ignore
+      rest.onToggle(enabled);
+    }
+  });
+
+  btnElem.classList.add("bytm-generic-btn", "long");
+  btnElem.ariaLabel = btnElem.title = title;
+  btnElem.tabIndex = 0;
+  btnElem.role = "button";
+
+  const imgElem = document.createElement("div");
+  imgElem.classList.add("bytm-generic-btn-img");
+  imgElem.innerHTML = "src" in rest ? rest.src : await resourceToHTMLString(rest.resourceName as "_") ?? "";
+
+  const txtElem = document.createElement("span");
+  txtElem.classList.add("bytm-generic-long-btn-txt", "bytm-no-select");
+  txtElem.textContent = txtElem.ariaLabel = rest.text;
+
+  btnElem.appendChild(imgElem);
+  btnElem.appendChild(txtElem);
+
+  return btnElem;
+}

+ 11 - 1
src/dialogs/autoLikeChannels.ts

@@ -8,7 +8,7 @@ let autoLikeChannelsDialog: BytmDialog | null = null;
 /** Creates and/or returns the import dialog */
 export async function getAutoLikeChannelsDialog() {
   if(!autoLikeChannelsDialog) {
-    await autoLikeChannelsStore.loadData();
+    await initAutoLikeChannelsStore();
     autoLikeChannelsDialog = new BytmDialog({
       id: "auto-like-channels",
       width: 600,
@@ -24,6 +24,16 @@ export async function getAutoLikeChannelsDialog() {
   return autoLikeChannelsDialog;
 }
 
+let isLoaded = false;
+
+/** Inits autoLikeChannels DataStore instance */
+export function initAutoLikeChannelsStore() {
+  if(isLoaded)
+    return;
+  isLoaded = true;
+  return autoLikeChannelsStore.loadData();
+}
+
 async function renderHeader() {
   const headerEl = document.createElement("h2");
   headerEl.classList.add("bytm-dialog-title");

+ 28 - 0
src/features/input.css

@@ -1,3 +1,9 @@
+:root {
+  --bytm-auto-like-btn-color: #c47df4;
+  --bytm-auto-like-btn-color-toggled: rgba(201, 122, 254, 0.25);
+  --bytm-auto-like-btn-color-toggled-hover: rgba(201, 122, 254, 0.5);
+}
+
 .bytm-auto-like-channel-row-left-cont {
   display: flex;
   align-items: center;
@@ -51,3 +57,25 @@
   color: #aaa;
   margin-left: 10px;
 }
+
+.bytm-auto-like-toggle-btn {
+  margin-right: 8px;
+  border: 1px solid var(--bytm-auto-like-btn-color);
+  box-sizing: border-box;
+}
+
+.bytm-auto-like-toggle-btn.toggled {
+  background-color: var(--bytm-auto-like-btn-color-toggled, rgba(255, 255, 255, 0.25));
+}
+
+.bytm-auto-like-toggle-btn.toggled:hover {
+  background-color: var(--bytm-auto-like-btn-color-toggled-hover, rgba(255, 255, 255, 0.5));
+}
+
+.bytm-auto-like-toggle-btn .bytm-generic-long-btn-txt {
+  color: var(--bytm-auto-like-btn-color);
+}
+
+.bytm-auto-like-toggle-btn svg path {
+  fill: var(--bytm-auto-like-btn-color);
+}

+ 57 - 11
src/features/input.ts

@@ -1,5 +1,5 @@
 import { DataStore, clamp, compress, decompress } from "@sv443-network/userutils";
-import { error, getVideoTime, info, log, warn, getVideoSelector, getDomain, compressionSupported } from "../utils";
+import { error, getVideoTime, info, log, warn, getVideoSelector, getDomain, compressionSupported, t, clearNode, resourceToHTMLString } from "../utils";
 import type { Domain } from "../types";
 import { isCfgMenuOpen } from "../menu/menu_old";
 import { disableBeforeUnload } from "./behavior";
@@ -7,6 +7,9 @@ import { siteEvents } from "../siteEvents";
 import { featInfo } from "./index";
 import { getFeatures } from "../config";
 import { compressionFormat } from "../constants";
+import { addSelectorListener } from "../observers";
+import { createLongBtn } from "../components/longButton";
+import { initAutoLikeChannelsStore } from "../dialogs";
 import "./input.css";
 
 export const inputIgnoreTagNames = ["INPUT", "TEXTAREA", "SELECT", "BUTTON", "A"];
@@ -174,6 +177,7 @@ export const autoLikeChannelsStore = new DataStore<{
 export async function initAutoLikeChannels() {
   try {
     canCompress = await compressionSupported();
+    await initAutoLikeChannelsStore();
     if(getDomain() === "ytm") {
       let timeout: NodeJS.Timeout;
       // TODO:FIXME: needs actual fix instead of timeout
@@ -205,13 +209,23 @@ export async function initAutoLikeChannels() {
         }, 5_000);
       });
 
-      if(getFeatures().autoLikeChannelToggleButtons) {
-        // TODO:
-        const artistEls = document.querySelectorAll<HTMLElement>(".content-info-wrapper .subtitle a.yt-formatted-string[href]");
-
-        for(const artistEl of artistEls)
-          addAutoLikeToggleBtn(artistEl);
-      }
+      siteEvents.on("pathChanged", (path) => {
+        if(path.match(/\/channel\/.+/)) {
+          const chanId = path.split("/").pop();
+          if(!chanId)
+            return error("Couldn't extract channel ID from URL");
+
+          document.querySelectorAll<HTMLElement>(".bytm-auto-like-toggle-btn").forEach((btn) => clearNode(btn));
+
+          addSelectorListener("browseResponse", "ytmusic-browse-response #header .actions .buttons", {
+            listener(buttonsCont) {
+              const lastBtn = buttonsCont.querySelector<HTMLElement>("ytmusic-subscribe-button-renderer");
+              const chanName = document.querySelector<HTMLElement>("ytmusic-immersive-header-renderer .content-container yt-formatted-string[role=\"heading\"]")?.textContent ?? null;
+              lastBtn && addAutoLikeToggleBtn(lastBtn, chanId, chanName);
+            }
+          });
+        }
+      });
     }
     else if(getDomain() === "yt") {
       // TODO:
@@ -222,7 +236,39 @@ export async function initAutoLikeChannels() {
   }
 }
 
-function addAutoLikeToggleBtn(sibling: HTMLElement) {
-  // TODO:
-  void sibling;
+async function addAutoLikeToggleBtn(sibling: HTMLElement, chanId: string, chanName: string | null) {
+  const chan = autoLikeChannelsStore.getData().channels.find((ch) => ch.id === chanId);
+
+  const buttonEl = await createLongBtn({
+    resourceName: `icon-auto_like${chan?.enabled ? "_enabled" : "_disabled"}`,
+    text: t("auto_like"),
+    title: t("auto_like_channel_toggle"),
+    toggle: true,
+    toggleInitialState: chan?.enabled ?? false,
+    async onToggle(toggled) {
+      const imgEl = buttonEl.querySelector<HTMLElement>(".bytm-generic-btn-img");
+      const imgHtml = await resourceToHTMLString(`icon-auto_like_${toggled ? "enabled" : "disabled"}`);
+      if(imgEl && imgHtml)
+        imgEl.innerHTML = imgHtml;
+
+      if(autoLikeChannelsStore.getData().channels.find((ch) => ch.id === chanId) === undefined) {
+        await autoLikeChannelsStore.setData({
+          channels: [
+            ...autoLikeChannelsStore.getData().channels,
+            { id: chanId, name: chanName ?? "", enabled: toggled },
+          ],
+        });
+      }
+      else {
+        await autoLikeChannelsStore.setData({
+          channels: autoLikeChannelsStore.getData().channels
+            .map((ch) => ch.id === chanId ? { ...ch, enabled: toggled } : ch),
+        });
+      }
+    }
+  });
+  buttonEl.classList.add("bytm-auto-like-toggle-btn");
+  buttonEl.dataset.channelId = chanId;
+
+  sibling.insertAdjacentElement("afterend", buttonEl);
 }

+ 23 - 2
src/features/layout.css

@@ -22,13 +22,13 @@
   max-height: var(--bytm-generic-btn-height);
 
   border: 1px solid transparent;
-  border-radius: 100%;
+  border-radius: calc(var(--bytm-generic-btn-height, 36px) / 2);
   background-color: transparent;
 
   transition: background-color 0.2s ease;
 }
 
-.bytm-generic-btn:hover {
+.bytm-generic-btn:not(.long):hover {
   background-color: rgba(255, 255, 255, 0.2);
 }
 
@@ -37,6 +37,27 @@
   animation: flashBorder 0.4s ease 1;
 }
 
+.bytm-generic-btn.long {
+  --bytm-generic-btn-width: 136px;
+  padding: 0px;
+
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  position: relative;
+  vertical-align: middle;
+  cursor: pointer;
+  margin-left: 0px;
+}
+
+.bytm-generic-btn.long .bytm-generic-btn-img {
+  margin-right: 6px;
+}
+
+.bytm-generic-long-btn-txt {
+  font-size: 14px;
+}
+
 .bytm-ftitem-help-btn.bytm-generic-btn {
   --bytm-generic-btn-width: 24px;
   --bytm-generic-btn-height: 24px;

+ 1 - 1
src/interface.ts

@@ -160,7 +160,7 @@ export function emitInterface<
   getUnsafeWindow().dispatchEvent(new CustomEvent(type, { detail: detail?.[0] ?? undefined }));
   //@ts-ignore
   emitOnPlugins(type, undefined, ...detail);
-  log(`Emitted interface event '${type}'${detail && detail.length > 0 ? " with data:" : ""}`, ...detail);
+  log(`Emitted interface event '${type}'${detail.length > 0 && detail?.[0] ? " with data:" : ""}`, ...detail);
 }
 
 //#region register plugins

+ 15 - 0
src/observers.ts

@@ -15,6 +15,7 @@ export type SharedObserverName =
 
 // YTM only
 export type YTMObserverName =
+  | "browseResponse"
   | "navBar"
   | "mainPanel"
   | "sideBar"
@@ -61,6 +62,18 @@ export function initObservers() {
     case "ytm": {
       //#region YTM
 
+      //#region browseResponse
+      // -> for example the /channel/UC... page
+      const browseResponseSelector = "ytmusic-browse-response";
+      globservers.browseResponse = new SelectorObserver(browseResponseSelector, {
+        ...defaultObserverOptions,
+        subtree: true,
+      });
+
+      globservers.body.addListener(browseResponseSelector, {
+        listener: () => globservers.browseResponse.enable(),
+      });
+
       //#region navBar
       // -> the navigation / title bar at the top of the page
       const navBarSelector = "ytmusic-nav-bar";
@@ -224,6 +237,8 @@ export function initObservers() {
   }
 }
 
+//#region add listener func
+
 /**
  * Interface function for adding listeners to the {@linkcode globservers}  
  * @param selector Relative to the observer's root element, so the selector can only start at of the root element's children at the earliest!

+ 32 - 16
src/siteEvents.ts

@@ -28,14 +28,21 @@ export interface SiteEventsMap {
   /** Emitted whenever child nodes are added to or removed from the autoplay queue underneath the song queue */
   autoplayQueueChanged: (queueElement: HTMLElement) => void;
   /**
-   * Emitted whenever the current song title changes
-   * @param newTitle The new song title
-   * @param oldTitle The old song title, or `null` if no previous title was found
-   * @param initialPlay Whether this is the first played song
+   * Emitted whenever the current song title changes.  
+   * Uses the DOM element `yt-formatted-string.title` to detect changes and emit instantaneously.  
+   * If `oldTitle` is `null`, this is the first song played in the session.
+   */
+  songTitleChanged: (newTitle: string, oldTitle: string | null) => void;
+  /**
+   * Emitted whenever the current song's watch ID changes.  
+   * If `oldId` is `null`, this is the first song played in the session.
    */
-  songTitleChanged: (newTitle: string, oldTitle: string | null, initialPlay: boolean) => void;
-  /** Emitted whenever the current song's watch ID changes - `oldId` is `null` if this is the first song played in the session */
   watchIdChanged: (newId: string, oldId: string | null) => void;
+  /**
+   * Emitted whenever the URL path (`location.pathname`) changes.  
+   * If `oldPath` is `null`, this is the first path in the session.
+   */
+  pathChanged: (newPath: string, oldPath: string | null) => void;
   /** Emitted whenever the player enters or exits fullscreen mode */
   fullscreenToggled: (isFullscreen: boolean) => void;
 }
@@ -53,6 +60,7 @@ export const allSiteEvents = [
   "autoplayQueueChanged",
   "songTitleChanged",
   "watchIdChanged",
+  "pathChanged",
   "fullscreenToggled",
 ] as const;
 
@@ -70,6 +78,10 @@ export function removeAllObservers() {
   observers = [];
 }
 
+let lastWatchId: string | null = null;
+let lastPathname: string | null = null;
+let lastFullscreen: boolean;
+
 /** Creates MutationObservers that check if parts of the site have changed, then emit an event on the `siteEvents` instance. */
 export async function initSiteEvents() {
   try {
@@ -109,7 +121,6 @@ export async function initSiteEvents() {
     //#region player bar
 
     let lastTitle: string | null = null;
-    let initialPlay = true;
 
     addSelectorListener("playerBarInfo", "yt-formatted-string.title", {
       continuous: true,
@@ -119,9 +130,8 @@ export async function initSiteEvents() {
         if(newTitle === lastTitle || !newTitle)
           return;
         lastTitle = newTitle;
-        info(`Detected song change - old title: "${oldTitle}" - new title: "${newTitle}" - initial play: ${initialPlay}`);
-        emitSiteEvent("songTitleChanged", newTitle, oldTitle, initialPlay);
-        initialPlay = false;
+        info(`Detected song change - old title: "${oldTitle}" - new title: "${newTitle}"`);
+        emitSiteEvent("songTitleChanged", newTitle, oldTitle);
       },
     });
 
@@ -136,7 +146,10 @@ export async function initSiteEvents() {
 
     const playerFullscreenObs = new MutationObserver(([{ target }]) => {
       const isFullscreen = (target as HTMLElement).getAttribute("player-ui-state")?.toUpperCase() === "FULLSCREEN";
-      emitSiteEvent("fullscreenToggled", isFullscreen);
+      if(lastFullscreen !== isFullscreen || typeof lastFullscreen === "undefined") {
+        emitSiteEvent("fullscreenToggled", isFullscreen);
+        lastFullscreen = isFullscreen;
+      }
     });
 
     addSelectorListener("mainPanel", "ytmusic-player#player", {
@@ -149,9 +162,7 @@ export async function initSiteEvents() {
 
     //#region other
 
-    let lastWatchId: string | null = null;
-
-    const checkWatchId = () => {
+    const runIntervalChecks = () => {
       if(location.pathname.startsWith("/watch")) {
         const newWatchId = new URL(location.href).searchParams.get("v");
         if(newWatchId && newWatchId !== lastWatchId) {
@@ -160,11 +171,16 @@ export async function initSiteEvents() {
           lastWatchId = newWatchId;
         }
       }
+
+      if(location.pathname !== lastPathname) {
+        emitSiteEvent("pathChanged", String(location.pathname), lastPathname);
+        lastPathname = String(location.pathname);
+      }
     };
 
     window.addEventListener("bytm:ready", () => {
-      checkWatchId();
-      setInterval(checkWatchId, 200);
+      runIntervalChecks();
+      setInterval(runIntervalChecks, 100);
     }, {
       once: true,
     });

+ 2 - 1
src/utils/dom.ts

@@ -136,7 +136,8 @@ export function clearInner(element: Element) {
     clearNode(element!.firstChild as Element);
 }
 
-function clearNode(element: Element) {
+/** Removes all child nodes of an element recursively and also removes the element itself */
+export function clearNode(element: Element) {
   while(element.hasChildNodes())
     clearNode(element!.firstChild as Element);
   element.parentNode!.removeChild(element);

+ 1 - 1
src/utils/misc.ts

@@ -169,7 +169,7 @@ export function getPreferredLocale(): TrLocale {
 }
 
 /** Returns the content behind the passed resource identifier to be assigned to an element's innerHTML property */
-export async function resourceToHTMLString(resource: ResourceKey) {
+export async function resourceToHTMLString(resource: ResourceKey | "_") {
   try {
     const resourceUrl = await getResourceUrl(resource);
     if(!resourceUrl)