// ==UserScript== // @name BetterYTM // @name:de BetterYTM // @namespace https://github.com/Sv443/BetterYTM#readme // @version 0.2.0 // @license MIT // @author Sv443 // @copyright Sv443 (https://github.com/Sv443) // @description Improvements for YouTube Music // @description:de Verbesserungen fΓΌr YouTube Music // @match https://music.youtube.com/* // @match https://www.youtube.com/* // @match https://genius.com/search* // @icon https://www.google.com/s2/favicons?domain=music.youtube.com // @run-at document-start // @connect self // @connect youtube.com // @connect github.com // @connect githubusercontent.com // @downloadURL https://raw.githubusercontent.com/Sv443/BetterYTM/main/BetterYTM.user.js // @updateURL https://raw.githubusercontent.com/Sv443/BetterYTM/main/BetterYTM.user.js // ==/UserScript== /* Disclaimer: I am not affiliated with YouTube, Google, Alphabet, Genius or anyone else */ /* C&D this, Susan πŸ–• */ (() => { "use-strict"; /* β–ˆβ–€β–€β–ˆ β–„β–„β–„ β–ˆ β–ˆ β–€ β–„β–„β–„ β–„β–„β–„β–„ β–„β–„β–„ β–€β–€β–„β–„ β–ˆβ–„β–ˆ β–ˆβ–€ β–ˆβ–€ β–€β–ˆ β–ˆ β–ˆ β–ˆ β–„β–„ β–ˆβ–„β–„ β–€ β–ˆβ–„β–„β–ˆ β–ˆβ–„β–„ β–ˆβ–„β–„ β–ˆβ–„β–„ β–„β–ˆβ–„ β–ˆ β–ˆ β–ˆβ–„β–„β–ˆ β–„β–„β–ˆ β–„ */ /** * This is where you can enable or disable features * If this userscript ever becomes something I might add like a menu to toggle these */ const features = Object.freeze({ // --- Quality of Life --- /** Whether arrow keys should skip forwards and backwards by 10 seconds */ arrowKeySupport: true, /** Whether to remove the "Upgrade" / YT Music Premium tab */ removeUpgradeTab: true, // --- Extra Features --- /** Whether to add a button or key combination (TODO) to switch between the YT and YTM sites on a video */ switchBetweenSites: true, /** Adds a button to the media controls bar to search for the current song's lyrics on genius.com in a new tab */ geniusLyrics: true, /** This option makes the genius.com lyrics search button from above automatically open the best matching result */ geniusAutoclickBestResult: true, // --- Other --- /** Set to true to remove the watermark under the YTM logo */ removeWatermark: false, // /** The theme color - accepts any CSS color value - default is "#ff0000" */ // themeColor: "#0f0", }); /** Set to true to enable debug mode for more output in the JS console */ const dbg = false; //#MARKER types /** @typedef {"yt"|"ytm"|"genius"} Domain Constant string representation of which domain this script is currently running on */ //#MARKER init /** Specifies the hard limit for repetitive tasks */ const triesLimit = 20; const info = Object.freeze({ name: GM.info.script.name, // eslint-disable-line no-undef version: GM.info.script.version, // eslint-disable-line no-undef namespace: GM.info.script.namespace, // eslint-disable-line no-undef }); function init() { try { console.log(`${info.name} v${info.version} - ${info.namespace}`); document.addEventListener("DOMContentLoaded", onDomLoad); } catch(err) { console.error("BetterYTM - General Error:", err); } } //#MARKER events /** * Called when the DOM has finished loading (after `DOMContentLoaded` is emitted) */ function onDomLoad() { const domain = getDomain(); dbg && console.info(`BetterYTM: Initializing features for domain '${domain}'`); try { if(domain === "ytm") { if(features.arrowKeySupport) { document.addEventListener("keydown", onKeyDown); dbg && console.info(`BetterYTM: Added key press listener`); } if(features.removeUpgradeTab) removeUpgradeTab(); if(!features.removeWatermark) addWatermark(); if(features.geniusLyrics) addGeniusButton(); } if(["ytm", "yt"].includes(domain)) { if(features.switchBetweenSites) initSiteSwitch(domain); } if(domain === "genius") { if(features.geniusAutoclickBestResult) autoclickGeniusResult(); } } catch(err) { console.error(`BetterYTM: General error while executing feature:`, err); } // if(features.themeColor != "#f00" && features.themeColor != "#ff0000") // applyTheme(); } //#MARKER features //#SECTION arrow key skip /** * Called when the user presses keys * @param {KeyboardEvent} evt */ function onKeyDown(evt) { if(["ArrowLeft", "ArrowRight"].includes(evt.code)) { dbg && console.info(`BetterYTM: Captured key '${evt.code}' in proxy listener`); // ripped this stuff from the console, most of these are probably unnecessary but this was finnicky af and I am sick and tired of trial and error const defaultProps = { altKey: false, bubbles: true, cancelBubble: false, cancelable: true, charCode: 0, composed: true, ctrlKey: false, currentTarget: null, defaultPrevented: evt.defaultPrevented, explicitOriginalTarget: document.body, isTrusted: true, metaKey: false, originalTarget: document.body, repeat: false, shiftKey: false, srcElement: document.body, target: document.body, type: "keydown", view: window, }; let invalidKey = false; let keyProps = {}; switch(evt.code) { case "ArrowLeft": keyProps = { code: "KeyH", key: "h", keyCode: 72, which: 72, }; break; case "ArrowRight": keyProps = { code: "KeyL", key: "l", keyCode: 76, which: 76, }; break; default: // console.warn("BetterYTM - Unknown key", evt.code); invalidKey = true; break; } if(!invalidKey) { const proxyProps = { ...defaultProps, ...keyProps }; document.body.dispatchEvent(new KeyboardEvent("keydown", proxyProps)); dbg && console.info(`BetterYTM: Dispatched proxy keydown event: [${evt.code}] -> [${proxyProps.code}]`); } else if(dbg) console.warn(`BetterYTM: Captured key '${evt.code}' has no defined behavior`); } } //#SECTION site switch /** * Initializes the site switch feature * @param {Domain} domain */ function initSiteSwitch(domain) { // TODO: // extra features: // - keep video time document.addEventListener("keydown", (e) => { if(e.key == "F9") switchSite(domain === "yt" ? "ytm" : "yt"); }); dbg && console.info(`BetterYTM: Initialized site switch listener`); } /** * Switches to the other site (between YT and YTM) * @param {Domain} newDomain */ function switchSite(newDomain) { dbg && console.info(`BetterYTM: Switching from domain '${getDomain()}' to '${newDomain}'`); try { let subdomain; if(newDomain === "ytm") subdomain = "music"; else if(newDomain === "yt") subdomain = "www"; if(!subdomain) throw new TypeError(`Unrecognized domain '${newDomain}'`); const { pathname, search, hash } = new URL(location.href); const vt = getVideoTime() ?? 0; dbg && console.info(`BetterYTM: Found video time of ${vt} seconds`); const newSearch = search.includes("?") ? `${search}&t=${vt}` : `?t=${vt}`; const url = `https://${subdomain}.youtube.com${pathname}${newSearch}${hash}`; console.info(`BetterYTM - switching to domain '${newDomain}' at ${url}`); location.href = url; } catch(err) { console.error(`BetterYTM: Error while switching site:`, err); } } //#SECTION remove upgrade tab let removeUpgradeTries = 0; /** * Removes the "Upgrade" / YT Music Premium tab from the title / nav bar */ function removeUpgradeTab() { const tabElem = document.querySelector(`.ytmusic-nav-bar ytmusic-pivot-bar-item-renderer[tab-id="SPunlimited"]`); if(tabElem) { tabElem.remove(); dbg && console.info(`BetterYTM: Removed upgrade tab after ${removeUpgradeTries} tries`); } else if(removeUpgradeTries < triesLimit) { setTimeout(removeUpgradeTab, 250); // TODO: improve this removeUpgradeTries++; } else console.error(`BetterYTM: Couldn't find upgrade tab to remove after ${removeUpgradeTries} tries`); } //#SECTION add watermark /** * Adds a watermark beneath the logo */ function addWatermark() { const watermark = document.createElement("a"); watermark.id = "betterytm-watermark"; watermark.className = "style-scope ytmusic-nav-bar"; watermark.innerText = info.name; watermark.title = `${info.name} v${info.version}`; watermark.href = info.namespace; watermark.target = "_blank"; watermark.rel = "noopener noreferrer"; const style = `\ #betterytm-watermark { display: inline-block; position: absolute; left: 45px; top: 43px; z-index: 10; color: white; text-decoration: none; cursor: pointer; } @media(max-width: 615px) { #betterytm-watermark { display: none; } } #betterytm-watermark:hover { text-decoration: underline; }`; addGlobalStyle(style, "watermark"); const logoElem = document.querySelector("#left-content"); insertAfter(logoElem, watermark); dbg && console.info(`BetterYTM: Added watermark element:`, watermark); } //#SECTION genius.com lyrics button let currentSongTitle = ""; let lyricsButtonAddTries = 0; /** * Adds a genius.com lyrics button to the media controls bar */ function addGeniusButton() { const likeContainer = document.querySelector(".middle-controls-buttons ytmusic-like-button-renderer#like-button-renderer"); if(!likeContainer) { lyricsButtonAddTries++; if(lyricsButtonAddTries < triesLimit) return setTimeout(addGeniusButton, 250); // TODO: improve this return console.error(`BetterYTM: Couldn't find like buttons to append lyrics button to after ${lyricsButtonAddTries} tries`); } const songTitleElem = document.querySelector(".content-info-wrapper > yt-formatted-string"); const gUrl = getGeniusUrl(); const linkElem = document.createElement("a"); linkElem.id = "betterytm-lyrics-button"; linkElem.className = "ytmusic-player-bar"; linkElem.title = "Search for lyrics on genius.com"; linkElem.href = gUrl; linkElem.target = "_blank"; linkElem.rel = "noopener noreferrer"; linkElem.style.visibility = gUrl ? "initial" : "hidden"; const style = `\ #betterytm-lyrics-button { display: inline-flex; align-items: center; justify-content: center; position: relative; vertical-align: middle; margin-left: 8px; width: 40px; height: 40px; border-radius: 100%; background-color: transparent; } #betterytm-lyrics-button:hover { background-color: #383838; } #betterytm-lyrics-img { display: inline-block; z-index: 10; width: 24px; height: 24px; padding: 5px; }`; addGlobalStyle(style, "lyrics"); const imgElem = document.createElement("img"); imgElem.id = "betterytm-lyrics-img"; imgElem.src = "https://raw.githubusercontent.com/Sv443/BetterYTM/main/resources/external/genius.png"; linkElem.appendChild(imgElem); dbg && console.info(`BetterYTM: Inserted genius button after ${lyricsButtonAddTries} tries:`, linkElem); insertAfter(likeContainer, linkElem); currentSongTitle = songTitleElem.title; /** @param {MutationRecord[]} mutations */ const onMutation = (mutations) => { mutations.forEach(mut => { const newTitle = mut.target.title; if(newTitle != currentSongTitle) { dbg && console.info(`BetterYTM: Song title changed from '${currentSongTitle}' to '${newTitle}'`); currentSongTitle = newTitle; const lyricsBtn = document.querySelector("#betterytm-lyrics-button"); lyricsBtn.href = getGeniusUrl(); lyricsBtn.style.visibility = "initial"; } }); }; // since YT and YTM don't reload the page on video change, MutationObserver needs to be used const obs = new MutationObserver(onMutation); obs.observe(songTitleElem, { attributes: true, attributeFilter: [ "title" ] }); } /** * Returns the genius.com search URL for the current song * @returns {string|null} */ function getGeniusUrl() { try { const songTitleElem = document.querySelector(".content-info-wrapper > yt-formatted-string"); const songMetaElem = document.querySelector("span.subtitle > yt-formatted-string:first-child"); if(!songTitleElem || !songMetaElem || !songTitleElem.title) return null; const sanitizeSongName = (songName) => { let sanitized; if(songName.match(/\(|feat|ft/gmi)) { // should hopefully trim right after the song name sanitized = songName.substring(0, songName.indexOf("(")); } return (sanitized || songName).trim(); }; const songNameRaw = songTitleElem.title; const songName = sanitizeSongName(songNameRaw); const songMeta = songMetaElem.title; const artistName = songMeta.split(/\s*\u2022\s*/gmiu)[0]; // split at • (β€’) character // TODO: artist might need further splitting before comma or ampersand const sn = encodeURIComponent(songName); const an = encodeURIComponent(artistName); const acParams = features.geniusAutoclickBestResult ? `&bytm-ac-sn=${sn}&bytm-ac-an=${an}` : ""; const url = `https://genius.com/search?q=${sn}%20${an}${acParams}`; dbg && console.info(`BetterYTM: Resolved genius.com URL for song '${songName}' by '${artistName}': ${url}`); return url; } catch(err) { console.error(`BetterYTM: Couldn't resolve genius.com URL:`, err); } } //#SECTION autoclick best genius.com result /** * Automatically clicks the best matching result in a genius.com search */ function autoclickGeniusResult() { if(!location.pathname.includes("/search")) return; const miniCards = document.querySelectorAll(".mini_card-title_and_subtitle"); if(!miniCards || miniCards.length == 0) { if(geniusAutoclickTries < Math.round(triesLimit * 2.5)) // tries limit higher due to lower timeout { geniusAutoclickTries++; return setTimeout(autoclickGeniusResult, 100); // TODO: improve this } else return console.error(`BetterYTM: Couldn't find result minicards after ${geniusAutoclickTries} tries`); } const params = getGeniusAcParams(); if(!params) return console.info("BetterYTM: No query params present, not autoclicking"); const { songName, artistName } = params; const resultNode = findMatchingGeniusResult(songName, artistName); if(!resultNode) return console.error("BetterYTM: Couldn't find matching result node"); dbg && console.info(`BetterYTM: Found matching result node after ${geniusAutoclickTries} tries:`, resultNode); resultNode.click(); } let geniusAutoclickTries = 0; /** * Finds a result minicard node that matches the provided song and artist names (case insensitive) * @param {string} song * @param {string} artist * @returns {Node|null} */ function findMatchingGeniusResult(song, artist) { const miniCards = document.querySelectorAll(".mini_card-title_and_subtitle"); dbg && console.info(`BetterYTM: Found ${miniCards.length} minicards in results, searching for match...`); for(const card of miniCards) { if(card.childNodes && card.childNodes.length > 0) { const title = Array.from(card.childNodes).find(cn => cn.classList && cn.classList.contains("mini_card-title")); const subTitle = Array.from(card.childNodes).find(cn => cn.classList && cn.classList.contains("mini_card-subtitle")); if(!title || !subTitle || !title.innerText || !subTitle.innerText) continue; const songName = title.innerText.toLowerCase(); const artistName = subTitle.innerText.toLowerCase(); // TODO: there can be multiple artists and since their order and spelling on YTM and genius can differ, I need to split them and compare one by one if(songName.includes(song.toLowerCase()) && artistName.includes(artist.toLowerCase())) return card; } } return null; } /** * Returns autoclick query params if they exist, else returns null * @returns {({ songName: string, artistName: string })|null} */ function getGeniusAcParams() { const params = location.search.substring(1).split(/&/g); if(params.find(p => p.includes("bytm-ac-sn=")) && params.find(p => p.includes("bytm-ac-an="))) { const songName = decodeURIComponent(params.find(p => p.includes("bytm-ac-sn=")).split(/=/)[1]); const artistName = decodeURIComponent(params.find(p => p.includes("bytm-ac-an=")).split(/=/)[1]); return { songName, artistName }; } return null; } //#MARKER other /** * Returns the current domain as a constant string representation * @throws {Error} If script runs on an unexpected website * @returns {Domain} */ function getDomain() { const { hostname } = new URL(location.href); if(hostname.includes("music.youtube")) return "ytm"; else if(hostname.includes("youtube")) return "yt"; else if(hostname.includes("genius")) return "genius"; else throw new Error("BetterYTM is running on an unexpected website"); } /** * Returns the current video time in seconds * @returns {number|null} Returns null if the video time is unavailable */ function getVideoTime() { const domain = getDomain(); try { if(domain === "ytm") { const pbEl = document.querySelector("#progress-bar"); return pbEl.value ?? null; } else if(domain === "yt") // YT doesn't update the progress bar when it's hidden (YTM doesn't hide it) so TODO: come up with some solution here return 0; return null; } catch(err) { console.error("BetterYTM: Couldn't get video time due to error:", err); return null; } } /** * Inserts `afterNode` as a sibling just after the provided `beforeNode` * @param {HTMLElement} beforeNode * @param {HTMLElement} afterNode * @returns {HTMLElement} Returns the `afterNode` */ function insertAfter(beforeNode, afterNode) { beforeNode.parentNode.insertBefore(afterNode, beforeNode.nextSibling); return afterNode; } /** * Adds global CSS style through a <style> element in the document's <head> * @param {string} style CSS string * @param {string} ref Reference name that is included in the <style>'s ID */ function addGlobalStyle(style, ref) { const styleElem = document.createElement("style"); styleElem.id = `betterytm-${ref}-style`; if(styleElem.styleSheet) styleElem.styleSheet.cssText = style; else styleElem.appendChild(document.createTextNode(style)); document.querySelector("head").appendChild(styleElem); dbg && console.info(`BetterYTM: Inserted global style with ref '${ref}':`, styleElem); } init(); // call init() when script is loaded })();