Brain.fm Automator

Enhanced audio downloader + auto-signup for Brain.fm with ID3 metadata embedding

K instalaci tototo skriptu si budete muset nainstalovat rozšíření jako Tampermonkey, Greasemonkey nebo Violentmonkey.

You will need to install an extension such as Tampermonkey to install this script.

K instalaci tohoto skriptu si budete muset nainstalovat rozšíření jako Tampermonkey nebo Violentmonkey.

K instalaci tohoto skriptu si budete muset nainstalovat rozšíření jako Tampermonkey nebo Userscripts.

You will need to install an extension such as Tampermonkey to install this script.

K instalaci tohoto skriptu si budete muset nainstalovat manažer uživatelských skriptů.

(Už mám manažer uživatelských skriptů, nechte mě ho nainstalovat!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(Už mám manažer uživatelských stylů, nechte mě ho nainstalovat!)

// ==UserScript==
// @name         Brain.fm Automator
// @description  Enhanced audio downloader + auto-signup for Brain.fm with ID3 metadata embedding
// @author       Soul
// @version      3.1.0
// @match        https://my.brain.fm/*
// @match        https://my.brain.fm/player*
// @match        https://brain.fm/*
// @grant        GM_download
// @grant        GM_addStyle
// @grant        GM_xmlhttpRequest
// @grant        GM_getValue
// @grant        GM_setValue
// @run-at       document-idle
// @namespace https://greasyfork.org/users/1597689
// ==/UserScript==


(function () {
  'use strict';

  // ===== CONFIGURATION =====
  const CONFIG = {
    WIDGET_ID: 'brainfm-unified-widget-fixed',
    POS_KEY: 'brainfm_widget_pos',
    META_KEY: 'brainfm_meta_enabled',
    DRAG_THRESHOLD: 3,
    OBSERVER_DEBOUNCE: 250,
    DOWNLOAD_TIMEOUT: 120000,
    STATE_CHECK_INTERVAL: 800,
    MAX_RETRY_ATTEMPTS: 3,
    METADATA_ENABLED: true, // Default: embed metadata
    SELECTORS: {
      trackTitle: '[data-testid="currentTrackTitle"]',
      trackGenre: '[data-testid="trackGenre"]',
      trackArt: "[data-testid='currentTrackInformationCard'] img.sc-6e0b0444-1",
      profileTab: '[data-testid="sideDeckProfileTab"]',
      audio: 'audio',
      source: 'source[src]'
    }
  };

  // ===== UTILITIES =====
  const $ = (sel, ctx = document) => ctx.querySelector(sel);
  const $$ = (sel, ctx = document) => Array.from(ctx.querySelectorAll(sel));
  const sleep = ms => new Promise(r => setTimeout(r, ms));
  const log = (...a) => console.log('%c[BrainFM]', 'color:#7df', ...a);
  const warn = (...a) => console.warn('%c[BrainFM]', 'color:#fa3', ...a);

  // Safe storage wrapper
  const storage = {
    get: (key, fallback) => {
      try {
        const raw = typeof GM_getValue === 'function' 
          ? GM_getValue(key) 
          : localStorage.getItem(key);
        return raw ? JSON.parse(raw) : fallback;
      } catch { return fallback; }
    },
    set: (key, val) => {
      try {
        const str = JSON.stringify(val);
        typeof GM_setValue === 'function' 
          ? GM_setValue(key, val) 
          : localStorage.setItem(key, str);
      } catch (e) { warn('Storage error:', e); }
    }
  };

  const debounce = (fn, wait) => {
    let t; return (...a) => { clearTimeout(t); t = setTimeout(() => fn.apply(null, a), wait); };
  };

  // ===== ID3 METADATA EMBEDDER (Minimal MP3 Support) =====
  const ID3Embedder = (() => {
    const writeID3 = (audioBuffer, metadata) => {
      const { title, artist, genre, coverBlob } = metadata;
      
      const processCover = async (blob) => {
        if (!blob) return null;
        const img = new Image();
        img.src = URL.createObjectURL(blob);
        await new Promise(r => img.onload = r);
        
        const maxDim = 600, quality = 0.85;
        let { width, height } = img;
        if (width > maxDim || height > maxDim) {
          const ratio = Math.min(maxDim / width, maxDim / height);
          width *= ratio; height *= ratio;
        }
        
        const canvas = Object.assign(document.createElement('canvas'), { width, height });
        const ctx = canvas.getContext('2d');
        ctx.drawImage(img, 0, 0, width, height);
        
        return new Promise(resolve => {
          canvas.toBlob(resolve, 'image/jpeg', quality);
          URL.revokeObjectURL(img.src);
        });
      };

      const createTextFrame = (id, text) => {
        if (!text) return null;
        const encoder = new TextEncoder();
        const encoded = encoder.encode(text);
        const frameSize = encoded.length + 1;
        return {
          id,
          data: new Uint8Array([0x00, ...encoded]),
          size: frameSize
        };
      };

      const createAPICFrame = (coverBuffer) => {
        if (!coverBuffer) return null;
        const encoder = new TextEncoder();
        const mimeType = encoder.encode('image/jpeg\0');
        const pictureType = new Uint8Array([0x03]);
        const description = encoder.encode('\0');
        
        const frameData = new Uint8Array(
          1 + mimeType.length + 1 + pictureType.length + description.length + coverBuffer.length
        );
        let offset = 0;
        frameData[offset++] = 0x00;
        frameData.set(mimeType, offset); offset += mimeType.length;
        frameData.set(pictureType, offset); offset += pictureType.length;
        frameData.set(description, offset); offset += description.length;
        frameData.set(coverBuffer, offset);
        
        return { id: 'APIC', data: frameData, size: frameData.length };
      };

      const encodeFrame = (frame) => {
        if (!frame) return null;
        const header = new Uint8Array(10);
        const encoder = new TextEncoder();
        encoder.encodeInto(frame.id, header);
        header[4] = (frame.size >> 24) & 0xFF;
        header[5] = (frame.size >> 16) & 0xFF;
        header[6] = (frame.size >> 8) & 0xFF;
        header[7] = frame.size & 0xFF;
        header[8] = 0x00; header[9] = 0x00;
        return new Uint8Array([...header, ...frame.data]);
      };

        /* eslint-disable no-async-promise-executor */
        /* eslint-disable no-unused-vars */
      return new Promise(async (resolve, reject) => {
        try {
          const frames = [];
          
          const titleFrame = createTextFrame('TIT2', title);
          const artistFrame = createTextFrame('TPE1', artist || 'Brain.fm');
          const genreFrame = createTextFrame('TCON', genre || 'Ambient');
          if (titleFrame) frames.push(encodeFrame(titleFrame));
          if (artistFrame) frames.push(encodeFrame(artistFrame));
          if (genreFrame) frames.push(encodeFrame(genreFrame));
          
          if (coverBlob) {
            const processedCover = await processCover(coverBlob);
            if (processedCover) {
              const coverBuffer = new Uint8Array(await processedCover.arrayBuffer());
              const apicFrame = createAPICFrame(coverBuffer);
              if (apicFrame) frames.push(encodeFrame(apicFrame));
            }
          }
          
          if (frames.length === 0) return resolve(audioBuffer);
          
          const tagSize = frames.reduce((sum, f) => sum + f.length, 0) + 10;
          const tagHeader = new Uint8Array(10);
          tagHeader.set([0x49, 0x44, 0x33, 0x03, 0x00, 0x00, 0x00, 0x00]);
          tagHeader[6] = (tagSize >> 21) & 0x7F;
          tagHeader[7] = (tagSize >> 14) & 0x7F;
          tagHeader[8] = (tagSize >> 7) & 0x7F;
          tagHeader[9] = tagSize & 0x7F;
          
          const newBuffer = new Uint8Array(tagSize + audioBuffer.byteLength);
          let offset = 0;
          newBuffer.set(tagHeader, offset); offset += 10;
          for (const frame of frames) {
            newBuffer.set(frame, offset); offset += frame.length;
          }
          newBuffer.set(new Uint8Array(audioBuffer), offset);
          
          resolve(newBuffer.buffer);
        } catch (e) {
          warn('ID3 embedding failed:', e);
          resolve(audioBuffer);
        }
      });
    };

    return { writeID3 };
  })();

  // ===== STYLES =====
  const createStyles = () => {
    const css = `
      #${CONFIG.WIDGET_ID} {
        position: fixed; top: 20px; right: 20px; z-index: 2147483647;
        display: flex; flex-direction: column; gap: 8px;
        padding: 12px 14px; border-radius: 16px;
        border: 1px solid rgba(255,255,255,0.25);
        background: rgba(255,255,255,0.08);
        backdrop-filter: blur(30px) saturate(180%) brightness(1.25);
        -webkit-backdrop-filter: blur(30px) saturate(180%) brightness(1.25);
        box-shadow: inset 0 0 0 1px rgba(255,255,255,0.35), 0 8px 32px rgba(0,0,0,0.35);
        color: #fff; font: 13px/1.4 system-ui, -apple-system, "Segoe UI", Roboto, Arial, sans-serif;
        cursor: grab; user-select: none; min-width: 190px; max-width: 44vw;
        transition: all 0.25s ease; will-change: transform;
      }
      #${CONFIG.WIDGET_ID}:hover {
        background: rgba(255,255,255,0.14); border-color: rgba(255,255,255,0.4);
        box-shadow: inset 0 0 0 1.2px rgba(255,255,255,0.5), 0 10px 36px rgba(0,0,0,0.45);
        transform: translateY(-1px);
      }
      #${CONFIG.WIDGET_ID}.dragging { cursor: grabbing; transition: none; }
      #${CONFIG.WIDGET_ID}.error { animation: errflash 0.9s ease; }
      #${CONFIG.WIDGET_ID} .row { display: flex; gap: 8px; align-items: center; }
      #${CONFIG.WIDGET_ID} .primary-btn {
        all: unset; cursor: pointer; flex: 1; padding: 8px 12px; text-align: center;
        border-radius: 10px; background: linear-gradient(145deg, rgba(255,255,255,0.25), rgba(255,255,255,0.05));
        border: 1px solid rgba(255,255,255,0.35); color: #fff; font-weight: 600;
        backdrop-filter: blur(16px); transition: all 0.2s ease;
      }
      #${CONFIG.WIDGET_ID} .primary-btn:hover:not([disabled]) {
        background: linear-gradient(145deg, rgba(255,255,255,0.4), rgba(255,255,255,0.1));
        transform: translateY(-1px); box-shadow: 0 4px 14px rgba(0,0,0,0.35);
      }
      #${CONFIG.WIDGET_ID} .primary-btn[disabled] { opacity: 0.6; cursor: not-allowed; }
      #${CONFIG.WIDGET_ID} .meta {
        font-size: 12px; opacity: 0.9; overflow: hidden;
        text-overflow: ellipsis; white-space: nowrap; max-width: 100%;
      }
      #${CONFIG.WIDGET_ID} .icon-btn {
        all: unset; width: 36px; height: 36px; display: inline-flex;
        align-items: center; justify-content: center; border-radius: 10px;
        background: rgba(255,255,255,0.1); border: 1px solid rgba(255,255,255,0.25);
        color: #fff; cursor: pointer; font-weight: 700; transition: all 0.2s ease;
      }
      #${CONFIG.WIDGET_ID} .icon-btn:hover { background: rgba(255,255,255,0.2); transform: scale(1.08); }
      #${CONFIG.WIDGET_ID} .settings-row {
        justify-content: space-between; font-size: 11px; opacity: 0.85;
        display: flex; align-items: center; gap: 6px;
      }
      #${CONFIG.WIDGET_ID} .settings-row label {
        display: flex; align-items: center; gap: 6px; cursor: pointer;
      }
      #${CONFIG.WIDGET_ID} .settings-row input[type="checkbox"] {
        cursor: pointer; accent-color: #7df;
      }
      @keyframes errflash {
        0%,100% { box-shadow: 0 8px 32px rgba(0,0,0,0.35); }
        15% { box-shadow: 0 0 0 3px rgba(255,0,0,0.55); }
      }
      @media (prefers-reduced-motion: reduce) {
        #${CONFIG.WIDGET_ID}, #${CONFIG.WIDGET_ID} * { transition: none !important; animation: none !important; }
      }
    `;
    if (typeof GM_addStyle === 'function') GM_addStyle(css);
    else { const s = document.createElement('style'); s.textContent = css; document.head.appendChild(s); }
  };

  // ===== WIDGET MANAGER =====
  const Widget = (() => {
    let el, state = { downloading: false, dragging: false, audioSrc: null };

    const create = () => {
      if (document.getElementById(CONFIG.WIDGET_ID)) return;
      
      el = document.createElement('div');
      el.id = CONFIG.WIDGET_ID;
      el.innerHTML = `
        <div class="meta" id="meta-display">Initializing...</div>
        <div class="row">
          <button class="primary-btn" id="btn-download">🎧 Download Audio</button>
          <button class="icon-btn" id="btn-refresh" title="Refresh">↻</button>
        </div>
        <div class="row">
          <button class="primary-btn" id="btn-auto">New Acc</button>
      `;
      document.body.appendChild(el);
      return el;
    };

    const restorePosition = () => {
      const pos = storage.get(CONFIG.POS_KEY, null);
      if (!pos?.left || !pos?.top) return;
      const { left, top } = pos;
      const maxL = window.innerWidth - 200, maxT = window.innerHeight - 100;
      el.style.cssText += `left:${Math.max(4, Math.min(left, maxL))}px;top:${Math.max(4, Math.min(top, maxT))}px;right:auto;bottom:auto;`;
    };

    const savePosition = () => {
      const r = el.getBoundingClientRect();
      storage.set(CONFIG.POS_KEY, { left: r.left, top: r.top });
    };

    const setupDrag = () => {
      let startX, startY, origX, origY, moved = false;

      const onDown = (e) => {
        if (e.button !== 0 || e.target.closest('button')) return;
        e.preventDefault();
        const rect = el.getBoundingClientRect();
        startX = e.clientX; startY = e.clientY;
        origX = rect.left; origY = rect.top;
        moved = false;
        state.dragging = true;
        el.classList.add('dragging');
        el.setPointerCapture?.(e.pointerId);
        document.addEventListener('pointermove', onMove);
        document.addEventListener('pointerup', onUp);
      };

      const onMove = (e) => {
        if (!state.dragging) return;
        const dx = e.clientX - startX, dy = e.clientY - startY;
        if (!moved && (Math.abs(dx) > CONFIG.DRAG_THRESHOLD || Math.abs(dy) > CONFIG.DRAG_THRESHOLD)) {
          moved = true; el.style.transition = 'none';
        }
        if (!moved) return;
        const maxL = window.innerWidth - el.offsetWidth - 4;
        const maxT = window.innerHeight - el.offsetHeight - 4;
        el.style.left = `${Math.max(4, Math.min(origX + dx, maxL))}px`;
        el.style.top = `${Math.max(4, Math.min(origY + dy, maxT))}px`;
        el.style.right = 'auto'; el.style.bottom = 'auto';
      };

      const onUp = (e) => {
        if (!state.dragging) return;
        state.dragging = false;
        el.classList.remove('dragging');
        el.style.transition = '';
        el.releasePointerCapture?.(e.pointerId);
        document.removeEventListener('pointermove', onMove);
        document.removeEventListener('pointerup', onUp);
        if (moved) savePosition();
      };

      el.addEventListener('pointerdown', onDown);
      el.addEventListener('dragstart', e => e.preventDefault());
    };

    const updateState = (info) => {
      const meta = $('#meta-display', el);
      const btn = $('#btn-download', el);
      if (!meta || !btn) return;
      
      if (info?.title) {
        const display = info.title.length > 30 ? info.title.slice(0, 27) + '...' : info.title;
        meta.textContent = display;
        meta.title = info.title;
      }
      btn.disabled = state.downloading || !info?.src;
      btn.textContent = state.downloading ? '⏳ Processing...' : '🎧 Download Audio';
    };

    const flashError = () => {
      el.classList.add('error');
      setTimeout(() => el.classList.remove('error'), 900);
    };

    return { create, restorePosition, setupDrag, updateState, flashError, getEl: () => el, getState: () => state, setState: (s) => Object.assign(state, s) };
  })();

// ===== AUDIO HANDLER =====
const decodeTrackName = (str) => {
  if (!str) return '';
  return str
    .replace(/([a-z])([A-Z])/g, '$1 $2')
    .replace(/_/g, ' ')
    .replace(/\s+/g, ' ')
    .trim();
};

const AudioHandler = (() => {
  const listened = new WeakSet();
  let lastValidInfo = null;
  let currentPageType = null;

  const sanitize = (name) => (name || 'audio').replace(/[/\\?%*:|"<>]/g, '_').trim() || 'audio';
  
  const getExt = (url, ct) => {
    const urlExt = url?.match(/\.(\w+)(?:[?#]|$)/i)?.[1]?.toLowerCase();
    if (urlExt && ['mp3','wav','ogg','m4a','aac','flac','opus'].includes(urlExt)) return urlExt;
    const ctMap = {
      'audio/mpeg':'mp3','audio/mp3':'mp3','audio/ogg':'ogg','audio/opus':'opus',
      'audio/aac':'aac','audio/wav':'wav','audio/x-wav':'wav','audio/flac':'flac',
      'audio/mp4':'m4a','audio/m4a':'m4a'
    };
    return ctMap[ct?.toLowerCase()] || 'mp3';
  };
/* eslint-disable no-empty */
  const parseCD = (cd) => {
    if (!cd) return '';
    const m1 = cd.match(/filename\*\s*=\s*[^']*'[^']*'([^;]+)/i);
    if (m1) { try { return decodeURIComponent(m1[1]); } catch {} }
    const m2 = cd.match(/filename\s*=\s*"?([^";]+)"?/i);
    return m2?.[1] || '';
  };

  const getHeader = (headers, key) => {
    if (!headers) return '';
    const k = key.toLowerCase();
    return headers.split(/\r?\n/).find(l => l.toLowerCase().startsWith(k + ':'))?.split(':', 2)[1]?.trim() || '';
  };

  // 🎨 NEW: Extract track cover art URL
  const getTrackArtUrl = () => {
    // Primary: Player page card image
    const cardImg = $(CONFIG.SELECTORS.trackArt);
    if (cardImg?.src && cardImg.src.includes('unsplash.com')) {
      return cardImg.src.replace('&w=1080', '&w=1440'); // Higher res
    }
    // Fallback: Mini-player on homepage (styled-components class)
    const miniImg = $('.bMnsXL ~ div img') || $('[class*="currentTrack"] img');
    if (miniImg?.src) return miniImg.src;
    return null;
  };

  const getTrackInfo = () => {
    let title = null, genre = null;
    
    if (location.pathname.startsWith('/player')) {
      title = $("[data-testid='currentTrackTitle']")?.textContent?.trim();
      genre = $("[data-testid='trackGenre']")?.textContent?.trim();
    }
    
    if (!title) {
      const titleEl = $('.bMnsXL');
      const genreEl = $('[class*="gustOw"][color]');
      if (titleEl?.textContent?.trim()) title = titleEl.textContent.trim();
      if (genreEl?.textContent?.trim()) genre = genreEl.textContent.trim();
    }
    
    if (!title) {
      const audio = $$('audio').find(a => a.src || a.currentSrc);
      if (audio) {
        const src = audio.src || audio.currentSrc;
        const filename = src?.split('/').pop()?.split('?')[0] || '';
        if (filename) {
          const parts = filename.replace('.mp3', '').split('_');
          if (parts.length >= 2) {
            title = parts[0].replace(/([a-z])([A-Z])/g, '$1 $2').trim();
            const possibleGenre = parts.find(p => 
              !/^\d+$/.test(p) && !p.includes('BPM') && !p.includes('VPR')
            );
            if (possibleGenre && possibleGenre !== parts[0]) {
              genre = possibleGenre.charAt(0).toUpperCase() + possibleGenre.slice(1);
            }
          }
        }
      }
    }
    
    if (title) return { title: genre ? `${title} - ${genre}` : title, genre };
    return null;
  };

  const findAudio = () => {
    const candidates = $$('audio').map(audio => {
      let src = audio.src || audio.currentSrc;
      if (!src) {
        const s = $('source[src]', audio);
        if (s) src = s.src || s.getAttribute('src');
      }
      return src ? { audio, src: new URL(src, location.href).href } : null;
    }).filter(Boolean);

    const playing = candidates.find(c => !c.audio.paused && c.audio.currentTime > 0);
    return playing || candidates[0] || null;
  };

  const attachListeners = (audio) => {
    if (listened.has(audio)) return;
    listened.add(audio);
    const update = debounce(() => {
      const info = buildAudioInfo();
      if (info) Widget.updateState(info);
    }, 100);
    
    ['loadedmetadata','canplay','play','pause','ended','emptied','stalled','suspend','error'].forEach(ev => 
      audio.addEventListener(ev, update, { passive: true })
    );
    $$('source[src]', audio).forEach(s => 
      ['load','error'].forEach(ev => s.addEventListener(ev, update, { passive: true }))
    );
  };

  const buildAudioInfo = () => {
    const item = findAudio();
    if (!item) {
      if (lastValidInfo) return { ...lastValidInfo, title: lastValidInfo.title + ' (paused)' };
      return null;
    }
    
    attachListeners(item.audio);
    const track = getTrackInfo();
    const ext = getExt(item.src);
    
    const urlBase = item.src.split('/').pop()?.split('?')[0] || 'audio';
    const base = sanitize(track?.title || decodeTrackName(urlBase.replace(/\.[^.]+$/, '')));
    const filename = `${base}.${ext}`;
    
    const info = { 
      src: item.src, 
      title: track?.title || decodeTrackName(urlBase), 
      filename,
      genre: track?.genre,
      coverUrl: getTrackArtUrl() // 🎨 Include cover URL
    };
    
    if (track?.title) lastValidInfo = info;
    return info;
  };

  const scan = debounce(() => {
    const info = buildAudioInfo();
    if (info) {
      Widget.updateState(info);
    } else if (!lastValidInfo) {
      Widget.updateState({ title: 'No audio detected' });
    }
  }, CONFIG.OBSERVER_DEBOUNCE);

  const initObserver = () => {
    const mo = new MutationObserver(scan);
    mo.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['src'] });
    
    let lastURL = location.href;
    const urlObserver = setInterval(() => {
      if (location.href !== lastURL) {
        lastURL = location.href;
        currentPageType = null;
        scan();
      }
    }, 500);
    
    document.addEventListener('visibilitychange', () => { 
      if (document.visibilityState === 'visible') scan(); 
    });
    
    window.addEventListener('beforeunload', () => clearInterval(urlObserver));
    
    return scan;
  };

  return { buildAudioInfo, initObserver, getExt, sanitize, parseCD, getHeader, getTrackArtUrl, getTrackInfo };
})();

  // ===== DOWNLOADER =====
  const Downloader = (() => {
    const gmXhr = (opts) => {
      if (typeof GM_xmlhttpRequest === 'function') return GM_xmlhttpRequest(opts);
      if (typeof GM?.xmlHttpRequest === 'function') return GM.xmlHttpRequest(opts);
      throw new Error('GM_xhr unavailable');
    };

    const downloadBlob = (blob, filename, type) => {
      const url = URL.createObjectURL(blob);
      const a = Object.assign(document.createElement('a'), { href: url, download: filename, rel: 'noopener' });
      document.body.appendChild(a); a.click(); a.remove();
      setTimeout(() => URL.revokeObjectURL(url), 100);
    };

    // 🎨 UPDATED: viaGMXhr now accepts metadata parameter
    const viaGMXhr = async (url, filename, metadata = {}) => {
      return new Promise((resolve, reject) => {
        gmXhr({
          method: 'GET', url, responseType: 'arraybuffer',
          headers: { Referer: location.href, Accept: 'audio/*', 'Cache-Control': 'no-cache' },
          timeout: CONFIG.DOWNLOAD_TIMEOUT,
          onload: async (r) => {
            if (!r.response) return reject('Empty response');
            
            let blob = new Blob([new Uint8Array(r.response)], { 
              type: AudioHandler.getHeader(r.responseHeaders, 'content-type') || 'audio/mpeg' 
            });
            
            // 🎨 Embed metadata if enabled, MP3 format, and metadata provided
            const isMp3 = filename.toLowerCase().endsWith('.mp3');
            if (CONFIG.METADATA_ENABLED && isMp3 && (metadata.title || metadata.coverUrl)) {
              try {
                let coverBlob = null;
                
                // Fetch cover art if URL provided
                if (metadata.coverUrl) {
                  coverBlob = await new Promise((res, rej) => {
                    gmXhr({
                      url: metadata.coverUrl,
                      responseType: 'blob',
                      headers: { Referer: location.href },
                      onload: (imgResp) => res(imgResp.response),
                      onerror: rej,
                      timeout: 15000
                    });
                  });
                  log('🖼️ Cover art fetched');
                }
                
                // Embed ID3 tags
                const taggedBuffer = await ID3Embedder.writeID3(r.response, {
                  title: metadata.title,
                  artist: 'Brain.fm',
                  genre: metadata.genre,
                  coverBlob
                });
                
                blob = new Blob([taggedBuffer], { type: 'audio/mpeg' });
                log('✅ Metadata embedded successfully');
              } catch (e) {
                warn('⚠️ Metadata embedding skipped:', e);
                // Continue with original blob - don't fail the download
              }
            }
            
            downloadBlob(blob, filename, blob.type);
            resolve();
          },
          onerror: reject, ontimeout: reject
        });
      });
    };

    const viaGMDownload = (url, filename, metadata = {}) => new Promise((resolve, reject) => {
      if (typeof GM_download !== 'function') return reject('GM_download unavailable');
      // Note: GM_download doesn't support post-processing, so metadata won't be embedded
      GM_download({
        url, name: filename, saveAs: true,
        onload: resolve, onerror: reject, ontimeout: reject, timeout: CONFIG.DOWNLOAD_TIMEOUT
      });
    });

    const viaAnchor = (url, filename) => {
      const a = Object.assign(document.createElement('a'), {
        href: url, download: filename, rel: 'noopener', target: '_blank'
      });
      document.body.appendChild(a); a.click(); a.remove();
    };

    // 🎨 UPDATED: download now accepts metadata parameter
    const download = async (url, filename, metadata = {}) => {
      // Try methods in order of reliability
      // Prefer GM_xhr for metadata embedding support
      try { await viaGMXhr(url, filename, metadata); return true; } catch {}
      try { await viaGMDownload(url, filename, metadata); return true; } catch {}
      viaAnchor(url, filename); return true; // Fallback (no metadata)
    };

    return { download };
  })();

// ===== SIGNUP AUTOMATION =====
  const AutoSignup = (() => {
    const find = (textRe, tag = '*') => 
      $$(tag).find(el => textRe.test(el.textContent?.trim() || el.getAttribute('aria-label') || ''));

    const fillInput = (sel, val) => {
      const el = $(sel); if (!el) return false;
      const proto = Object.getPrototypeOf(el);
      const desc = Object.getOwnPropertyDescriptor(proto, 'value');
      if (desc?.set) desc.set.call(el, val); else el.value = val;
      el.dispatchEvent(new Event('input', { bubbles: true }));
      el.dispatchEvent(new Event('change', { bubbles: true }));
      return true;
    };

    const clickEl = async (el) => {
      if (!el) return false;
      el.scrollIntoView({ behavior: 'auto', block: 'center' });
      await sleep(200);
      ['mouseover','mousedown','mouseup','click'].forEach(ev => 
        el.dispatchEvent(new MouseEvent(ev, { bubbles: true, cancelable: true }))
      );
      return true;
    };

    const waitFor = async (sel, timeout = 10000) => {
      const start = Date.now();
      while (Date.now() - start < timeout) {
        const el = $(sel); if (el) return el;
        await sleep(300);
      }
      return null;
    };

    const handleConfirmationModal = async () => {
      const modal = await waitFor('[role="dialog"], .modal, [class*="modal"]', 3000);
      if (!modal) return false;

      const origConfirm = window.confirm;
      window.confirm = () => true;

      const okBtn = $$('button', modal).find(btn => 
        /^(ok|yes|confirm|log out|logout)$/i.test(btn.textContent?.trim() || '')
      );

      if (okBtn) {
        await clickEl(okBtn);
        await sleep(500);
        window.confirm = origConfirm;
        return true;
      }

      const firstBtn = $('button', modal);
      if (firstBtn && !/cancel|no|back/i.test(firstBtn.textContent?.trim() || '')) {
        await clickEl(firstBtn);
        await sleep(500);
        window.confirm = origConfirm;
        return true;
      }

      window.confirm = origConfirm;
      return false;
    };

    const logoutFlow = async () => {
      const openBtn = find(/open/i); if (!openBtn) return false;
      await clickEl(openBtn); await sleep(1500);
      
      const profile = await waitFor(CONFIG.SELECTORS.profileTab); if (!profile) return false;
      await clickEl(profile); await sleep(2000);
      
      const logout = $$('button,div,span').find(el => 
        el.textContent?.trim().toLowerCase() === 'logout' && el.offsetParent
      );
      
      if (logout) {
        await clickEl(logout);
        await sleep(500);
        await handleConfirmationModal();
        await sleep(1500);
        return true;
      }
      return false;
    };

    const signupFlow = async () => {
      if (!await waitFor('input[type="email"]', 15000)) { warn('Signup form timeout'); return; }
      
      const ts = Date.now(), rand = Math.random().toString(36).slice(2,7);
      const creds = {
        email: `user_${ts}@autotest.com`,
        username: `user_${rand}`,
        password: Math.random().toString(36).slice(2,10) + '!'
      };
      
      fillInput('input[id*="name"],input[name*="name"]', creds.username);
      fillInput('input[id*="email"],input[name*="email"]', creds.email);
      fillInput('input[id*="password"],input[name*="password"]', creds.password);
      log('📝 Filled:', creds.email);
      
      const submit = find(/create|sign\s*up/i, 'button');
      if (submit) { await clickEl(submit); log('✅ Submitted'); }
      else { warn('⚠️ Submit button not found'); }
      
      const check = setInterval(() => {
        if (location.href.includes('/welcome')) {
          clearInterval(check);
          const btn = $('#btn-auto'); if (btn) { btn.style.background = '#00c851'; btn.textContent = '✓ Success'; }
          log('🎉 Signup complete');
        }
      }, 800);
      setTimeout(() => clearInterval(check), 30000);
    };

    const run = async () => {
      const btn = Widget.getEl()?.querySelector('#btn-auto');
      if (!btn) return;
      
      btn.disabled = true; btn.style.opacity = '0.7';
      try {
        if (location.href.includes('/welcome')) {
          btn.style.background = '#00c851'; btn.textContent = '✓ Active';
          log('✅ Already on welcome page');
          return;
        }
        
        const didLogout = await logoutFlow();
        if (didLogout) await sleep(2000);
        await signupFlow();
      } catch (e) { warn('Auto-signup error:', e); }
      finally {
        btn.disabled = false; btn.style.opacity = '1';
        if (!location.href.includes('/welcome')) btn.textContent = 'New Acc';
      }
    };

    return { run };
  })();

  // ===== INITIALIZATION =====
  const init = () => {
    // Restore metadata toggle preference
    CONFIG.METADATA_ENABLED = storage.get(CONFIG.META_KEY, true);
    
    createStyles();
    Widget.create();
    Widget.restorePosition();
    Widget.setupDrag();
    
    // 🎨 Setup metadata toggle
    const metaToggle = $('#meta-toggle');
    if (metaToggle) {
      metaToggle.checked = CONFIG.METADATA_ENABLED;
      metaToggle.addEventListener('change', (e) => {
        CONFIG.METADATA_ENABLED = e.target.checked;
        storage.set(CONFIG.META_KEY, CONFIG.METADATA_ENABLED);
        log(`🎨 Metadata embedding: ${CONFIG.METADATA_ENABLED ? 'enabled' : 'disabled'}`);
      });
    }
    
    // Button handlers
    $('#btn-download')?.addEventListener('click', async () => {
      const { downloading } = Widget.getState();
      if (downloading) return;
      
      const info = AudioHandler.buildAudioInfo();
      if (!info?.src) { Widget.flashError(); return; }
      
      // 🎨 Prepare metadata for embedding
      const trackInfo = AudioHandler.getTrackInfo() || {};
      const metadata = {
        title: trackInfo.title,
        genre: trackInfo.genre,
        coverUrl: info.coverUrl
      };
      
      Widget.setState({ downloading: true });
      const originalTitle = info.title;
      Widget.updateState({ ...info, title: CONFIG.METADATA_ENABLED ? '⏳ Embedding...' : '⏳ Downloading...' });
      
      try {
        await Downloader.download(info.src, info.filename, metadata);
        Widget.updateState({ ...info, title: CONFIG.METADATA_ENABLED ? '✓ Saved + Art!' : '✓ Saved!' });
        setTimeout(() => Widget.updateState({ ...info, title: originalTitle }), 1500);
      } catch (e) {
        warn('Download failed:', e);
        Widget.flashError();
        Widget.updateState({ ...info, title: '✗ Failed' });
        setTimeout(() => Widget.updateState({ ...info, title: originalTitle }), 1500);
      } finally {
        Widget.setState({ downloading: false });
      }
    });
    
    $('#btn-refresh')?.addEventListener('click', (e) => { e.stopPropagation(); AudioHandler.initObserver()(); });
    $('#btn-auto')?.addEventListener('click', (e) => { e.stopPropagation(); AutoSignup.run(); });
    
    // Initial scan + observer
    AudioHandler.initObserver()();
    setTimeout(() => Widget.updateState(AudioHandler.buildAudioInfo()), CONFIG.STATE_CHECK_INTERVAL);
    
    log(`✅ Widget initialized v3.1.0 | Metadata: ${CONFIG.METADATA_ENABLED ? 'ON' : 'OFF'}`);
  };

  // Start when DOM ready
  if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
  else init();
})();