Genius Gradient Assistant

Mass-edit gradient for album pages on Genius

// ==UserScript==
// @name         Genius Gradient Assistant 
// @namespace    https://genius.com/
// @version      2.387
// @description  Mass-edit gradient for album pages on Genius
// @author       thousandeyes
// @match        *://genius.com/*-lyrics
// @match        *://genius.com/*-lyrics?*
// @icon         https://imgur.com/qgv8m0o.png
// @grant        GM_addStyle
// @grant        GM_getValue
// @grant        GM_setValue
// @license      MIT
// ==/UserScript==
 
(function() {
    'use strict';
 
    GM_addStyle(`
        #genius-gradient-batch-ui {
          position: fixed;
          bottom: 16px;
          right: 16px;
          background: #fff;
          padding: 16px;
          border: 1px solid #e0e0e0;
          border-radius: 12px;
          box-shadow: 0 4px 12px rgba(0,0,0,0.15);
          z-index: 999999;
          width: 360px;
          font-family: 'Programme', 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
        }
 
        #genius-gradient-batch-ui input[type="text"] {
          width: 100%;
          padding: 8px;
          margin-bottom: 6px;
          border: 1px solid #ccc;
          border-radius: 8px;
          background: #fff;
          color: #000;
        }
 
        #genius-gradient-batch-ui div {
          color: #333;
        }
 
        #genius-gradient-batch-ui button {
          padding: 10px;
          border: 1px solid #000000;
          border-radius: 10px;
          background: #f5f5f5;
          color: #000;
          cursor: pointer;
          font-family: 'Programme', 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
        }
 
        #genius-gradient-batch-ui button[aria-label="Close"] {
          background: transparent;
          border: none;
          color: #333;
          font-size: 16px;
        }
 
        #genius-gradient-batch-ui div[style*="max-height: 240px"] {
          background: #fafafa;
          border-radius: 8px;
          padding: 8px;
          overflow-y: auto;
        }
 
        #genius-gradient-batch-ui div[style*="height: 6px"] {
          background: #e0e0e0;
        }
 
        #genius-gradient-batch-ui div[style*="height: 6px"] > div {
          background: #ffd700;
        }
 
        #gradient-assistant-toggle {
          position: fixed;
          bottom: 16px;
          right: 16px;
          padding: 10px 16px;
          border: 1px solid #ccc;
          border-radius: 8px;
          background: #f5f5f5;
          color: #000;
          cursor: pointer;
          font-family: 'Programme', 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
          font-weight: 500;
          z-index: 999998;
          box-shadow: 0 2px 8px rgba(0,0,0,0.1);
        }
 
        #gradient-assistant-toggle:hover {
          background: #e0e0e0;
        }
    `);
 
    function backgroundHandler(request) {
        if (request.type === "getCookie") {
            return new Promise((resolve) => {
                const token = getCsrf();
                resolve(token || null);
            });
        }
    }
 
    (function installNetworkHexTap() {
        const HEX6 = /^#?[0-9a-fA-F]{6}$/;
        const wantField = (k) => /song_art_primary_color|song_art_secondary_color/i.test(k);
        function normalizeHex6(s) {
            if (!s) return null;
            const m = String(s).match(/[0-9a-fA-F]{6}/);
            if (m) return "#" + m[0].toUpperCase();
            const rgbMatch = String(s).match(/^rgb\s*\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)$/i);
            if (rgbMatch) {
                const r = Math.min(255, parseInt(rgbMatch[1], 10));
                const g = Math.min(255, parseInt(rgbMatch[2], 10));
                const b = Math.min(255, parseInt(rgbMatch[3], 10));
                return "#" + [r, g, b].map((x) => x.toString(16).padStart(2, "0")).join("").toUpperCase();
            }
            return null;
        }
        function pullFromObject(obj) {
            if (!obj || typeof obj !== "object") return null;
            let out = {};
            for (const k of Object.keys(obj)) {
                const v = obj[k];
                if (wantField(k) && typeof v === "string") {
                    const hex = normalizeHex6(v);
                    if (hex) out[k] = hex;
                }
            }
            return Object.keys(out).length ? out : null;
        }
        function pullFromUrlEncoded(str) {
            try {
                const p = new URLSearchParams(str);
                let out = {};
                for (const [k, v] of p.entries()) {
                    if (wantField(k)) {
                        const hex = normalizeHex6(v);
                        if (hex) out[k] = hex;
                    }
                }
                return Object.keys(out).length ? out : null;
            } catch (e) {
                return null;
            }
        }
        function maybeEmit(found, context) {
            if (!found) return;
            const colors = {
                primary: found.song_art_primary_color || found.primary,
                secondary: found.song_art_secondary_color || found.secondary
            };
            if (!HEX6.test(colors.primary)) colors.primary = null;
            if (!HEX6.test(colors.secondary)) colors.secondary = null;
            if (colors.primary || colors.secondary) {
                window.__lastSongArtHex = colors;
                window.dispatchEvent(new CustomEvent("song-art-hex", { detail: colors, bubbles: false }));
                console.log("[HEX tap]", context, colors);
            }
        }
        const _fetch = window.fetch;
        window.fetch = async function(input, init = {}) {
            try {
                let body = init && init.body;
                if (body) {
                    if (typeof body === "string") {
                        let found = null;
                        if (body.trim().startsWith("{")) {
                            try { found = pullFromObject(JSON.parse(body)); } catch {}
                        }
                        if (!found) found = pullFromUrlEncoded(body);
                        maybeEmit(found, "fetch:string-body");
                    } else if (body instanceof FormData) {
                        const obj = {};
                        for (const [k, v] of body.entries()) obj[k] = v;
                        maybeEmit(pullFromObject(obj), "fetch:formdata");
                    }
                }
            } catch (e) {
                console.warn("HEX tap(fetch) error:", e);
            }
            return _fetch.apply(this, arguments);
        };
        const _open = XMLHttpRequest.prototype.open;
        const _send = XMLHttpRequest.prototype.send;
        XMLHttpRequest.prototype.open = function(method, url) {
            this.__hexTapUrl = url;
            this.__hexTapMethod = method;
            return _open.apply(this, arguments);
        };
        XMLHttpRequest.prototype.send = function(body) {
            try {
                if (typeof body === "string") {
                    let found = null;
                    if (body.trim().startsWith("{")) {
                        try { found = pullFromObject(JSON.parse(body)); } catch {}
                    }
                    if (!found) found = pullFromUrlEncoded(body);
                    maybeEmit(found, "xhr:string-body");
                } else if (body instanceof FormData) {
                    const obj = {};
                    for (const [k, v] of body.entries()) obj[k] = v;
                    maybeEmit(pullFromObject(obj), "xhr:formdata");
                }
            } catch (e) {
                console.warn("HEX tap(xhr) error:", e);
            }
            return _send.apply(this, arguments);
        };
    })();
 
    const uiId = "genius-gradient-batch-ui";
    function createUI() {
        if (document.getElementById(uiId)) return;
        const ui = document.createElement("div");
        ui.id = uiId;
        ui.style.position = "fixed";
        ui.style.bottom = "16px";
        ui.style.right = "16px";
        ui.style.zIndex = "999999";
        ui.style.background = "#fff";
        ui.style.borderRadius = "12px";
        ui.style.boxShadow = "0 4px 12px rgba(0,0,0,0.15)";
        ui.style.padding = "16px";
        ui.style.width = "360px";
        ui.style.display = "grid";
        ui.style.gap = "12px";
        ui.style.fontFamily = "'Programme', 'Inter', -apple-system, BlinkMacSystemFont, sans-serif";
 
        const title = document.createElement("div");
        title.textContent = "Gradient Assistant";
        title.style.fontWeight = "600";
        title.style.fontSize = "16px";
        title.style.color = "#000";
 
        const copySection = document.createElement("div");
        copySection.style.display = "grid";
        copySection.style.gap = "6px";
        const copyLabel = document.createElement("div");
        copyLabel.textContent = "Current Gradient:";
        copyLabel.style.fontSize = "12px";
        copyLabel.style.color = "#333";
        const copyBtn = document.createElement("button");
        copyBtn.textContent = "Copy Gradient";
        copyBtn.style.padding = "8px";
        copyBtn.style.background = "#f5f5f5";
        copyBtn.style.color = "#000";
        copyBtn.style.border = "1px solid #ccc";
        copyBtn.style.borderRadius = "8px";
        copyBtn.style.cursor = "pointer";
        copyBtn.style.fontWeight = "500";
        copySection.appendChild(copyLabel);
        copySection.appendChild(copyBtn);
 
        const pasteSection = document.createElement("div");
        pasteSection.style.display = "grid";
        pasteSection.style.gap = "6px";
        const pasteLabel = document.createElement("div");
        pasteLabel.textContent = "Paste Gradient:";
        pasteLabel.style.fontSize = "12px";
        pasteLabel.style.color = "#333";
        const gradientInput = document.createElement("input");
        gradientInput.type = "text";
        gradientInput.id = "gradient-input";
        gradientInput.placeholder = "linear-gradient(...)";
        gradientInput.style.width = "100%";
        gradientInput.style.padding = "8px";
        gradientInput.style.borderRadius = "8px";
        gradientInput.style.border = "1px solid #ccc";
        gradientInput.style.background = "#fff";
        gradientInput.style.color = "#000";
        pasteSection.appendChild(pasteLabel);
        pasteSection.appendChild(gradientInput);
 
        const controls = document.createElement("div");
        controls.style.display = "grid";
        controls.style.gridTemplateColumns = "1fr 1fr 1fr 1fr";
        controls.style.gap = "8px";
 
        function smallBtn(txt) {
            const b = document.createElement("button");
            b.textContent = txt;
            b.style.height = "50px";
            b.style.border = "1px solid #ccc";
            b.style.borderRadius = "8px";
            b.style.fontWeight = "500";
            b.style.cursor = "pointer";
            b.style.background = "#f5f5f5";
            b.style.color = "#000";
            b.style.width = "100%";
            b.style.boxSizing = "border-box";
            return b;
        }
 
        const reloadBtn = smallBtn("Reload tracks");
        const allBtn = smallBtn("Select all");
        const noneBtn = smallBtn("Deselect all");
        const applyBtn = document.createElement("button");
        applyBtn.textContent = "Apply to selected";
        applyBtn.style.height = "42px";
        applyBtn.style.border = "none";
        applyBtn.style.borderRadius = "8px";
        applyBtn.style.fontWeight = "600";
        applyBtn.style.cursor = "pointer";
        applyBtn.style.background = "rgba(255, 255, 100, 1)";
        applyBtn.style.color = "#000";
        applyBtn.style.gridColumn = "span 4";
        controls.appendChild(reloadBtn);
        controls.appendChild(allBtn);
        controls.appendChild(noneBtn);
        controls.appendChild(applyBtn);
 
        const listWrap = document.createElement("div");
        listWrap.style.display = "grid";
        listWrap.style.gap = "6px";
        const listTitle = document.createElement("div");
        listTitle.textContent = "Tracks in this album (pick which to update)";
        listTitle.style.fontSize = "12px";
        listTitle.style.color = "#333";
        const list = document.createElement("div");
        list.style.maxHeight = "240px";
        list.style.overflow = "auto";
        list.style.background = "#fafafa";
        list.style.borderRadius = "8px";
        list.style.padding = "8px";
        listWrap.appendChild(listTitle);
        listWrap.appendChild(list);
 
        const bar = document.createElement("div");
        bar.style.height = "6px";
        bar.style.background = "#e0e0e0";
        bar.style.borderRadius = "999px";
        const fill = document.createElement("div");
        fill.style.height = "100%";
        fill.style.width = "0%";
        fill.style.background = "#ffd700";
        fill.style.borderRadius = "inherit";
        bar.appendChild(fill);
 
        const status = document.createElement("div");
        status.style.fontSize = "12px";
        status.style.color = "#333";
        status.textContent = "Ready";
 
        const close = document.createElement("button");
        close.textContent = "×";
        close.style.position = "absolute";
        close.style.top = "8px";
        close.style.right = "8px";
        close.style.background = "transparent";
        close.style.color = "#333";
        close.style.border = "none";
        close.style.cursor = "pointer";
        close.style.fontSize = "16px";
        close.setAttribute("aria-label", "Close");
        close.onclick = () => ui.remove();
 
        ui.appendChild(close);
        ui.appendChild(title);
        ui.appendChild(copySection);
        ui.appendChild(pasteSection);
        ui.appendChild(controls);
        ui.appendChild(listWrap);
        ui.appendChild(bar);
        ui.appendChild(status);
        document.body.appendChild(ui);
 
        return { ui, copyBtn, gradientInput, list, status, fill, reloadBtn, allBtn, noneBtn, applyBtn };
    }
 
    async function findCurrentGradient() {
        const candidates = [
            ...document.querySelectorAll('[class*="header"], [class*="album"], [class*="art"]'),
            ...document.querySelectorAll('[style*="gradient"], [data-gradient], [data-colors]')
        ];
        for (const el of candidates) {
            const style = window.getComputedStyle(el);
            if (style.backgroundImage.includes('gradient')) {
                return style.backgroundImage;
            }
            if (el.dataset.gradient) {
                return el.dataset.gradient;
            }
            if (el.dataset.primaryColor && el.dataset.secondaryColor) {
                return `linear-gradient(135deg, ${el.dataset.primaryColor}, ${el.dataset.secondaryColor})`;
            }
        }
        const metaGradient = document.querySelector('meta[name="gradient-colors"]');
        if (metaGradient) {
            try {
                const colors = JSON.parse(metaGradient.content);
                if (colors.primary && colors.secondary) {
                    return `linear-gradient(135deg, ${colors.primary}, ${colors.secondary})`;
                }
            } catch (e) {}
        }
        const allElements = document.querySelectorAll('*');
        for (const el of allElements) {
            const style = window.getComputedStyle(el);
            if (style.backgroundImage.includes('gradient')) {
                return style.backgroundImage;
            }
        }
        return null;
    }
 
    function parseGradient(gradient) {
        const colorPat = '(?:#[0-9a-fA-F]{3,6}|\\w+\\([^)]+\\)|[a-zA-Z]+)';
        const directionPat = '(?:to\\s+(?:top|bottom|left|right)(?:\\s+(?:top|bottom|left|right))?|\\d+deg)';
        const regex = new RegExp(`linear-gradient\\s*\\(\\s*(?:${directionPat}\\s*,)?\\s*(${colorPat})\\s*,\\s*(${colorPat})\\s*\\)`, 'i');
        const match = gradient.match(regex);
        if (match) {
            const color1 = match[1].trim();
            const color2 = match[2].trim();
            const primary = normalizeColor(color1);
            const secondary = normalizeColor(color2);
            if (primary && secondary) {
                return { primary, secondary };
            }
        }
        throw new Error('Invalid format.');
    }
 
    function normalizeColor(colorStr) {
        if (!colorStr) return null;
        if (/^#[0-9a-fA-F]{6}$/.test(colorStr)) {
            return colorStr.toUpperCase();
        }
        if (/^#[0-9a-fA-F]{3}$/.test(colorStr)) {
            return '#' + colorStr.slice(1).split('').map(c => c + c).join('').toUpperCase();
        }
        const rgbMatch = colorStr.match(/^rgb\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)$/i);
        if (rgbMatch) {
            const [_, r, g, b] = rgbMatch.map(Number);
            return `#${((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1).toUpperCase()}`;
        }
        const hslMatch = colorStr.match(/^hsl\(\s*(\d+)\s*,\s*(\d+)%?\s*,\s*(\d+)%?\s*\)$/i);
        if (hslMatch) {
            const [_, h, s, l] = hslMatch.map(Number);
            return hslToHex(h, s, l);
        }
        const namedColors = {
            'red': '#FF0000',
            'green': '#00FF00',
            'blue': '#0000FF',
            'black': '#000000',
            'white': '#FFFFFF'
        };
        if (colorStr.toLowerCase() in namedColors) {
            return namedColors[colorStr.toLowerCase()];
        }
        return null;
    }
 
    function hslToHex(h, s, l) {
        l /= 100;
        const a = s * Math.min(l, 1 - l) / 100;
        const f = n => {
            const k = (n + h / 30) % 12;
            const color = l - a * Math.max(Math.min(k - 3, 9 - k, 1), -1);
            return Math.round(255 * color).toString(16).padStart(2, '0');
        };
        return `#${f(0)}${f(8)}${f(4)}`.toUpperCase();
    }
 
    function hexToRgb(hex) {
        hex = hex.replace('#', '');
        if (hex.length === 3) {
            hex = hex.split('').map(c => c + c).join('');
        }
        return {
            r: parseInt(hex.substring(0, 2), 16),
            g: parseInt(hex.substring(2, 4), 16),
            b: parseInt(hex.substring(4, 6), 16)
        };
    }
 
    function getLuminance(rgb) {
        return (0.299 * rgb.r + 0.587 * rgb.g + 0.114 * rgb.b) / 255;
    }
 
    function computeTextColor(primary, secondary) {
        const rgb1 = hexToRgb(primary);
        const rgb2 = hexToRgb(secondary);
        const lum1 = getLuminance(rgb1);
        const lum2 = getLuminance(rgb2);
        const avgLum = (lum1 + lum2) / 2;
        return avgLum > 0.5 ? '#000' : '#fff';
    }
 
    function uniq(a) {
        return [...new Set(a)];
    }
 
    function getCsrf() {
        console.log('[Genius Gradient Assistant] Searching  CSRF token...');
        const cookie = document.cookie.split("; ").find(x => x.startsWith("_csrf_token="));
        if (cookie) {
            try {
                const token = decodeURIComponent(cookie.split("=")[1]);
                console.log(`[Genius Gradient Assistant] CSRF token found: ${token}`);
                return token;
            } catch (e) {
                console.warn('[Genius Gradient Assistant] Error decoding CSRF token:', e);
            }
        }
        console.warn('[Genius Gradient Assistant] CSRF token not found in cookies. Available cookies:', document.cookie.split("; "));
        return "";
    }
 
    function isInsideHotSongs(el) {
        let n = el;
        for (let i = 0; i < 8 && n; i++) {
            if (n.textContent && /^\s*hot songs\s*:?\s*$/i.test(n.textContent.trim())) return true;
            if (n.getAttribute && /hot[-_\s]?songs/i.test(n.getAttribute("aria-label") || "")) return true;
            n = n.parentElement;
        }
        return false;
    }
 
    function findAlbumTracklistContainer() {
        const a = document.querySelector('[data-test="album_tracklist"]');
        if (a) return a;
        const c = [...document.querySelectorAll('section,div,ol,ul')].find(n => /album.*tracklist/i.test(n.className) || /tracklist/i.test(n.getAttribute("data-test") || ""));
        if (c) return c;
        return null;
    }
 
    function collectAlbumAnchors() {
        const container = findAlbumTracklistContainer();
        let anchors = [];
        if (container) anchors = [...container.querySelectorAll('a[href*="-lyrics"]')];
        if (!anchors.length) {
            anchors = [...document.querySelectorAll('a[href*="-lyrics"]')].filter(a => !isInsideHotSongs(a));
        }
        anchors = anchors.filter(a => /https?:\/\/genius\.com\/[^?#]+-lyrics/i.test(a.href));
        anchors = anchors.filter(a => !isInsideHotSongs(a));
        return anchors.map(a => {
            const txt = (a && a.textContent || "").trim();
            let title = txt || decodeURIComponent(a.href.split("/").pop().replace(/-lyrics.*/i, "").replace(/-/g, " "));
            return { url: a.href, title };
        });
    }
 
    async function extractSongIdFromHtml(html) {
        const tries = [/"song"\s*:\s*{[^}]*"id"\s*:\s*(\d+)/i, /"song_id"\s*:\s*(\d+)/i, /data-song-id="(\d+)"/i, /rg_embed_link_(\d+)/i, /"pusher_channel"\s*:\s*"song-(\d+)"/i];
        for (const re of tries) {
            const m = html.match(re);
            if (m && m[1]) return m[1];
        }
        return null;
    }
 
    async function fetchSongIdByLyricUrl(url) {
        const res = await fetch(url, { credentials: "include" });
        const html = await res.text();
        return extractSongIdFromHtml(html);
    }
 
    async function putSongColors(id, primary, secondary, text, csrf) {
        const payload = { text_format: "html,markdown", song: { song_art_primary_color: primary, song_art_secondary_color: secondary, song_art_text_color: text, valid_song_art_contrast: true } };
        const res = await fetch(`https://genius.com/api/songs/${id}`, {
            method: "PUT",
            credentials: "include",
            headers: { "Content-Type": "application/json", "X-CSRF-Token": csrf, "Accept": "*/*" },
            body: JSON.stringify(payload)
        });
        if (!res.ok) throw new Error(String(res.status));
        return res.json();
    }
 
    function hex(v) {
        if (!v) return v;
        const x = v.trim().toLowerCase();
        if (/^#[0-9a-f]{6}$/.test(x)) return x;
        if (/^#[0-9a-f]{3}$/.test(x)) return "#" + x.slice(1).split("").map(c => c + c).join("");
        return x;
    }
 
    const state = { rows: [], data: [] };
    function renderList(items, list) {
        list.innerHTML = "";
        state.rows = [];
        state.data = items;
        items.forEach((it, idx) => {
            const row = document.createElement("label");
            row.style.display = "grid";
            row.style.gridTemplateColumns = "20px 1fr";
            row.style.gap = "8px";
            row.style.alignItems = "center";
            row.style.padding = "6px";
            row.style.borderRadius = "8px";
            row.style.cursor = "pointer";
            row.onmouseenter = () => row.style.background = "rgba(0,0,0,0.05)";
            row.onmouseleave = () => row.style.background = "transparent";
            const cb = document.createElement("input");
            cb.type = "checkbox";
            cb.checked = true;
            cb.dataset.index = String(idx);
            const tt = document.createElement("div");
            tt.style.fontSize = "13px";
            tt.style.whiteSpace = "nowrap";
            tt.style.overflow = "hidden";
            tt.style.textOverflow = "ellipsis";
            tt.textContent = it.title || it.url;
            row.appendChild(cb);
            row.appendChild(tt);
            list.appendChild(row);
            state.rows.push({ row, cb });
        });
    }
 
    function getSelected() {
        const out = [];
        state.rows.forEach((r, i) => { if (r.cb.checked) out.push(state.data[i]) });
        return out;
    }
 
    function setAll(val) {
        state.rows.forEach(r => r.cb.checked = val);
    }
 
    async function loadTracks(list, status) {
        status.textContent = "Scanning album tracklist…";
        const items = collectAlbumAnchors();
        renderList(items, list);
        status.textContent = `Loaded ${items.length} track(s).`;
    }
 
    let isProcessing = false;
    window.addEventListener('beforeunload', (e) => {
        if (isProcessing) {
            e.preventDefault();
            e.returnValue = 'Changes are being applied. Are you sure you want to leave?';
        }
    });
 
    const editBtn = document.createElement("button");
    editBtn.id = "gradient-assistant-toggle";
    editBtn.type = "button";
    editBtn.textContent = "Gradient Assistant";
    document.body.appendChild(editBtn);
 
    editBtn.onclick = async () => {
        const { ui, copyBtn, gradientInput, list, status, fill, reloadBtn, allBtn, noneBtn, applyBtn } = createUI();
        try {
            const gradient = await findCurrentGradient();
            if (gradient) {
                const colors = parseGradient(gradient);
                const gradientText = `linear-gradient(135deg, ${colors.primary}, ${colors.secondary})`;
                gradientInput.value = gradientText;
                await navigator.clipboard.writeText(gradientText);
                copyBtn.textContent = "Done!";
                setTimeout(() => copyBtn.textContent = "Copy Gradient", 2000);
            }
        } catch (error) {
            console.error('Error copying gradient:', error);
            status.textContent = `Error: ${error.message}`;
        }
 
        copyBtn.onclick = async () => {
            try {
                const gradient = await findCurrentGradient();
                if (!gradient) {
                    throw new Error('No visible gradient detected');
                }
                const colors = parseGradient(gradient);
                if (!colors) {
                    throw new Error('The found gradient is not in a valid format');
                }
                const gradientText = `linear-gradient(135deg, ${colors.primary}, ${colors.secondary})`;
                await navigator.clipboard.writeText(gradientText);
                copyBtn.textContent = "Done!";
                setTimeout(() => copyBtn.textContent = "Copy Gradient", 2000);
            } catch (error) {
                console.error('Error copying gradient:', error);
                status.textContent = `Error: ${error.message}`;
            }
        };
 
        reloadBtn.onclick = () => loadTracks(list, status);
        allBtn.onclick = () => setAll(true);
        noneBtn.onclick = () => setAll(false);
        applyBtn.onclick = async () => {
            const csrf = getCsrf();
            if (!csrf) {
                status.textContent = "Missing CSRF token.";
                return;
            }
            const gradientText = gradientInput.value.trim();
            if (!gradientText) {
                status.textContent = "Please paste a gradient.";
                return;
            }
            try {
                isProcessing = true;
                const { primary, secondary } = parseGradient(gradientText);
                const P = hex(primary);
                const S = hex(secondary);
                const T = computeTextColor(P, S);
                const sel = getSelected();
                if (!sel.length) {
                    status.textContent = "No tracks selected.";
                    return;
                }
                applyBtn.disabled = true;
                reloadBtn.disabled = true;
                allBtn.disabled = true;
                noneBtn.disabled = true;
                status.textContent = "Processing…";
                let done = 0, fail = 0;
                for (const it of sel) {
                    status.textContent = `Resolving ID…`;
                    let id = null;
                    try {
                        id = await fetchSongIdByLyricUrl(it.url);
                    } catch (e) {
                        console.warn(`Failed to fetch song ID for ${it.url}:`, e);
                    }
                    if (!id) {
                        fail++;
                        done++;
                        fill.style.width = ((done / sel.length) * 100).toFixed(1) + "%";
                        continue;
                    }
                    status.textContent = `Updating #${id}…`;
                    try {
                        await putSongColors(id, P, S, T, csrf);
                    } catch (e) {
                        console.warn(`Failed to update colors for song #${id}:`, e);
                        fail++;
                    }
                    done++;
                    fill.style.width = ((done / sel.length) * 100).toFixed(1) + "%";
                    await new Promise(r => setTimeout(r, 400));
                }
                status.textContent = `Done: ${sel.length - fail}/${sel.length}`;
            } catch (e) {
                status.textContent = `Error: ${e.message}`;
            } finally {
                applyBtn.disabled = false;
                reloadBtn.disabled = false;
                allBtn.disabled = false;
                noneBtn.disabled = false;
                isProcessing = false;
            }
        };
 
        loadTracks(list, status);
    };
})();