Skip to content

Instantly share code, notes, and snippets.

@solarkraft
Last active October 9, 2022 00:17
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save solarkraft/edd9d49bcf0f548b1aa285da7a8bf3ae to your computer and use it in GitHub Desktop.
Save solarkraft/edd9d49bcf0f548b1aa285da7a8bf3ae to your computer and use it in GitHub Desktop.
// ==UserScript==
// @name Tidal declutterer
// @namespace http://tampermonkey.net/
// @version 0.7
// @description Gives the Tidal web app some nice visual tweaks and hides non-personalized recommendations (ads). User configurable.
// @author solarkraft@mailbox.org
// @match https://listen.tidal.com/*
// ==/UserScript==
// The app is made with react and it would be preferable to directly hook into that to
// remove the need for some hacks. However the use of webpack and possibly some other obfuscation
// techniques are making that a bit hard.
const config = {
//// Home screen cleanup
cleanHome: true,
// Titles of home screen sections that should be removed
badSections: ["TIDAL Originals", "Featured", "Popular Playlists", "Trending Playlists", "Popular Albums", "TIDAL Rising", "The Charts", "New Music Videos", "Album Experiences", "New Video Playlists", "Classic Video Playlists", "Movies", "Hits Video Playlists", "Podcasts", "Cratediggers", "Suggested New Tracks", "Suggested New Albums", "Party", "Staff Picks: Tracks", "Staff Picks: Albums"],
// Sections that should be filtered for playlists with certain keywords
filterSections: true, // List of titles (["For You"]) or true to filter all. Filtering everything costs some performance and somewhat protects against newly added sections with unpredictable names.
// Keywords playlists in partially bad sections should be filtered for (full text, not only titles)
badPlaylists: ["Created by TIDAL", "What's Hot"],
//// Redundant functionality
hideQualityChooser: true, // Quality can be changed in settings
hideNowPlayingToggle: true, // "Now Playing" screen can be toggled by clicking the album cover
disablePlayerNowPlayingToggle: true, // Prevent "Now Playing" screen opening when clicking anywhere on the player
//// Unnecessary functionality
hideBell: true,
hideMasterBadge: true,
hideExplicitBadge: true,
hideHistoryButtons: true,
hideVideos: true,
hideExplore: true,
//// Visual tweaks
playerBackgroundOpacity: 0.5, // null to disable
playerBlur: 8, // 0 to disable
tightenSidebar: true, // Bring sidebar tabs closer together
lightenSearchBar: true, // Reduce opacity of unselected search bar (and notification icon)
searchBarLeft: true, // Realign search bar to the left
tightenHome: true, // Reduce spacing between playlist sections
smallerUserMenu: true, // Hide overflow dot and reduce spacing
playlistHeaderShadow: true,
// Animations
animateContextMenus: true,
fadeInPlaylistTitles: true,
fadeInPages: true,
animatePlaylistHeader: true,
animateSelection: true, // Background for hovering over sidebar items
// Somewhat functional
widenPlayer: true, // Increases player width when space is available, also self-resizes on long titles
}
// Wait for an element change to execute a function (used for lazily loaded content)
const doAfterElementUpdate = (observedElement, func, repeat = false, observerConfig = { attributes: false, childList: true, subtree: false }) => {
// After a playlist item is removed the rest of the code will still attempt to add an observer
if(!observedElement || !func) { return; }
let observer = new MutationObserver((update) => {
func(update);
if(!repeat) {
// Clean up after activation
observer.disconnect();
observer = null;
}
});
observer.observe(observedElement, observerConfig);
return observer;
}
const waitForElementUpdate = async (observedElement) => new Promise((resolve) => {
doAfterElementUpdate(observedElement, resolve)
})
// Remove playlists containing bad keywords ("Created by TIDAL")
const filterSection = async (section) => {
console.debug("Filtering section")
let removePlaylistItemIfBad = async (playlistItem) => {
for(let badKeyword of config.badPlaylists) {
if(playlistItem.innerText.includes(badKeyword)) {
let itemTitle = playlistItem.childNodes[0]?.childNodes[0]?.childNodes[0]?.childNodes[0]?.childNodes[0]?.childNodes[1]?.childNodes[0]?.childNodes[3]?.childNodes[0]?.childNodes[0]?.innerText;
console.info("Removing playlist item", itemTitle || " ");
// classList would be overwritten by React later so this is the easiest option
playlistItem.remove();
}
}
}
// Handle later invocations (elements already exist and are not lazy-loaded)
if(section.childNodes[0]?.childNodes.length > 0) {
// Not first load. Elements already exist and are not lazily loaded. Life is easy ...
for(let playlistItem of section.childNodes[0].childNodes) {
removePlaylistItemIfBad(playlistItem);
}
} else {
// Oh no, the elements we want to modify don't exist yet. Invoke galaxy brain DOM mutation listening ...
console.debug("Section isn't filled yet. Let's do some mutation listening ...");
let update1 = await waitForElementUpdate(section);
//console.debug("Update 1", section.innerHTML, update1);
let update2 = await waitForElementUpdate(update1[0].addedNodes[0]);
//console.debug("Update 2", update2);
// Here we finally get all the (unfilled) playlist items
// One MutationRecord per item, with one addedNodes item each
await Promise.all(update2.map(async (record) => {
let playlistItem = record.addedNodes[0];
let update3 = await waitForElementUpdate(playlistItem);
let itemContent = update3[0].addedNodes[0];
//console.debug("Playlist item updated (Update 3)", update3, itemContent.innerText);
await removePlaylistItemIfBad(playlistItem);
}));
//console.debug("Done filtering section")
}
}
// Drill into header names to find bad ones. If a bad one has been found, remove this item (the header) and the one after it (the content).
const filterHomeScreen = async () => {
console.info("Hiding bad sections");
let mainPage = document.getElementsByTagName("main").main;
let playlistList = Array.from(mainPage?.childNodes[1].childNodes);
// Sections that shouldn't fully be removed, but contain bad recommendations
Promise.all(playlistList.map(async (el, i) => {
let sectionTitle = el.firstChild?.firstChild?.firstChild?.firstChild?.data;
if (!sectionTitle) {
//console.debug(i, "Not a heading", el)
return;
}
let heading = el;
let section = playlistList[i+1];
if(config.badSections.includes(sectionTitle)) {
console.info("Removing section", sectionTitle);
heading.classList.add("bad-section");
section.classList.add("bad-section");
} else if(config.filterSections === true || config.filterSections.includes(sectionTitle)) {
console.debug(sectionTitle, "is a partially bad section");
await filterSection(section);
//console.debug("Done filtering", sectionTitle, section, section.childNodes.length);
if(section.childNodes[0].childElementCount < 1) {
console.info("Removing empty section", sectionTitle);
heading.classList.add("bad-section");
section.classList.add("bad-section");
}
}
//}
}))
}
let setUpHomeCleanup = () => {
// Try to attach to list container until it succeeds (loading takes a while)
let isMutationRelevant = (mutation) => {
let pageName = mutation[0].target.attributes["data-test-page-name"].textContent;
console.debug("Page name", pageName)
// We only care about the home page, which has an empty page name string
if(pageName != "") { return false; }
// This is the typical mutation that happens on first load or when scrolling the page
if(mutation[0].addedNodes.length > 0) {
console.debug("Relevant change (elements added)", mutation);
return true;
}
// This is brittle
if(mutation.length == 2) {
console.debug("Relevant change (page switch)", mutation);
return true;
}
}
// The observer needs to be attached between the element's creation and the relevant change
const observeDelay = 100;
let i = 0;
let observeInterval = setInterval(() => {
let observedElement = document.getElementById("main")
console.debug("Attempting observe main page"/*, i, observedElement*/);
// We have the element we want to observe
if(observedElement) {
clearInterval(observeInterval);
console.info("Observing main page")
console.debug("Observing", observedElement)
doAfterElementUpdate(observedElement, (mutation) => {
console.debug("Main changed", mutation);
if(isMutationRelevant(mutation)) {
filterHomeScreen();
}
}, true);
}
// Cancel after taking too long
if(i > 100) {
console.warn("Failed to attach playlist observer")
clearInterval(observeInterval);
};
i++;
}, observeDelay)
}
const apppendStyle = (css) => {
const styleSheet = document.createElement("style")
styleSheet.innerText = css
document.head.appendChild(styleSheet)
}
(function() {
'use strict';
//// Remove superfluous UI elements (can be done in other ways)
if(config.hideQualityChooser) {
// Hide quality chooser (can be done in settings)
apppendStyle(`.css-ydx5c7 .css-1pnqyx0 { display: none; }`);
}
if(config.hideNowPlayingToggle) {
// Hide "now playing" screen toggle (can be done via album cover)
apppendStyle(`.css-ydx5c7 button[data-test="toggle-now-playing"] { display: none; }`); // Seems to have a race condition without the parent selector
}
if(config.disablePlayerNowPlayingToggle) {
// Disable opening full-screen view on click on the bottom player
// (This is especially hacky. Takes the play button's container's ::after element
// and expands it over the player's size to prevent clicks from going all the way through)
apppendStyle(`
#footerPlayer { pointer-events: none; }
#footerPlayer > * > * > * > *,
.css-ydx5c7 > * /* Right-side buttons */ {
pointer-events: all;
}
.css-1ri6uh9::after {
content: "";
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
pointer-events: all;
z-index: -1;
}
.css-1ri6uh9 { position: static; }`
);
}
//// Cosmetic
if(config.widenPlayer) {
// Make footer player thing wider
apppendStyle(`#footerPlayer { grid-auto-columns: revert; place-content: center stretch; }`); // Disable explicit grid sizing (skips title concatenation in many cases)
// Make player thing stretch to full available width
apppendStyle(`.css-1rr7pa1 { justify-content: stretch; }`);
apppendStyle(`.css-12v683h, .css-1l6gurb { width: revert; }`);
}
if(config.playerBackgroundOpacity) {
// Make footer transparent
apppendStyle(`#footerPlayer { background-color: rgba(34, 34, 38, ${config.playerBackgroundOpacity}); }`);
}
if(config.playerBlur > 0) {
// Apply blur to player
apppendStyle(`#footerPlayer { backdrop-filter: blur(${config.playerBlur}px); }`);
}
if(config.lightenSearchBar) {
// Reduce search field and notification bell opacity
apppendStyle(`.css-1un3uiy, button.feedBell--1-nIa { opacity: 0.5; transition: 0.5s; }`);
apppendStyle(`.css-1un3uiy:hover, button.feedBell--1-nIa { opacity: 1; transition: 0.3s; }`);
}
if(config.searchBarLeft) {
apppendStyle(`.css-1lir8gx { flex-direction: row-reverse; }`);
//apppendStyle(`.css-1un3uiy { margin-left: revert; }`);
}
if(config.animateSelection) {
// Transitions for selection highlights
apppendStyle(`.item--3_8nW:hover { transition: 0.15s; }`);
apppendStyle(`.item--3_8nW { transition: 0.25s; }`);
}
if(config.tightenHome) {
// Reduce spacing between playlist sections
apppendStyle(`.css-c2i2c1, div.MIX_LIST, div.MIXED_TYPES_LIST, div.ALBUM_LIST { --moduleBottomMargin: 2rem; }`);
// Reduce top list padding (kept empty for search bar and history buttons)
apppendStyle(`.css-666cnv { height: 5.5rem; }`);
}
if(config.animatePlaylistHeader) {
// Animate in artist/radio header after scrolling
apppendStyle(`@keyframes appear-large { from {opacity: 0; transform: translate3d(0, -2rem, 0); } to {opacity: 1;} }`);
apppendStyle(`.css-y0rt3m { animation: appear-large 0.5s; }`); // Appear animation
}
if(config.playlistHeaderShadow) {
// Nice little shadow (but only on the artist animation, in radios it already fades out)
apppendStyle(`#main[data-test-page-name="artist"] .css-y0rt3m { box-shadow: 0.5rem 0.5rem 0.5rem rgba(0, 0, 0, 0.5); }`);
}
if(config.animateContextMenus) {
// Animate in context menus
apppendStyle(`@keyframes appear-small { from {opacity: 0; transform: translate3d(0, -0.5rem, 0); } to {opacity: 1;} }`);
apppendStyle(`.contextMenu--2UG7P { animation: appear-small 0.25s; }`);
apppendStyle(`.contextMenu--2UG7P, [data-test="contextmenu"] { overflow: hidden; }`); // Disable scrolling, otherwise scroll bars would appear during some animations
}
if(config.fadeInPages) {
// Fade in pages
apppendStyle(`@keyframes fade-in { from {opacity: 0; } to {opacity: 1;} }`);
apppendStyle(`.container--OskMS { animation: fade-in 0.5s; }`); // Settings
}
if(config.fadeInPlaylistTitles) {
// Fade in playlist list titles
apppendStyle(`.css-o8w9if { animation: fade-in 0.25s; }`);
}
if(config.smallerUserMenu) {
// User menu
apppendStyle(`.profileIconWrapper--2fP1t { opacity: 0; }`); // Remove overflow dots
apppendStyle(`.userLoggedIn--3jqOa { margin-bottom: -2.4rem; }`);
}
if(config.tightenSidebar) {
// Vertically center home tab
apppendStyle(`.sidebar-section--3C8Oy.homeItem--35cRx { margin-bottom: -1rem; }`);
// Bring sidebar tabs closer together
apppendStyle(`.sidebar-section--3C8Oy { margin-bottom: -0.25rem; }`);
}
//// Remove pointless functionality
if(config.hideVideos) {
// Hide videos sidebar tabs
apppendStyle(`a[data-test="menu--explore_videos"], a[data-test="menu--favorite-videos"] { display: none; }`);
}
if(config.hideExplore) {
// Hide "explore" sidebar tab
apppendStyle(`a[data-test="menu--explore"] { display: none; }`);
}
if(config.hideBell) {
// Hide notification bell
apppendStyle(`.css-7w3j8j:enabled { display: none; }`);
}
if(config.hideMasterBadge) {
// Hide "explicit" badge (what are you, 12?)
apppendStyle(`svg[data-test="icon-IndatorsBadgesMaster"], .badge--27Nhw { display: none; }`);
}
if(config.hideExplicitBadge) {
// Hide "master" badge
apppendStyle(`.badge--explicit { display: none; }`);
}
if(config.hideHistoryButtons) {
// Hide history navigation buttons (forwards just DOESN'T WORK sometimes!)
apppendStyle(`.container--3s2l4 { display: none; }`);
}
//// Hide generic unpersonalized recommendations
// Hide podcasts, Charts, Tidal rising, Popular albums, ...
if(config.cleanHome) {
apppendStyle(`.bad-section { display: none !important; }`);
// Hacky workaround to prevent "Suggested New Tracks" from showing up (display: none doesn't work).
apppendStyle(`#main[data-test-page-name=""] .TRACK_LIST { height: 0; overflow: hidden; margin: 0; }`);
setUpHomeCleanup();
}
})();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment