// ==UserScript==
// @name Harmony: Copy tracklist to clipboard
// @namespace https://musicbrainz.org/user/chaban
// @description Copies the tracklist in MusicBrainz track parser compatible format to clipboard when clicking on track header
// @tag ai-created
// @version 2.1.2
// @author chaban
// @license MIT
// @match *://harmony.pulsewidth.org.uk/release*
// @exclude *://harmony.pulsewidth.org.uk/release/actions*
// @grant none
// @icon https://harmony.pulsewidth.org.uk/harmony-logo.svg
// ==/UserScript==
(function() {
'use strict';
const TOOLTIP_DISPLAY_DURATION = 2000;
/**
* Displays a temporary tooltip message near specific coordinates.
* @param {number} clientX The X coordinate of the cursor.
* @param {number} clientY The Y coordinate of the cursor.
* @param {string} message The message to display.
* @param {string} type 'success' or 'error' to determine styling.
*/
function showTooltipAtCursor(clientX, clientY, message, type) {
let tooltip = document.createElement('div');
tooltip.textContent = message;
tooltip.style.cssText = `
position: fixed;
background-color: ${type === 'success' ? '#4CAF50' : '#f44336'};
color: white;
padding: 5px 10px;
border-radius: 4px;
font-size: 12px;
z-index: 10000;
opacity: 0;
transition: opacity 0.3s ease-in-out;
pointer-events: none;
white-space: nowrap;
`;
document.body.appendChild(tooltip);
tooltip.style.left = `${clientX - (tooltip.offsetWidth / 2)}px`;
tooltip.style.top = `${clientY - tooltip.offsetHeight - 10}px`;
if (parseFloat(tooltip.style.left) < 5) {
tooltip.style.left = '5px';
}
if (parseFloat(tooltip.style.left) + tooltip.offsetWidth > window.innerWidth - 5) {
tooltip.style.left = `${window.innerWidth - tooltip.offsetWidth - 5}px`;
}
if (parseFloat(tooltip.style.top) < 5) {
tooltip.style.top = `${clientY + 10}px`;
}
setTimeout(() => {
tooltip.style.opacity = '1';
}, 10);
setTimeout(() => {
tooltip.style.opacity = '0';
tooltip.addEventListener('transitionend', () => tooltip.remove());
}, TOOLTIP_DISPLAY_DURATION);
}
/**
* Helper function to get clean text content by removing specified selectors.
* This function is now defined once in the outer scope.
* @param {HTMLElement} element The HTML element to extract text from.
* @param {string[]} selectorsToRemove An array of CSS selectors for elements to remove.
* @returns {string} The cleaned text content.
*/
const getCleanText = (element, selectorsToRemove) => {
if (!element) return '';
const tempDiv = document.createElement('div');
tempDiv.innerHTML = element.innerHTML;
Array.from(tempDiv.querySelectorAll(selectorsToRemove.join(','))).forEach(el => el.remove());
return tempDiv.textContent.trim();
};
/**
* Extracts tracklist data from a given table and formats it for MusicBrainz parser.
* @param {HTMLTableElement} table The tracklist table element.
* @param {MouseEvent} event The click event for tooltip positioning.
* @returns {string[]|null} An array of formatted tracklist strings, or null if an error occurs.
*/
function processTracklistTable(table, event) {
const headerRow = table.querySelector('thead tr');
if (!headerRow) {
showTooltipAtCursor(event.clientX, event.clientY, 'Table headers not found!', 'error');
return null;
}
const columnHeaderMap = {
trackNum: 'Track',
title: 'Title',
artist: 'Artists',
length: 'Length'
};
const columnIndices = {};
const headers = Array.from(headerRow.querySelectorAll('th'));
for (const [key, headerText] of Object.entries(columnHeaderMap)) {
const foundIndex = headers.findIndex(th => th.textContent.trim() === headerText);
if (foundIndex !== -1) {
columnIndices[key] = foundIndex;
} else {
showTooltipAtCursor(event.clientX, event.clientY, `Column "${headerText}" not found in this table!`, 'error');
return null;
}
}
const tracklistLines = [];
const rows = Array.from(table.querySelectorAll('tbody tr'));
for (const row of rows) {
const trackNumberCell = row.children[columnIndices.trackNum];
const titleCell = row.children[columnIndices.title];
const artistCell = row.children[columnIndices.artist];
const lengthCell = row.children[columnIndices.length];
const trackNumber = trackNumberCell ? trackNumberCell.textContent.trim() : '';
const trackTitle = getCleanText(titleCell, ['ul.alt-values']);
let trackArtist = '';
const artistCreditSpan = artistCell ? artistCell.querySelector('.artist-credit') : null;
if (artistCreditSpan) {
trackArtist = getCleanText(artistCreditSpan, ['ul.alt-values']);
}
const fullLength = getCleanText(lengthCell, ['ul.alt-values']);
const trackLength = fullLength.split('.')[0];
let formattedLine = `${trackNumber}. ${trackTitle}`;
if (trackArtist) {
formattedLine += ` - ${trackArtist}`;
}
formattedLine += ` (${trackLength})`;
tracklistLines.push(formattedLine);
}
return tracklistLines;
}
/**
* Main function to extract and copy tracklist data.
* @param {MouseEvent} event The click event that triggered this function.
* @returns {void}
*/
async function extractAndCopyTracklist(event) {
const clickedTrackHeader = event.target;
const table = clickedTrackHeader.closest('table.tracklist');
if (!table) {
showTooltipAtCursor(event.clientX, event.clientY, 'Tracklist table not found!', 'error');
return;
}
const tracklistLines = processTracklistTable(table, event);
if (tracklistLines && tracklistLines.length > 0) {
const clipboardContent = tracklistLines.join('\n');
try {
await navigator.clipboard.writeText(clipboardContent);
console.log('Tracklist copied to clipboard successfully!', clipboardContent);
showTooltipAtCursor(event.clientX, event.clientY, 'Tracklist copied!', 'success');
clickedTrackHeader.style.color = 'green';
setTimeout(() => clickedTrackHeader.style.color = 'blue', TOOLTIP_DISPLAY_DURATION);
} catch (err) {
console.error('Failed to copy tracklist to clipboard:', err);
showTooltipAtCursor(event.clientX, event.clientY, 'Failed to copy!', 'error');
clickedTrackHeader.style.color = 'red';
setTimeout(() => clickedTrackHeader.style.color = 'blue', TOOLTIP_DISPLAY_DURATION);
}
} else {
showTooltipAtCursor(event.clientX, event.clientY, 'No tracks found in this table!', 'error');
}
}
const initializeScript = () => {
const allTrackHeaders = document.querySelectorAll('table.tracklist th');
allTrackHeaders.forEach(header => {
if (header.textContent.trim() === 'Track') {
header.addEventListener('click', extractAndCopyTracklist);
header.style.cursor = 'pointer';
header.style.textDecoration = 'underline';
header.style.color = 'blue';
header.title = 'Click to copy this tracklist';
}
});
};
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initializeScript);
} else {
initializeScript();
}
})();