Pārlūkot izejas kodu

feat: first prototype of media control lyrics btn

Sven 1 gadu atpakaļ
vecāks
revīzija
ccf25f5e8a
3 mainītis faili ar 123 papildinājumiem un 65 dzēšanām
  1. 50 2
      src/features/layout.ts
  2. 3 3
      src/features/lyrics.css
  3. 70 60
      src/features/lyrics.ts

+ 50 - 2
src/features/layout.ts

@@ -4,6 +4,7 @@ import { addGlobalStyle, error, getEvtData, insertAfter, log, siteEvents } from
 import type { FeatureConfig } from "../types";
 import { openMenu } from "./menu/menu_old";
 import "./layout.css";
+import { getGeniusUrl, getLyricsBtn, sanitizeArtists, sanitizeSong } from "./lyrics";
 
 let features: FeatureConfig;
 
@@ -93,17 +94,64 @@ export function initQueueButtons() {
   queueItems.forEach(itm => addQueueButtons(itm as HTMLElement));
 }
 
-function addQueueButtons(queueItem: HTMLElement) {
+/** For how long the user needs to hover over the song info to fetch the lyrics */
+const queueBtnLyricsLoadDebounce = 250;
+
+async function addQueueButtons(queueItem: HTMLElement) {
   const queueBtnsCont = document.createElement("div");
   queueBtnsCont.className = "bytm-queue-btn-container";
-  queueBtnsCont.innerText = "ayo";
 
   const songInfo = queueItem.querySelector(".song-info");
   if(!songInfo)
     return false;
 
+  const [songEl, artistEl] = (songInfo.querySelectorAll("yt-formatted-string") as NodeListOf<HTMLElement>);
+  const song = songEl.innerText;
+  const artist = artistEl.innerText;
+  if(!song || !artist)
+    return false;
+
+  // TODO: display "hover to load" and "currently loading" icons
+  const lyricsBtnElem = getLyricsBtn(undefined, false);
+
+  // load the URL only on hover because of geniURL rate limiting
+  songInfo.addEventListener("mouseenter", async () => {
+    const startTs = Date.now();
+    if(songInfo.classList.contains("bytm-fetched-lyrics-url"))
+      return;
+
+    /** Loads lyrics after `queueBtnLyricsLoadDebounce` time has passed - gets aborted if the mouse leaves before that time passed */
+    const lyricsLoadTimeout = setTimeout(async () => {
+      const lyricsUrl = await getGeniusUrl(sanitizeArtists(artist), sanitizeSong(song));
+
+      if(!lyricsUrl)
+        return false;
+
+      songInfo.classList.add("bytm-fetched-lyrics-url");
+
+      lyricsBtnElem.href = lyricsUrl;
+
+      lyricsBtnElem.title = "Open the current song's lyrics in a new tab";
+      lyricsBtnElem.style.cursor = "pointer";
+      lyricsBtnElem.style.visibility = "initial";
+      lyricsBtnElem.style.display = "inline-flex";
+      lyricsBtnElem.style.pointerEvents = "initial";
+    }, queueBtnLyricsLoadDebounce);
+
+    songInfo.addEventListener("mouseleave", () => {
+      if(Date.now() - startTs < queueBtnLyricsLoadDebounce) {
+        clearTimeout(lyricsLoadTimeout);
+        console.log("CLEAR", song);
+      }
+    });
+  });
+
+  queueBtnsCont.appendChild(lyricsBtnElem);
+
   songInfo.appendChild(queueBtnsCont);
   queueItem.classList.add("bytm-has-queue-btns");
+
+  log(`Added queue buttons for song '${artist} - ${song}'`, queueBtnsCont);
   return true;
 }
 

+ 3 - 3
src/features/lyrics.css

@@ -1,4 +1,4 @@
-#betterytm-lyrics-button {
+.bytm-generic-lyrics-btn {
     align-items: center;
     justify-content: center;
     position: relative;
@@ -11,11 +11,11 @@
     background-color: transparent;
 }
 
-#betterytm-lyrics-button:hover {
+.bytm-generic-lyrics-btn:hover {
     background-color: #383838;
 }
 
-#betterytm-lyrics-img {
+.betterytm-lyrics-img {
     display: inline-block;
     z-index: 10;
     width: 24px;

+ 70 - 60
src/features/lyrics.ts

@@ -11,44 +11,32 @@ let mcCurrentSongTitle = "";
 let mcLyricsButtonAddTries = 0;
 
 /** Adds a lyrics button to the media controls bar */
-export async function addMediaCtrlLyricsBtn(): Promise<unknown> {
+export function addMediaCtrlLyricsBtn(): void {
   const likeContainer = document.querySelector(".middle-controls-buttons ytmusic-like-button-renderer#like-button-renderer") as HTMLElement;
 
   if(!likeContainer) {
     mcLyricsButtonAddTries++;
-    if(mcLyricsButtonAddTries < triesLimit)
-      return setTimeout(addMediaCtrlLyricsBtn, triesInterval); // TODO: improve this
+    if(mcLyricsButtonAddTries < triesLimit) {
+      setTimeout(addMediaCtrlLyricsBtn, triesInterval); // TODO: improve this
+      return;
+    }
 
     return error(`Couldn't find element to append lyrics buttons to after ${mcLyricsButtonAddTries} tries`);
   }
 
   const songTitleElem = document.querySelector(".content-info-wrapper > yt-formatted-string") as HTMLDivElement;
 
+  // run parallel without awaiting so the MutationObserver below can observe the title element in time
+  (async () => {
+    const gUrl = await getCurrentLyricsUrl();
 
-  const gUrl = await getCurrentGeniusUrl();
-
-  const linkElem = document.createElement("a");
-  linkElem.id = "betterytm-lyrics-button";
-  linkElem.className = "ytmusic-player-bar";
-  linkElem.title = gUrl ? "Click to open this song's lyrics in a new tab" : "Loading...";
-  if(gUrl)
-    linkElem.href = gUrl;
-  linkElem.target = "_blank";
-  linkElem.rel = "noopener noreferrer";
-  linkElem.style.visibility = gUrl ? "initial" : "hidden";
-  linkElem.style.display = gUrl ? "inline-flex" : "none";
-
-
-  const imgElem = document.createElement("img");
-  imgElem.id = "betterytm-lyrics-img";
-  imgElem.src = "https://raw.githubusercontent.com/Sv443/BetterYTM/main/assets/external/genius.png";
-
-  linkElem.appendChild(imgElem);
+    const linkElem = getLyricsBtn(gUrl ?? undefined);
+    linkElem.id = "betterytm-lyrics-button";
 
-  log(`Inserted lyrics button after ${mcLyricsButtonAddTries} tries:`, linkElem);
-
-  insertAfter(likeContainer, linkElem);
+    log(`Inserted lyrics button after ${mcLyricsButtonAddTries} tries:`, linkElem);
 
+    insertAfter(likeContainer, linkElem);
+  })();
 
   mcCurrentSongTitle = songTitleElem.title;
 
@@ -56,7 +44,7 @@ export async function addMediaCtrlLyricsBtn(): Promise<unknown> {
     for await(const mut of mutations) {
       const newTitle = (mut.target as HTMLElement).title;
 
-      if(newTitle != mcCurrentSongTitle && newTitle.length > 0) {
+      if(newTitle !== mcCurrentSongTitle && newTitle.length > 0) {
         const lyricsBtn = document.querySelector("#betterytm-lyrics-button") as HTMLAnchorElement;
 
         if(!lyricsBtn)
@@ -69,13 +57,13 @@ export async function addMediaCtrlLyricsBtn(): Promise<unknown> {
 
         mcCurrentSongTitle = newTitle;
 
-        const url = await getCurrentGeniusUrl(); // can take a second or two
+        const url = await getCurrentLyricsUrl(); // can take a second or two
         if(!url)
           continue;
 
         lyricsBtn.href = url;
 
-        lyricsBtn.title = "Click to open this song's lyrics in a new tab";
+        lyricsBtn.title = "Open the current song's lyrics in a new tab";
         lyricsBtn.style.cursor = "pointer";
         lyricsBtn.style.visibility = "initial";
         lyricsBtn.style.display = "inline-flex";
@@ -90,46 +78,48 @@ export async function addMediaCtrlLyricsBtn(): Promise<unknown> {
   obs.observe(songTitleElem, { attributes: true, attributeFilter: [ "title" ] });
 }
 
-/** Returns the lyrics URL from genius for the current song */
-export async function getCurrentGeniusUrl() {
-  try {
-    // In videos the video title contains both artist and song title, in "regular" YTM songs, the video title only contains the song title
-    const isVideo = typeof document.querySelector("ytmusic-player")?.getAttribute("video-mode_") === "string";
+/** Removes everything in parentheses from the passed song name */
+export function sanitizeSong(songName: string) {
+  const parensRegex = /\(.+\)/gmi;
+  const squareParensRegex = /\[.+\]/gmi;
 
-    const songTitleElem = document.querySelector(".content-info-wrapper > yt-formatted-string") as HTMLElement;
-    const songMetaElem = document.querySelector("span.subtitle > yt-formatted-string:first-child") as HTMLElement;
+  // trim right after the song name:
+  const sanitized = songName
+    .replace(parensRegex, "")
+    .replace(squareParensRegex, "");
 
-    if(!songTitleElem || !songMetaElem || !songTitleElem.title)
-      return null;
+  return sanitized.trim();
+}
 
-    const sanitizeSongName = (songName: string) => {
-      const parensRegex = /\(.+\)/gmi;
-      const squareParensRegex = /\[.+\]/gmi;
+/** Removes the secondary artist (if it exists) from the passed artists string */
+export function sanitizeArtists(artists: string) {
+  artists = artists.split(/\s*\u2022\s*/gmiu)[0]; // split at &bull; [•] character
 
-      // trim right after the song name:
-      const sanitized = songName
-        .replace(parensRegex, "")
-        .replace(squareParensRegex, "");
+  if(artists.match(/&/))
+    artists = artists.split(/\s*&\s*/gm)[0];
 
-      return sanitized.trim();
-    };
+  if(artists.match(/,/))
+    artists = artists.split(/,\s*/gm)[0];
 
-    const splitArtist = (songMeta: string) => {
-      songMeta = songMeta.split(/\s*\u2022\s*/gmiu)[0]; // split at bullet (&bull; / •) character
+  return artists.trim();
+}
 
-      if(songMeta.match(/&/))
-        songMeta = songMeta.split(/\s*&\s*/gm)[0];
+/** Returns the lyrics URL from genius for the currently selected song */
+export async function getCurrentLyricsUrl() {
+  try {
+    // In videos the video title contains both artist and song title, in "regular" YTM songs, the video title only contains the song title
+    const isVideo = typeof document.querySelector("ytmusic-player")?.getAttribute("video-mode_") === "string";
 
-      if(songMeta.match(/,/))
-        songMeta = songMeta.split(/,\s*/gm)[0];
+    const songTitleElem = document.querySelector(".content-info-wrapper > yt-formatted-string") as HTMLElement;
+    const songMetaElem = document.querySelector("span.subtitle > yt-formatted-string:first-child") as HTMLElement;
 
-      return songMeta.trim();
-    };
+    if(!songTitleElem || !songMetaElem || !songTitleElem.title)
+      return null;
 
     const songNameRaw = songTitleElem.title;
-    const songName = sanitizeSongName(songNameRaw);
+    const songName = sanitizeSong(songNameRaw);
 
-    const artistName = splitArtist(songMetaElem.title);
+    const artistName = sanitizeArtists(songMetaElem.title);
 
     /** Use when the current song is not a "real YTM song" with a static background, but rather a music video */
     const getGeniusUrlVideo = async () => {
@@ -155,10 +145,10 @@ export async function getCurrentGeniusUrl() {
 }
 
 /**
-   * @param artist
-   * @param song
-   */
-async function getGeniusUrl(artist: string, song: string): Promise<string | undefined> {
+ * @param artist
+ * @param song
+ */
+export async function getGeniusUrl(artist: string, song: string): Promise<string | undefined> {
   try {
     const startTs = Date.now();
     const fetchUrl = `${geniURLSearchTopUrl}?artist=${encodeURIComponent(artist)}&song=${encodeURIComponent(song)}`;
@@ -183,3 +173,23 @@ async function getGeniusUrl(artist: string, song: string): Promise<string | unde
     return undefined;
   }
 }
+
+export function getLyricsBtn(geniusUrl?: string, hideIfLoading = true): HTMLAnchorElement {
+  const linkElem = document.createElement("a");
+  linkElem.className = "ytmusic-player-bar bytm-generic-lyrics-btn";
+  linkElem.title = geniusUrl ? "Click to open this song's lyrics in a new tab" : "Loading...";
+  if(geniusUrl)
+    linkElem.href = geniusUrl;
+  linkElem.target = "_blank";
+  linkElem.rel = "noopener noreferrer";
+  linkElem.style.visibility = hideIfLoading && geniusUrl ? "initial" : "hidden";
+  linkElem.style.display = hideIfLoading && geniusUrl ? "inline-flex" : "none";
+
+  const imgElem = document.createElement("img");
+  imgElem.className = "betterytm-lyrics-img";
+  imgElem.src = "https://raw.githubusercontent.com/Sv443/BetterYTM/main/assets/external/genius.png";
+
+  linkElem.appendChild(imgElem);
+
+  return linkElem;
+}