DEV Community

Cover image for A Browser Extension to highlight your favorite music on r/ListenToThis
Alex Adam
Alex Adam

Posted on • Originally published at alexadam.dev

A Browser Extension to highlight your favorite music on r/ListenToThis

When I want to discover new and overlooked music I go to https://www.reddit.com/r/listentothis/. Because there are a lot of new songs added every day, I need a way to quickly find my favorite genres. Fortunately, each post contains the genre info in its title so... I built a browser extension that highlights with different colors the posts containing some keywords (genres):

how to make a browser extension - overview

You can find the full source code here: https://github.com/alexadam/ListenToThis-Highlight

How to make a Browser Extension

Create a folder named music-highlight. This will be the root folder of the extension:

mkdir music-highlight
cd music-highlight
Enter fullscreen mode Exit fullscreen mode

Each browser extension must have a manifest file containing metadata like author's name, description, permissions, license, what scripts are included etc. You can find more details here: https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/manifest.json

Create a file manifest.json and paste:

{
  "manifest_version": 2,
  "name": "music-highlight",
  "version": "1",
  "author": "Your Name",
  "homepage_url": "https://www.reddit.com/r/listentothis/",
  "description": "Highlight your favorite music genres on r/ListenToThis",
  "content_scripts": [{
    "matches": ["*://*.reddit.com/r/listentothis*"],
    "js": ["colors.js"]
  }],
  "permissions": [
    "activeTab",
    "https://www.reddit.com/r/listentothis/",
    "https://old.reddit.com/r/listentothis/",
    "storage"
  ]
}
Enter fullscreen mode Exit fullscreen mode

In the content_scripts section we tell Chrome or Firefox to run colors.js on the web pages whose URL matches that regex pattern - in our case both old & new reddit.

By injecting color.js in the reddit's page, we can access and modify its content using the standard DOM APIs (change colors, add new HTML elements etc).More about the content scripts: https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/Content_scripts

How color.js works:

  • for each post on the page
  • get the post's title & url
  • if the title contains a genre on your list
  • change the background color

How do we find a find a post? Right click on it -> Inspect element. We'll get all the HTML elements containing the scrollerElement class or the thing class, on the old Reddit.

how to make a browser extension - find element

Let's create a file colors.js then add the function that returns all posts:

const getAllPosts = () => {
  // old reddit
  const allPosts = document.getElementsByClassName('thing');
  if (allPosts.length === 0) {
    // new reddit
    return document.getElementsByClassName('scrollerItem');
  }
  return allPosts
}
Enter fullscreen mode Exit fullscreen mode

Then, create a function that extracts the title:

const getTitle = (post) => {
  // old reddit
  const titleElem = post.querySelector('a.title');
  // new reddit
  if (!titleElem) {
    return post.querySelector('h3');
  }
  return titleElem
}
Enter fullscreen mode Exit fullscreen mode

The title format is like Band - Song [genre1/genre2, genre3] (2020) and we only need what's inside the square brackets:

const getGenresAsString = (titleElem) => {
  const text = titleElem.innerText.toLowerCase()

  // Extract the genres from the title 
  const genresRegex = /\[([^\]]+)\]/g
  const match = genresRegex.exec(text)

  // Skip over posts that are not properly formatted
  if (!match) {
    return null
  }
  return match[0]
}
Enter fullscreen mode Exit fullscreen mode

Let's add a list with our favorite genres and some colors:

const favoriteGenres = {
  'ambient': '#fa8335',
  'blues': '#0df9f9',
  'country': '#fad337',
  'chill': '#a8f830',
  'funk': '#F2EF0C',
  'jazz': '#fba19d',
  'soul': '#aca2bb',
}
Enter fullscreen mode Exit fullscreen mode

Now we create a function that will iterate through all of our fav. genres defined in the list above. If a genre is mentioned in the string returned by getGenresAsString, the function returns its color (or the color of the last one, if there are multiple matches).

const getBGColor = (allGenresStr, favGenres) => {
  let bgColor = null

  // Test if the post contains any of our fav. genres
  for (const genre of Object.keys(favGenres)) {
    const genreRegex = new RegExp('.*' + genre + '.*', "i")
    if (!genreRegex.test(allGenresStr)) {
      continue
    }
    bgColor = 'background-color: ' + favGenres[genre] + ' !important'
  }

  return bgColor
}
Enter fullscreen mode Exit fullscreen mode

Before trying it, there is another problem we have to solve... the new Reddit loads the content dynamically and, at the time color.js is run on the page, there is no useful data. Moreover, when you scroll down, new content is added and we have to reapply the logic on it and update the colors. So, we need a new content listener that will trigger the code:

const observeDOM = (() => {
  const MutationObserver = window.MutationObserver || window.WebKitMutationObserver;
  const eventListenerSupported = window.addEventListener;

  return (obj, callback) => {
    if (MutationObserver) {
      const obs = new MutationObserver((mutations, observer) => {
        if (mutations[0].addedNodes.length)
          callback(mutations[0].addedNodes);
      });
      obs.observe(obj, {
        childList: true,
        subtree: true
      });
    } else if (eventListenerSupported) {
      obj.addEventListener('DOMNodeInserted', callback, false);
      obj.addEventListener('DOMNodeRemoved', callback, false);
    }
  };

})();

// detect if on the new Reddit before the content is loaded
const targetElem = document.getElementById('2x-container')
if (targetElem) {
  observeDOM(targetElem, (addedNodes) => {
    // whenever new content is added
    addColorsOnSongs(favoriteGenres);
  });
}
Enter fullscreen mode Exit fullscreen mode

After we put everything together, color.js will look like this:

const favoriteGenres = {
  'ambient': '#fa8335',
  'blues': '#0df9f9',
  'country': '#fad337',
  'chill': '#a8f830',
  'funk': '#F2EF0C',
  'jazz': '#fba19d',
  'soul': '#aca2bb',
}

const getAllPosts = () => {
  // old reddit
  const allPosts = document.getElementsByClassName('thing');
  if (allPosts.length === 0) {
    // new reddit
    return document.getElementsByClassName('scrollerItem');
  }
  return allPosts
}

const getTitle = (post) => {
  // old reddit
  const titleElem = post.querySelector('a.title');
  // new reddit
  if (!titleElem) {
    return post.querySelector('h3');
  }
  return titleElem
}

const getGenresAsString = (titleElem) => {
  const text = titleElem.innerText.toLowerCase()

  // Extract the genres from the title 
  const genresRegex = /\[([^\]]+)\]/g
  const match = genresRegex.exec(text)

  // Skip over posts that are not properly formatted
  if (!match) {
    return null
  }
  return match[0]
}


const getBGColor = (allGenresStr, favGenres) => {
  let bgColor = null

  // Test if the post contains any of our fav. genres
  for (const genre of Object.keys(favGenres)) {
    const genreRegex = new RegExp('.*' + genre + '.*', "i")
    if (!genreRegex.test(allGenresStr)) {
      continue
    }
    bgColor = 'background-color: ' + favGenres[genre] + ' !important'
  }

  return bgColor
}

const changePostColor = (post, bgColor) => {
  post.setAttribute('style', bgColor);
    for (let child of post.children) {
      child.setAttribute('style', bgColor);
      for (let child2 of child.children) {
        child2.setAttribute('style', bgColor);
      }
    }
}

const addColorsOnSongs = (colorData) => {
  const allPosts = getAllPosts();

  for (const post of allPosts) {

    const titleElem = getTitle(post)

    if (!titleElem) continue

    const genresStr = getGenresAsString(titleElem)
    const bgColor = getBGColor(genresStr, favoriteGenres)

    if (!bgColor) continue

    // Change the post's & its children bg color
    changePostColor(post, bgColor)

  }

}

addColorsOnSongs(favoriteGenres)

const observeDOM = (() => {
  const MutationObserver = window.MutationObserver || window.WebKitMutationObserver;
  const eventListenerSupported = window.addEventListener;

  return (obj, callback) => {
    if (MutationObserver) {
      const obs = new MutationObserver((mutations, observer) => {
        if (mutations[0].addedNodes.length)
          callback(mutations[0].addedNodes);
      });
      obs.observe(obj, {
        childList: true,
        subtree: true
      });
    } else if (eventListenerSupported) {
      obj.addEventListener('DOMNodeInserted', callback, false);
      obj.addEventListener('DOMNodeRemoved', callback, false);
    }
  };

})();

// detect if on the new Reddit before the content is loaded
const targetElem = document.getElementById('2x-container')
if (targetElem) {
  observeDOM(targetElem, (addedNodes) => {
    addColorsOnSongs(favoriteGenres);
  });
}
Enter fullscreen mode Exit fullscreen mode

Let's test it...

On Chrome:

  • Navigate to chrome://extensions
  • Toggle Developer Mode (top right)
  • click on Load Unpacked
  • Select the extension's folder music-highlight
  • click Open

On Firefox:

  • Navigate to about:debugging
  • click on This Firefox (top left)
  • click on Load Temporary Add-on
  • go to the extension's folder
  • select manifest.json
  • click Open

Then visit https://old.reddit.com/r/listentothis/ and you should see:

how to make a browser extension - old reddit colors

and https://www.reddit.com/r/listentothis/

how to make a browser extension - new reddit colors

Solving an UX Problem

Although highlighting with different colors makes it easier to find interesting music on that list, we can still improve it. I want to add an element that displays the genres with a bigger font and, when you click on it, you go directly to the linked song - instead of opening the reddit comments page.

So, if a post contains at least one of my fav. genres:

  • create a hyperlink HTML Element
  • with the href pointing to the source
  • and all the matched genres as the inner text
  • then append it to the reddit's post

Let start by adding some useful functions - getSongURL extracts the source URL from the post:

const getSongURL = (titleElem, post) => {
  // old reddit
  let href = titleElem.href
  // new reddit
  if (!href) {
    const extLink = post.querySelector('a.styled-outbound-link')
    if (extLink) {
      return extLink.href
    }
  }
  return href
}
Enter fullscreen mode Exit fullscreen mode

And createSongLink creates the HTML Element:

const createSongLink = (titleElem, post, genresText) => {
  post.style.position = 'relative'
  let linkElem = document.createElement('a')
  linkElem.className = "favGenresLink"
  linkElem.style.position = 'absolute'
  linkElem.style.right = '20px'
  linkElem.style.bottom = '0'
  linkElem.style.height = '50px'
  linkElem.style.color = 'black'
  linkElem.style.fontSize = '50px'
  linkElem.style.zIndex = '999999'
  linkElem.innerText = genresText
  linkElem.href = getSongURL(titleElem, post)
  return linkElem
}
Enter fullscreen mode Exit fullscreen mode

Let's modify getBGColor to return both the colors and all the matched genres, as text:

const getBGColor = (allGenresStr, favGenres) => {
  let bgColor = null
  let favGenresStr = ''

  // Test if the post contains any of our fav. genres
  for (const genre of Object.keys(favGenres)) {
    const genreRegex = new RegExp('.*' + genre + '.*', "i")
    if (!genreRegex.test(allGenresStr)) {
      continue
    }
    bgColor = 'background-color: ' + favGenres[genre] + ' !important'
    favGenresStr += genre + ' '
  }

  return {bgColor, favGenresStr}
}
Enter fullscreen mode Exit fullscreen mode

Update addColorsOnSongs:

const addColorsOnSongs = (colorData) => {
  const allPosts = getAllPosts();

  for (const post of allPosts) {

    // Ingnore this post if it already
    // contains a favGenresLink
    let colorObj = post.querySelector('a.favGenresLink');
    if (colorObj) continue

    const titleElem = getTitle(post)

    if (!titleElem) continue

    const genresStr = getGenresAsString(titleElem)
    const {bgColor, favGenresStr} = getBGColor(genresStr, favoriteGenres)

    if (!bgColor) continue

    // Change the post's & its children bg color
    changePostColor(post, bgColor)

    // Create the genres link and add it to the post
    const linkElem = createSongLink(titleElem, post, favGenresStr)
    post.insertAdjacentElement('afterbegin', linkElem)
  }

}
Enter fullscreen mode Exit fullscreen mode

Each new HTML Element we add will trigger the new content listener created earlier (to update the colors on dynamic content). To avoid an infinite loop - new content listener -> addColors() -> create & add genres link -> trigger new content listener - we must add a condition:

if (targetElem) {
  observeDOM(targetElem, (addedNodes) => {
    // ignore favGenresLink to avoid an infinite loop
    for (let addedNode of addedNodes) {
      if (addedNode.classList.contains('favGenresLink')) {
        return
      }
    }

    // whenever new content is added
    addColorsOnSongs(favoriteGenres);
  });
}
Enter fullscreen mode Exit fullscreen mode

how to make a browser extension - genres

This is the colors.js file with all the updates:

const favoriteGenres = {
  'ambient': '#fa8335',
  'blues': '#0df9f9',
  'country': '#fad337',
  'chill': '#a8f830',
  'funk': '#F2EF0C',
  'jazz': '#fba19d',
  'soul': '#aca2bb',
}

const getAllPosts = () => {
  // old reddit
  const allPosts = document.getElementsByClassName('thing');
  if (allPosts.length === 0) {
    // new reddit
    return document.getElementsByClassName('scrollerItem');
  }
  return allPosts
}

const getTitle = (post) => {
  // old reddit
  const titleElem = post.querySelector('a.title');
  // new reddit
  if (!titleElem) {
    return post.querySelector('h3');
  }
  return titleElem
}

const getGenresAsString = (titleElem) => {
  const text = titleElem.innerText.toLowerCase()

  // Extract the genres from the title 
  const genresRegex = /\[([^\]]+)\]/g
  const match = genresRegex.exec(text)

  // Skip over posts that are not properly formatted
  if (!match) {
    return null
  }
  return match[0]
}


const getBGColor = (allGenresStr, favGenres) => {
  let bgColor = null
  let favGenresStr = ''

  // Test if the post contains any of our fav. genres
  for (const genre of Object.keys(favGenres)) {
    const genreRegex = new RegExp('.*' + genre + '.*', "i")
    if (!genreRegex.test(allGenresStr)) {
      continue
    }
    bgColor = 'background-color: ' + favGenres[genre] + ' !important'
    favGenresStr += genre + ' '
  }

  return {bgColor, favGenresStr}
}

const changePostColor = (post, bgColor) => {
  post.setAttribute('style', bgColor);
  for (let child of post.children) {
    child.setAttribute('style', bgColor);
    for (let child2 of child.children) {
      child2.setAttribute('style', bgColor);
    }
  }
}

const getSongURL = (titleElem, post) => {
  // old reddit
  let href = titleElem.href
  // new reddit
  if (!href) {
    const extLink = post.querySelector('a.styled-outbound-link')
    if (extLink) {
      return extLink.href
    }
  }
  return href
}

const createSongLink = (titleElem, post, genresText) => {
  post.style.position = 'relative'
  let linkElem = document.createElement('a')
  linkElem.className = "favGenresLink"
  linkElem.style.position = 'absolute'
  linkElem.style.right = '20px'
  linkElem.style.bottom = '0'
  linkElem.style.height = '50px'
  linkElem.style.color = 'black'
  linkElem.style.fontSize = '50px'
  linkElem.style.zIndex = '999999'
  linkElem.innerText = genresText
  linkElem.href = getSongURL(titleElem, post)
  return linkElem
}

const addColorsOnSongs = (colorData) => {
  const allPosts = getAllPosts();

  for (const post of allPosts) {

    // ignore
    let colorObj = post.querySelector('a.favGenresLink');
    if (colorObj) continue //TODO

    const titleElem = getTitle(post)

    if (!titleElem) continue

    const genresStr = getGenresAsString(titleElem)
    const {bgColor, favGenresStr} = getBGColor(genresStr, favoriteGenres)

    if (!bgColor) continue

    // Change the post's & its children bg color
    changePostColor(post, bgColor)

    // Create the genres link and add it to the post
    const linkElem = createSongLink(titleElem, post, favGenresStr)
    post.insertAdjacentElement('afterbegin', linkElem)
  }

}

addColorsOnSongs(favoriteGenres)


const observeDOM = (() => {
  const MutationObserver = window.MutationObserver || window.WebKitMutationObserver;
  const eventListenerSupported = window.addEventListener;

  return (obj, callback) => {
    if (MutationObserver) {
      const obs = new MutationObserver((mutations, observer) => {
        if (mutations[0].addedNodes.length)
          callback(mutations[0].addedNodes);
      });
      obs.observe(obj, {
        childList: true,
        subtree: true
      });
    } else if (eventListenerSupported) {
      obj.addEventListener('DOMNodeInserted', callback, false);
      obj.addEventListener('DOMNodeRemoved', callback, false);
    }
  };

})();

// detect if on the new Reddit before the content is loaded
const targetElem = document.getElementById('2x-container')
if (targetElem) {
  observeDOM(targetElem, (addedNodes) => {
    // ignore favGenresLink to avoid an infinite loop
    for (let addedNode of addedNodes) {
      if (addedNode.classList.contains('favGenresLink')) {
        return
      }
    }

    // whenever new content is added
    addColorsOnSongs(favoriteGenres);
  });
}
Enter fullscreen mode Exit fullscreen mode

Custom genres and colors

Now, the favorite genres are stored in a list, in code. Let's change this and create a settings page where you can define your own favorite genres.

First, we have to update manifest.json and specify the options page:

...
  "options_page": "options.html",
  "options_ui": {
    "page": "options.html"
  }
...
Enter fullscreen mode Exit fullscreen mode

Create the file options.html:

<html>
  <head></head>
  <body>
    <h1>Music Highlight Options</h1>

    <div id="root">
      <div id="container">
      </div>
      <button id="add" class="button" type="button" name="button">Add genre</button>
      <hr />
      <div id="buttons">
          <button id="save" class="button" type="button" name="button">Save</button>
          <div id="status"></div>
      </div>
    </div>

    <script src="options.js"></script>
  </body>
</html>
Enter fullscreen mode Exit fullscreen mode

And options.js...

const defaultGenres = {
  'ambient': '#fa8335',
  'blues': '#0df9f9',
  'country': '#fad337',
  'chill': '#a8f830',
  'funk': '#F2EF0C',
  'jazz': '#fba19d',
  'soul': '#aca2bb',
}

const restoreOptions = () => {
  chrome.storage.local.get('colors', (data) => {
    if (!data 
        || Object.keys(data).length === 0 
      || Object.keys(data.colors).length === 0) {
      createColorsUI(defaultGenres);
    } else {
      createColorsUI(data.colors);
    }
  });
}

document.addEventListener('DOMContentLoaded', restoreOptions);
Enter fullscreen mode Exit fullscreen mode

Let's add a function that creates the genre's color inputs:

const createColorInput = (genre, color, id) => {
  let genreInputLabel = document.createElement('span')
  genreInputLabel.innerText = 'Genre:'
  genreInputLabel.className = 'genreNameLabel'
  let genreInput = document.createElement('input')
  genreInput.className = 'genreName'
  genreInput.type = 'text'
  genreInput.value = genre
  let colorInputLabel = document.createElement('span')
  colorInputLabel.innerText = 'Color:'
  colorInputLabel.className = 'colorNameLabel'
  let colorInput = document.createElement('input')
  colorInput.className = 'colorName'
  colorInput.type = 'color'
  colorInput.value = color
  let removeButton = document.createElement('button')
  removeButton.innerText = 'Remove'
  removeButton.className = 'removeButton button'
  removeButton.addEventListener('click', ((e) => {
    let tmpElem = document.getElementById(e.target.parentElement.id)
    if (tmpElem && tmpElem.parentElement) {
      tmpElem.parentElement.removeChild(tmpElem)
    }
  }))

  let group = document.createElement('div')
  group.id = 'data' + id
  group.className = 'genreColorGroup'
  group.appendChild(genreInputLabel)
  group.appendChild(genreInput)
  group.appendChild(colorInputLabel)
  group.appendChild(colorInput)
  group.appendChild(removeButton)

  let container = document.getElementById('container')
  container.appendChild(group)
}
Enter fullscreen mode Exit fullscreen mode

Next, the function createColorsUI

const createColorsUI = (data) => {
  let index = 0
  for (let variable in data) {
    if (data.hasOwnProperty(variable)) {
      createColorInput(variable, data[variable], index)
      index++
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

and the addOption function:

const addOption = () => {
  let index = Math.floor(Math.random() * 1000000)
  createColorInput('misc', '#000000', index)
}

document.getElementById('add').addEventListener('click', addOption);
Enter fullscreen mode Exit fullscreen mode

Save the changes to chrome.storage.local:

const saveOptions = () => {
  let allGenreNames = document.getElementsByClassName('genreName')
  let allColorNames = document.getElementsByClassName('colorName')

  let data = {}

  for (let i = 0; i < allGenreNames.length; i++) {
    let name = allGenreNames[i].value
    let color = allColorNames[i].value
    data[name] = color
  }

  chrome.storage.local.set({
    colors: data
  }, () => {
    let status = document.getElementById('status');
    status.textContent = 'Options saved.';
    setTimeout(() => {
      status.textContent = '';
    }, 2750);
  });
}

document.getElementById('save').addEventListener('click', saveOptions);
Enter fullscreen mode Exit fullscreen mode

How to open the Options page on Chrome:

  • click on the Extesions button, on the toolbar
  • click on music-highligt's More Actions menu
  • click on Options

how to make a browser extension - options on Chrome

And on Firefox:

  • click on the extension's icon, on the toolbar
  • click on Manage Extension
  • go to the Preferences menu

how to make a browser extension - options on Firefox

Last part

Next, to make it work, we have to link the saved options to the highlight code:

Open colors.js and replace:

addColorsOnSongs(favoriteGenres)
Enter fullscreen mode Exit fullscreen mode

with:

chrome.storage.local.get('colors', (data) => {
  if (!data || Object.keys(data).length === 0 || Object.keys(data.colors).length === 0) {
      addColorsOnSongs(favoriteGenres);
  } else {
    console.log(data)
      addColorsOnSongs(data.colors);
  }
});
Enter fullscreen mode Exit fullscreen mode

and update:

// detect if on the new Reddit before the content is loaded
const targetElem = document.getElementById('2x-container')
if (targetElem) {
  observeDOM(targetElem, (addedNodes) => {
    // ignore favGenresLink to avoid an infinite loop
    for (let addedNode of addedNodes) {
      if (addedNode.classList.contains('favGenresLink')) {
        return
      }
    }

    // whenever new content is added
    //addColorsOnSongs(favoriteGenres);

    chrome.storage.local.get('colors', (data) => {
      if (!data || Object.keys(data).length === 0 || Object.keys(data.colors).length === 0) {
          addColorsOnSongs(favoriteGenres);
      } else {
        console.log(data)
          addColorsOnSongs(data.colors);
      }
    });
  });
}
Enter fullscreen mode Exit fullscreen mode

Create a browser action & icon

Let's add an icon to the extension's button on the toolbar menu (we'll reuse the r/ListenToThis icon):

  "icons": {
    "48": "icons/logo.png"
  },
  "browser_action": {
    "default_icon": {
      "48": "icons/logo.png"
    },
    "default_title": "Music-Highlight",
    "browser_style": true,
    "default_popup": "action.html"
  }
Enter fullscreen mode Exit fullscreen mode

Since going to the Options page involves so many steps, let's create a menu with an Options button that will open the page with one click:

create action.html:

<html>
<body>
  <button id="openOptions">Options...</button>
  <script src="action.js" charset="utf-8"></script>
</body>
</html>
Enter fullscreen mode Exit fullscreen mode

and action.js

document.getElementById('openOptions').addEventListener('click', (e) => {
  let optionsUrl = chrome.extension.getURL("./options.html");
  chrome.tabs.create({
    url: optionsUrl
  });
})
Enter fullscreen mode Exit fullscreen mode

Now, when you click on the extension's toolbar icon, a menu will pop-up with a button that opens the Options page!

how to make a browser extension - menu

how to make a browser extension - menu page

You can find the full source code here: https://github.com/alexadam/ListenToThis-Highlight

Top comments (0)