Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
184 changes: 184 additions & 0 deletions dist/deezerAtisketLink.user.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
// ==UserScript==
// @name Deezer: Add a-tisket import link
// @version 2023.4.12
// @namespace https://github.com/atj/userscripts
// @author atj
// @description Adds an a-tisket import link on Deezer release pages
// @homepageURL https://github.com/atj/userscripts#deezer-atisket-link
// @downloadURL https://raw.github.com/atj/userscripts/main/dist/deezerAtisketLink.user.js
// @updateURL https://raw.github.com/atj/userscripts/main/dist/deezerAtisketLink.user.js
// @supportURL https://github.com/atj/userscripts/issues
// @run-at document-idle
// @match *://www.deezer.com/*
// ==/UserScript==

(function () {
'use strict';

/**
* Returns a reference to the first DOM element with the specified value of the ID attribute.
* @param {string} elementId String that specifies the ID value.
*/
function dom(elementId) {
return document.getElementById(elementId);
}

/**
* Returns the first element that is a descendant of node that matches selectors.
* @param {string} selectors
* @param {ParentNode} node
*/
function qs(selectors, node = document) {
return node.querySelector(selectors);
}

/**
* Returns a promise that resolves after the given delay.
* @param {number} ms Delay in milliseconds.
*/
function delay(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}

/**
* Periodically calls the given function until it returns `true` and resolves afterwards.
* @param {(...params) => boolean} pollingFunction
* @param {number} pollingInterval
*/
function waitFor(pollingFunction, pollingInterval) {
return new Promise(async (resolve) => {
while (pollingFunction() === false) {
await delay(pollingInterval);
}
resolve();
});
}

/**
* Creates a DOM element from the given HTML fragment.
* @param {string} html HTML fragment.
*/
function createElement(html) {
const template = document.createElement('template');
template.innerHTML = html;
return template.content.firstElementChild;
}

/**
* Extracts the entity type and ID from a Deezer URL.
* @param {string} url URL of a Deezer entity page.
* @returns {[Deezer.EntityType,string]|undefined} Type and ID.
*/
function extractEntityFromURL(url) {
return url.match(/(album|artist)\/(\d+)/)?.slice(1);
}

const deezerPageLoadEventName = 'deezer-page-load';

const atisketUrl = 'https://atisket.pulsewidth.org.uk';
const atisketCountries = 'GB,US,DE';
const deezerListenButtonSelector = 'div[data-testid=play] > button';
const deezerListenButtonClassPrefix = 'css-';
const atisketButtonId = 'atisket';

function dispatchPageLoadEvent(element, detail) {
element?.dispatchEvent(new CustomEvent(deezerPageLoadEventName, {detail: detail}));
}

/**
* Initializes and returns a MutationObserver to watch the provided Deezer page loader element for style changes.
* @param {Element} pageLoaderElement A Deezer page loader element.
* @param {string} eventName Name of the custom event to trigger on the page loader element when a page load is detected.
* @returns {MutationObserver}
*/
function buildMutationObserver(pageLoaderElement) {
let loaderWidth = pageLoaderElement.style.width;

const mo = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
const newWidth = mutation.target.style.width;

if (loaderWidth === '100%' && newWidth == '0%') {
dispatchPageLoadEvent(pageLoaderElement, window.location.href);
}

loaderWidth = newWidth;
});
});

mo.observe(pageLoaderElement, {
attribute: true,
attributeFilter: ['style'],
attributeOldValue: false
});

return mo;
}

/**
* Retrieves the CSS class with the prefix "css-" from the Deezer "Listen" button.
* @returns {string|undefined}
*/
function getListenButtonCssClass() {
const classList = qs(deezerListenButtonSelector)?.classList;
if (!classList) {
return;
}

return Array.from(classList).find((klass) => klass.startsWith(deezerListenButtonClassPrefix));
}

function buildAtisketImportUrl(deezerId, baseUrl = atisketUrl, countries = atisketCountries) {
return `${baseUrl}/?preferred_countries=${encodeURIComponent(countries)}&deez_id=${deezerId}`;
}

function buildButtonMarkup(atisketUrl, cssClass) {
return `
<div id="${atisketButtonId}" style="margin-left: 16px;">
<a href="${atisketUrl}" target="_blank">
<button class="${cssClass}">
<span>➞ a-tisket</span>
</button>
</a>
</div>`;
}

function addAtisketButton(deezerUrl = window.location.href) {
const entity = extractEntityFromURL(deezerUrl);

if (!entity || entity[0] !== 'album') {
return;
}

dom(atisketButtonId)?.remove();

const buttonCssClass = getListenButtonCssClass();
if (!buttonCssClass) {
return;
}

qs('div[data-testid=toolbar]')?.append(
createElement(buildButtonMarkup(buildAtisketImportUrl(entity[1]), buttonCssClass))
);
}

const deezerPageLoaderSelector = '.page-loader-bar';

waitFor(() => qs(deezerPageLoaderSelector) !== null).then(() => {
const pageLoader = qs(deezerPageLoaderSelector);

pageLoader.addEventListener(deezerPageLoadEventName, (event) => {
const href = event.detail;
if (href !== window.location.href) {
return;
}

addAtisketButton();
});

// manually triggering the event at this stage fails as getListenButtonCssClass() fails
dispatchPageLoadEvent(pageLoader, window.location.href);
buildMutationObserver(pageLoader);
});

})();
7 changes: 7 additions & 0 deletions doc/_header.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# musicbrainz-scripts

**[Bookmarklets](https://en.wikipedia.org/wiki/Bookmarklet) and [Userscripts](https://en.wikipedia.org/wiki/Userscript) for [MusicBrainz.org](https://musicbrainz.org)**

In order to use one of the **bookmarklets** you have to save the compressed code snippet from the respective section below as a bookmark. Make sure to add the bookmark to a toolbar of your browser which you can easily access while you are editing on MusicBrainz.

While bookmarklets are good for trying things out because they do not require additional software to be installed, **userscripts** are more convenient if you need a snippet frequently. In case you have installed a userscript manager browser extension you can simply install userscripts from this page by clicking the *Install* button. Another benefit of them is that you will receive automatic updates if your userscript manager is configured accordingly.
5 changes: 5 additions & 0 deletions doc/development.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
## Development

Running `npm run build` compiles [all userscripts](src/userscripts/) and [all bookmarklets](src/bookmarklets/) before it generates an updated version of `README.md`. Before you can run this command you have to ensure that you have setup [Node.js](https://nodejs.org/) and have installed the dependencies of the build script via `npm install`.

If you want to compile a single minified bookmarklet from a module or a standalone JavaScript file you can run `node tools/bookmarkletify.js file.js`. The result will be output directly on screen and no files will be modified.
15 changes: 15 additions & 0 deletions jsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{
"compilerOptions": {
"lib": [
"dom",
"esnext"
],
"resolveJsonModule": true
},
"typeAcquisition": {
"include": [
"greasemonkey",
"jquery"
]
}
}
Loading