Prechádzať zdrojové kódy

feat: begin show likes & dislikes feature

Sv443 10 mesiacov pred
rodič
commit
f890c8554f

+ 17 - 17
dist/BetterYTM.css

@@ -579,23 +579,6 @@ hr {
   margin-right: 6px;
 }
 
-.bytm-hotkey-wrapper {
-  display: flex;
-  flex-direction: row;
-  align-items: center;
-  justify-content: flex-end;
-}
-
-.bytm-hotkey-reset {
-  font-size: 0.9em;
-  margin-right: 10px;
-}
-
-.bytm-hotkey-info {
-  font-size: 0.9em;
-  white-space: nowrap;
-}
-
 /* use `html body` prefix for more specificity */
 
 html body .bytm-ripple {
@@ -648,6 +631,23 @@ html body .bytm-ripple.slower {
   }
 }
 
+.bytm-hotkey-wrapper {
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  justify-content: flex-end;
+}
+
+.bytm-hotkey-reset {
+  font-size: 0.9em;
+  margin-right: 10px;
+}
+
+.bytm-hotkey-info {
+  font-size: 0.9em;
+  white-space: nowrap;
+}
+
 :root {
   --bytm-toast-bg-color: #323232;
   --bytm-toast-text-color: #fff;

+ 9 - 6
src/components/circularButton.ts

@@ -1,17 +1,19 @@
 import { getResourceUrl, onInteraction } from "../utils/index.js";
+import { createRipple } from "./ripple.js";
 import type { ResourceKey } from "../types.js";
 
-type CircularBtnOptions = (
+type CircularBtnOptions = {
+  /** Tooltip and aria-label of the button */
+  title: string;
+  /** Whether the button should have a ripple effect - defaults to true */
+  ripple?: boolean;
+} & (
   | {
     /** Resource key for the button icon */
     resourceName: (ResourceKey & `icon-${string}`) | "_";
-    /** Tooltip and aria-label of the button */
-    title: string;
   }
   | {
     src: string | Promise<string>;
-    /** Tooltip and aria-label of the button */
-    title: string;
   }
 ) & (
   | {
@@ -32,6 +34,7 @@ type CircularBtnOptions = (
  */
 export async function createCircularBtn({
   title,
+  ripple = true,
   ...rest
 }: CircularBtnOptions) {
   let btnElem: HTMLElement;
@@ -64,5 +67,5 @@ export async function createCircularBtn({
 
   btnElem.appendChild(imgElem);
 
-  return btnElem;
+  return ripple ? createRipple(btnElem, { speed: "slow" }) : btnElem;
 }

+ 13 - 11
src/components/longButton.ts

@@ -1,7 +1,17 @@
 import { onInteraction, resourceToHTMLString } from "../utils/index.js";
+import { createRipple } from "./ripple.js";
 import type { ResourceKey } from "../types.js";
 
-type LongBtnOptions = (
+type LongBtnOptions = {
+  /** Button text */
+  text: string;
+  /** Tooltip and aria-label of the button */
+  title: string;
+  /** Icon position inside the button - defaults to `left` */
+  iconPosition?: "left" | "right";
+  /** Whether the button should have a ripple effect - defaults to true */
+  ripple?: boolean;
+} & (
   | {
     /** Resource key for the button icon */
     resourceName: (ResourceKey & `icon-${string}`) | "_";
@@ -26,15 +36,6 @@ type LongBtnOptions = (
     /** Callback function to execute when the button is toggled */
     onToggle: (enabled: boolean, event: MouseEvent | KeyboardEvent) => void;
   }
-) & (
-  {
-    /** Button text */
-    text: string;
-    /** Tooltip and aria-label of the button */
-    title: string;
-    /** Icon position inside the button - defaults to `left` */
-    iconPosition?: "left" | "right";
-  }
 );
 
 /**
@@ -48,6 +49,7 @@ export async function createLongBtn({
   title,
   text,
   iconPosition,
+  ripple,
   ...rest
 }: LongBtnOptions) {
   if(["href", "onClick", "onToggle"].every((key) => !(key in rest)))
@@ -97,5 +99,5 @@ export async function createLongBtn({
   btnElem.appendChild(txtElem);
   iconPosition === "right" && btnElem.appendChild(imgElem);
 
-  return btnElem;
+  return ripple ? createRipple(btnElem, { speed: "normal" }) : btnElem;
 }

+ 5 - 2
src/config.ts

@@ -72,8 +72,11 @@ export const migrations: DataMigrationsDict = {
     useDefaultConfig(oldData, [
       "autoLikeChannels", "autoLikeChannelToggleBtn",
       "autoLikeTimeout", "autoLikeShowToast",
-      "autoLikeOpenMgmtDialog", "toastDuration",
-      "initTimeout",
+      "autoLikeOpenMgmtDialog",
+      "showVotes", "showVoteRatio",
+      "toastDuration", "initTimeout",
+      // forgot to add this to the migration when adding the feature so now will have to do:
+      "volumeSliderLabel",
     ]), [
       { key: "rememberSongTimeSites", oldDefault: "ytm" },
       { key: "volumeSliderScrollStep", oldDefault: 10 },

+ 17 - 0
src/features/index.ts

@@ -233,6 +233,23 @@ export const featInfo = {
     advanced: true,
     textAdornment: () => combineAdornments([adornments.advanced, adornments.reloadRequired]),
   },
+  showVotes: {
+    type: "toggle",
+    category: "layout",
+    default: true,
+    textAdornment: adornments.reloadRequired,
+  },
+  showVoteRatio: {
+    type: "select",
+    category: "layout",
+    options: () => [
+      { value: "disabled", label: t("vote_ratio_disabled") },
+      { value: "redGreen", label: t("vote_ratio_red_green") },
+      { value: "blueGray", label: t("vote_ratio_blue_gray") },
+    ],
+    default: "disabled",
+    textAdornment: adornments.reloadRequired,
+  },
 
   //#region volume
   volumeSliderLabel: {

+ 38 - 2
src/features/layout.ts

@@ -1,8 +1,8 @@
 import { addParent, autoPlural, debounce, fetchAdvanced, pauseFor } from "@sv443-network/userutils";
-import { getFeatures } from "../config.js";
+import { getFeature, getFeatures } from "../config.js";
 import { siteEvents } from "../siteEvents.js";
 import { addSelectorListener } from "../observers.js";
-import { error, getResourceUrl, log, warn, t, onInteraction, openInTab, getBestThumbnailUrl, getDomain, addStyle, currentMediaType, domLoaded, waitVideoElementReady, getVideoTime, fetchCss, addStyleFromResource } from "../utils/index.js";
+import { error, getResourceUrl, log, warn, t, onInteraction, openInTab, getBestThumbnailUrl, getDomain, addStyle, currentMediaType, domLoaded, waitVideoElementReady, getVideoTime, fetchCss, addStyleFromResource, fetchVideoVotes, getWatchId, type ReturnYoutubeDislikesVotesObj } from "../utils/index.js";
 import { mode, scriptInfo } from "../constants.js";
 import { openCfgMenu } from "../menu/menu_old.js";
 import { createCircularBtn } from "../components/index.js";
@@ -731,3 +731,39 @@ export async function fixHdrIssues() {
   else
     log("Fixed HDR issues");
 }
+
+//#region show likes&dislikes
+
+/** Shows the amount of likes and dislikes on the current song */
+export async function initShowVotes() {
+  addSelectorListener("playerBar", ".middle-controls-buttons ytmusic-like-button-renderer", {
+    async listener(voteCont) {
+      try {
+        const watchId = getWatchId();
+        if(!watchId)
+          return error("Couldn't get watch ID while initializing showVotes");
+        const voteObj = await fetchVideoVotes(watchId);
+        if(!voteObj)
+          return error("Couldn't fetch votes from ReturnYouTubeDislikes API");
+
+        getFeature("showVotes") && addVoteNumbers(voteCont, voteObj);
+        getFeature("showVoteRatio") && addVoteRatio(voteCont, voteObj);
+      }
+      catch(err) {
+        error("Couldn't initialize show votes feature due to an error:", err);
+      }
+    },
+  });
+
+  siteEvents.on("watchIdChanged", async (watchId) => {
+    void ["TODO", watchId];
+  });
+}
+
+function addVoteNumbers(voteCont: HTMLElement, voteObj: ReturnYoutubeDislikesVotesObj) {
+  void ["TODO", voteCont, voteObj];
+}
+
+function addVoteRatio(voteCont: HTMLElement, voteObj: ReturnYoutubeDislikesVotesObj) {
+  void ["TODO", voteCont, voteObj];
+}

+ 10 - 3
src/index.ts

@@ -10,15 +10,19 @@ import { getWelcomeDialog } from "./dialogs/index.js";
 import type { FeatureConfig } from "./types.js";
 import {
   // layout
-  addWatermark, removeUpgradeTab, initRemShareTrackParam, fixSpacing, initThumbnailOverlay, initHideCursorOnIdle, fixHdrIssues,
+  addWatermark, removeUpgradeTab, initRemShareTrackParam,
+  fixSpacing, initThumbnailOverlay, initHideCursorOnIdle,
+  fixHdrIssues, initShowVotes,
   // volume
   initVolumeFeatures,
   // song lists
   initQueueButtons, initAboveQueueBtns,
   // behavior
-  initBeforeUnloadHook, disableBeforeUnload, initAutoCloseToasts, initRememberSongTime, disableDarkReader,
+  initBeforeUnloadHook, disableBeforeUnload, initAutoCloseToasts,
+  initRememberSongTime, disableDarkReader,
   // input
-  initArrowKeySkip, initSiteSwitch, addAnchorImprovements, initNumKeysSkip, initAutoLike,
+  initArrowKeySkip, initSiteSwitch, addAnchorImprovements,
+  initNumKeysSkip, initAutoLike,
   // lyrics
   addPlayerBarLyricsBtn, initLyricsCache,
   // menu
@@ -163,6 +167,9 @@ async function onDomLoad() {
       if(features.fixHdrIssues)
         ftInit.push(["fixHdrIssues", fixHdrIssues()]);
 
+      if(features.showVotes || features.showVoteRatio !== "disabled")
+        ftInit.push(["showVotes", initShowVotes()]);
+
       //#region (ytm) volume
 
       ftInit.push(["volumeFeatures", initVolumeFeatures()]);

+ 14 - 14
src/interface.ts

@@ -195,22 +195,22 @@ export function emitInterface<
 //#region register plugins
 
 /** Map of plugin ID and plugins that are queued up for registration */
-const pluginsQueued = new Map<string, PluginItem>();
+const queuedPlugins = new Map<string, PluginItem>();
 
 /** Map of plugin ID and all registered plugins */
-const pluginsRegistered = new Map<string, PluginItem>();
+const registeredPlugins = new Map<string, PluginItem>();
 
 /** Map of plugin ID to auth token for plugins that have been registered */
-const pluginTokens = new Map<string, string>();
+const registeredPluginTokens = new Map<string, string>();
 
 /** Initializes plugins that have been registered already. Needs to be run after `bytm:ready`! */
 export function initPlugins() {
   // TODO(v1.3): check perms and ask user for initial activation
 
-  for(const [key, { def, events }] of pluginsQueued) {
+  for(const [key, { def, events }] of queuedPlugins) {
     try {
-      pluginsRegistered.set(key, { def, events });
-      pluginsQueued.delete(key);
+      registeredPlugins.set(key, { def, events });
+      queuedPlugins.delete(key);
       emitOnPlugins("pluginRegistered", (d) => sameDef(d, def), pluginDefToInfo(def)!);
     }
     catch(err) {
@@ -248,7 +248,7 @@ export function emitOnPlugins<TEvtKey extends keyof PluginEventMap>(
   predicate: ((def: PluginDef) => boolean) | boolean = true,
   ...data: Parameters<PluginEventMap[TEvtKey]>
 ) {
-  for(const { def, events } of pluginsRegistered.values())
+  for(const { def, events } of registeredPlugins.values())
     if(typeof predicate === "boolean" ? predicate : predicate(def))
       events.emit(event, ...data);
 }
@@ -274,10 +274,10 @@ export function getPlugin(pluginId: string): PluginItem | undefined
  */
 export function getPlugin(...args: [pluginDefOrNameOrId: PluginDefResolvable | string, namespace?: string]): PluginItem | undefined {
   return typeof args[0] === "string" && typeof args[1] === "undefined"
-    ? pluginsRegistered.get(args[0])
+    ? registeredPlugins.get(args[0])
     : args.length === 2
-      ? pluginsRegistered.get(`${args[1]}/${args[0]}`)
-      : pluginsRegistered.get(getPluginKey(args[0] as PluginDefResolvable));
+      ? registeredPlugins.get(`${args[1]}/${args[0]}`)
+      : registeredPlugins.get(getPluginKey(args[0] as PluginDefResolvable));
 }
 
 /**
@@ -308,7 +308,7 @@ export function getPluginInfo(...args: [token: string | undefined, pluginDefOrNa
     return undefined;
 
   return pluginDefToInfo(
-    pluginsRegistered.get(
+    registeredPlugins.get(
       typeof args[1] === "string" && typeof args[2] === "undefined"
         ? args[1]
         : args.length === 2
@@ -353,11 +353,11 @@ export function registerPlugin(def: PluginDef): PluginRegisterResult {
   const token = randomId(32, 36);
 
   const { plugin: { name } } = def;
-  pluginsQueued.set(getPluginKey(def), {
+  queuedPlugins.set(getPluginKey(def), {
     def: def,
     events,
   });
-  pluginTokens.set(getPluginKey(def), token);
+  registeredPluginTokens.set(getPluginKey(def), token);
 
   info(`Registered plugin: ${name}`, LogLevel.Info);
 
@@ -370,7 +370,7 @@ export function registerPlugin(def: PluginDef): PluginRegisterResult {
 
 /** Checks whether the passed token is a valid auth token for any registered plugin and returns the plugin ID, else returns undefined */
 export function resolveToken(token: string | undefined): string | undefined {
-  return token ? [...pluginTokens.entries()].find(([, v]) => v === token)?.[0] ?? undefined : undefined;
+  return token ? [...registeredPluginTokens.entries()].find(([, v]) => v === token)?.[0] ?? undefined : undefined;
 }
 
 //#region proxy funcs

+ 4 - 0
src/types.ts

@@ -362,6 +362,10 @@ export interface FeatureConfig {
   fixHdrIssues: boolean;
   /** On which sites to disable Dark Reader - does nothing if the extension is not installed */
   disableDarkReaderSites: SiteSelectionOrNone;
+  /** Whether to show the like/dislike ratio on the currently playing song */
+  showVotes: boolean;
+  /** Whether to show a bar graph of the like/dislike ratio on the currently playing song and which design it should use */
+  showVoteRatio: "disabled" | "redGreen" | "blueGray";
 
   //#region volume
   /** Add a percentage label to the volume slider */

+ 29 - 1
src/utils/xhr.ts

@@ -30,7 +30,7 @@ export function constructUrl(base: string, params: Record<string, Stringifiable
 
 /**
  * Sends a request with the specified parameters and returns the response as a Promise.  
- * Ignores the CORS policy, contrary to fetch and fetchAdvanced.
+ * Ignores [CORS](https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS), contrary to fetch and fetchAdvanced.
  */
 export function sendRequest<T = any>(details: GM.Request<T>) {
   return new Promise<GM.Response<T>>((resolve, reject) => {
@@ -56,3 +56,31 @@ export async function fetchCss(key: ResourceKey & `css-${string}`) {
     return undefined;
   }
 }
+
+export type ReturnYoutubeDislikesVotesObj = {
+  /** The watch ID of the video */
+  id: string;
+  /** ISO timestamp of when the video was uploaded */
+  dateCreated: string;
+  /** Amount of likes */
+  likes: number;
+  /** Amount of dislikes */
+  dislikes: number;
+  /** Like to dislike ratio from 0.0 to 5.0 */
+  rating: number;
+  /** Amount of views */
+  viewCount: number;
+  /** Whether the video was deleted */
+  deleted: boolean;
+};
+
+/**
+ * Fetches the votes object for a YouTube video from the [Return YouTube Dislikes API.](https://returnyoutubedislike.com/docs)
+ * @param watchId The watch ID of the video
+ */
+export async function fetchVideoVotes(watchId: string) {
+  return (await sendRequest<ReturnYoutubeDislikesVotesObj>({
+    method: "GET",
+    url: `https://returnyoutubedislikeapi.com/votes?videoId=${watchId}`,
+  })).response as ReturnYoutubeDislikesVotesObj;
+}